diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index e24a1715..6c0a6f85 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -15,9 +15,8 @@ import app.morphe.engine.isWindows import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_SIGNER_NAME -import app.morphe.engine.PatchEngine.Config.Companion.LEGACY_KEYSTORE_ALIAS -import app.morphe.engine.PatchEngine.Config.Companion.LEGACY_KEYSTORE_PASSWORD import app.morphe.engine.UpdateChecker +import app.morphe.engine.util.signWithLegacyFallback import app.morphe.engine.patches.LoadedBundle import app.morphe.engine.patches.PatchBundleLoader import app.morphe.library.installation.installer.* @@ -836,34 +835,24 @@ internal object PatchCommand : Callable { patchingResult.addStepResult( PatchingStep.SIGNING, { - fun signApk(alias: String, password: String) { + signWithLegacyFallback( + primary = ApkUtils.KeyStoreDetails( + keystoreFilePath, + keyStorePassword, + keyStoreEntryAlias, + keyStoreEntryPassword, + ), + allowLegacyFallback = keyStoreEntryAlias == DEFAULT_KEYSTORE_ALIAS && + keyStoreEntryPassword == DEFAULT_KEYSTORE_PASSWORD, + logger = logger, + ) { details -> ApkUtils.signApk( patchedApkFile, outputFilePath, signer, - ApkUtils.KeyStoreDetails( - keystoreFilePath, - keyStorePassword, - alias, - password, - ) + details, ) } - try { - signApk(keyStoreEntryAlias, keyStoreEntryPassword) - } catch (e: Exception){ - // Retry with legacy keystore defaults. - if (keyStoreEntryAlias == DEFAULT_KEYSTORE_ALIAS && - keyStoreEntryPassword == DEFAULT_KEYSTORE_PASSWORD && - keystoreFilePath.exists() - ) { - logger.info("Using legacy keystore credentials") - - signApk(LEGACY_KEYSTORE_ALIAS, LEGACY_KEYSTORE_PASSWORD) - } else { - throw e - } - } } ) } else { diff --git a/src/main/kotlin/app/morphe/engine/PatchEngine.kt b/src/main/kotlin/app/morphe/engine/PatchEngine.kt index cddc9cbe..26d64a2f 100644 --- a/src/main/kotlin/app/morphe/engine/PatchEngine.kt +++ b/src/main/kotlin/app/morphe/engine/PatchEngine.kt @@ -8,6 +8,7 @@ package app.morphe.engine +import app.morphe.engine.util.signWithLegacyFallback import app.morphe.patcher.Patcher import app.morphe.patcher.PatcherConfig import app.morphe.patcher.apk.ApkMerger @@ -244,7 +245,11 @@ object PatchEngine { } try { - fun signApk(details: ApkUtils.KeyStoreDetails) { + signWithLegacyFallback( + primary = keystoreDetails, + allowLegacyFallback = config.keystoreDetails == null, + logger = logger, + ) { details -> ApkUtils.signApk( rebuiltApk, tempOutput, @@ -252,25 +257,6 @@ object PatchEngine { details, ) } - - try { - signApk(keystoreDetails) - } catch (e: Exception) { - // Retry with legacy keystore defaults. - if (config.keystoreDetails == null && keystoreDetails.keyStore.exists()) { - logger.info("Using legacy keystore credentials") - - val legacyKeystoreDetails = ApkUtils.KeyStoreDetails( - keystoreDetails.keyStore, - null, - Config.LEGACY_KEYSTORE_ALIAS, - Config.LEGACY_KEYSTORE_PASSWORD, - ) - signApk(legacyKeystoreDetails) - } else { - throw e - } - } stepResults.add(StepResult(PatchStep.SIGNING, true)) } catch (e: Exception) { stepResults.add(StepResult(PatchStep.SIGNING, false, e.toString())) diff --git a/src/main/kotlin/app/morphe/engine/util/KeystoreSigner.kt b/src/main/kotlin/app/morphe/engine/util/KeystoreSigner.kt new file mode 100644 index 00000000..c5db9475 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/KeystoreSigner.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +import app.morphe.engine.PatchEngine +import app.morphe.patcher.apk.ApkUtils +import java.util.logging.Logger + +/** + * Signs with [primary] credentials, then retries the legacy default alias when + * the existing shared keystore predates the current defaults. + * + * If both attempts fail, keep the original exception as the primary failure + * because it describes the credentials the user actually expected to work. + */ +fun signWithLegacyFallback( + primary: ApkUtils.KeyStoreDetails, + allowLegacyFallback: Boolean, + logger: Logger, + sign: (ApkUtils.KeyStoreDetails) -> Unit, +) { + try { + sign(primary) + } catch (primaryError: Exception) { + if (!allowLegacyFallback || !primary.keyStore.exists()) throw primaryError + + logger.info( + "Default keystore credentials failed (${primaryError.message}). Retrying with legacy credentials" + ) + + val legacy = ApkUtils.KeyStoreDetails( + primary.keyStore, + primary.keyStorePassword, + PatchEngine.Config.LEGACY_KEYSTORE_ALIAS, + PatchEngine.Config.LEGACY_KEYSTORE_PASSWORD, + ) + + try { + sign(legacy) + } catch (legacyError: Exception) { + primaryError.addSuppressed(legacyError) + throw primaryError + } + } +} diff --git a/src/test/kotlin/app/morphe/engine/util/KeystoreSignerTest.kt b/src/test/kotlin/app/morphe/engine/util/KeystoreSignerTest.kt new file mode 100644 index 00000000..cb6e3236 --- /dev/null +++ b/src/test/kotlin/app/morphe/engine/util/KeystoreSignerTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +import app.morphe.engine.PatchEngine +import app.morphe.patcher.apk.ApkUtils +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import java.io.File +import java.util.logging.Logger +import kotlin.io.path.createTempDirectory + +class KeystoreSignerTest { + private val logger = Logger.getLogger(KeystoreSignerTest::class.java.name) + + @Test + fun `uses primary credentials when signing succeeds`() { + val primary = primaryDetails(existingKeystore()) + val attempts = mutableListOf() + + signWithLegacyFallback(primary, allowLegacyFallback = true, logger = logger) { details -> + attempts += details + } + + assertEquals(listOf(primary), attempts) + } + + @Test + fun `retries with legacy credentials when default credentials fail`() { + val primary = primaryDetails(existingKeystore()) + val attempts = mutableListOf() + + signWithLegacyFallback(primary, allowLegacyFallback = true, logger = logger) { details -> + attempts += details + if (attempts.size == 1) { + throw IllegalArgumentException("Keystore does not contain entry with alias ${details.alias}") + } + } + + assertEquals(2, attempts.size) + assertEquals(PatchEngine.Config.DEFAULT_KEYSTORE_ALIAS, attempts.first().alias) + assertEquals(PatchEngine.Config.DEFAULT_KEYSTORE_PASSWORD, attempts.first().password) + assertEquals(PatchEngine.Config.LEGACY_KEYSTORE_ALIAS, attempts.last().alias) + assertEquals(PatchEngine.Config.LEGACY_KEYSTORE_PASSWORD, attempts.last().password) + assertEquals(primary.keyStorePassword, attempts.last().keyStorePassword) + } + + @Test + fun `throws the primary failure when legacy retry also fails`() { + val primary = primaryDetails(existingKeystore()) + val primaryError = IllegalArgumentException("Keystore does not contain entry with alias ${primary.alias}") + val legacyError = IllegalArgumentException( + "Keystore does not contain entry with alias ${PatchEngine.Config.LEGACY_KEYSTORE_ALIAS}" + ) + + val thrown = assertThrows(IllegalArgumentException::class.java) { + signWithLegacyFallback(primary, allowLegacyFallback = true, logger = logger) { details -> + if (details.alias == primary.alias) { + throw primaryError + } + + throw legacyError + } + } + + assertSame(primaryError, thrown) + assertEquals(listOf(legacyError), thrown.suppressed.toList()) + } + + @Test + fun `does not retry when the keystore file does not exist`() { + val primary = primaryDetails(missingKeystore()) + val primaryError = IllegalArgumentException("missing primary entry") + var attempts = 0 + + val thrown = assertThrows(IllegalArgumentException::class.java) { + signWithLegacyFallback(primary, allowLegacyFallback = true, logger = logger) { + attempts += 1 + throw primaryError + } + } + + assertSame(primaryError, thrown) + assertEquals(1, attempts) + } + + @Test + fun `does not retry when legacy fallback is disabled`() { + val primary = primaryDetails(existingKeystore()) + val primaryError = IllegalArgumentException("custom credentials failed") + var attempts = 0 + + val thrown = assertThrows(IllegalArgumentException::class.java) { + signWithLegacyFallback(primary, allowLegacyFallback = false, logger = logger) { + attempts += 1 + throw primaryError + } + } + + assertSame(primaryError, thrown) + assertEquals(1, attempts) + } + + private fun primaryDetails(keystore: File) = ApkUtils.KeyStoreDetails( + keystore, + "store-pass", + PatchEngine.Config.DEFAULT_KEYSTORE_ALIAS, + PatchEngine.Config.DEFAULT_KEYSTORE_PASSWORD, + ) + + private fun existingKeystore(): File { + val tempDir = createTempDirectory().toFile().apply { deleteOnExit() } + return File(tempDir, "morphe.keystore").apply { + writeText("placeholder") + deleteOnExit() + } + } + + private fun missingKeystore(): File { + val tempDir = createTempDirectory().toFile().apply { deleteOnExit() } + return File(tempDir, "missing.keystore") + } +}