Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 13 additions & 24 deletions src/main/kotlin/app/morphe/cli/command/PatchCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down Expand Up @@ -836,34 +835,24 @@ internal object PatchCommand : Callable<Int> {
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 {
Expand Down
26 changes: 6 additions & 20 deletions src/main/kotlin/app/morphe/engine/PatchEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -244,33 +245,18 @@ object PatchEngine {
}

try {
fun signApk(details: ApkUtils.KeyStoreDetails) {
signWithLegacyFallback(
primary = keystoreDetails,
allowLegacyFallback = config.keystoreDetails == null,
logger = logger,
) { details ->
ApkUtils.signApk(
rebuiltApk,
tempOutput,
config.signerName,
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()))
Expand Down
48 changes: 48 additions & 0 deletions src/main/kotlin/app/morphe/engine/util/KeystoreSigner.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
}
128 changes: 128 additions & 0 deletions src/test/kotlin/app/morphe/engine/util/KeystoreSignerTest.kt
Original file line number Diff line number Diff line change
@@ -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<ApkUtils.KeyStoreDetails>()

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<ApkUtils.KeyStoreDetails>()

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")
}
}