diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreDeviceLockedException.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreDeviceLockedException.kt index d7df7d46939..2aa0d6516a8 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreDeviceLockedException.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreDeviceLockedException.kt @@ -23,13 +23,14 @@ data class DeviceLockState( /** * The Android Keystore denied an operation on a lock-screen-bound key * because ITS device-locked tracking says the device is locked — thrown by - * [KeystoreManager.encrypt] / [KeystoreManager.decrypt] for the - * [KeystoreManager.MASTER_ALIAS] AES key (which carries - * `setUnlockedDeviceRequired(true)` on lock-screen devices and NO - * `setUserAuthenticationRequired` gate, so a Keystore "user not - * authenticated" denial there can only mean the device-locked gate), and by - * the `PlatformWalletManager.createWallet` pre-check before any native - * wallet exists. + * [KeystoreManager.encrypt] / [KeystoreManager.decrypt] for the aliases + * that carry `setUnlockedDeviceRequired(true)` on lock-screen devices and + * NO `setUserAuthenticationRequired` gate, so a Keystore "user not + * authenticated" denial there can only mean the device-locked gate + * ([KeystoreManager.MASTER_ALIAS], the AES key; and + * [KeystoreManager.KEYS_ALIAS_DEVICE_BOUND], the non-auth-gated identity + * keypair — MO-972), and by the `PlatformWalletManager.createWallet` + * pre-check before any native wallet exists. * * **RETRYABLE AFTER UNLOCK.** This is never a permanent failure of the key * or the data: the exact same operation succeeds once the Keystore @@ -46,13 +47,22 @@ data class DeviceLockState( * observed in the field (two QA devices, wallet creation) — the device is * demonstrably unlocked but Keystore2's internal lock-state tracking * still says "locked". A short bounded retry is worthwhile (see - * [WalletStorage.storeMnemonic]); persistent recurrence points at the - * platform bug, not at this SDK or its keys. + * [WalletStorage.storeMnemonic]); recurrence past that schedule means + * the defect is PERSISTENT for the unlock session (an OEM unlock class + * that never satisfies `UNLOCKED_DEVICE_REQUIRED` — observed on + * HONOR/MagicOS Android 16; same mechanism as Google Issue Tracker + * 506989112), at which point [WalletStorage.storeMnemonic] degrades the + * write to the never-lock-bound + * [KeystoreManager.MASTER_ALIAS_UNBOUND] instead of throwing this — so + * escaping this exception false-locked now means even that degradation + * failed (see the suppressed exception). * - * NOT used for the auth-gated identity-key aliases: their - * `UserNotAuthenticatedException` means "auth window closed" and keeps its - * own prompt-and-retry contract via `BiometricGate` (see - * [KeystoreManager.decrypt]). + * NOT used for [KeystoreManager.KEYS_ALIAS_AUTH_GATED]: it carries BOTH + * gates, so the same `UserNotAuthenticatedException` may equally mean + * "auth window closed", and only that reading is fixable by prompting — + * it keeps its prompt-and-retry contract via `BiometricGate` (see + * [KeystoreManager.decrypt]). Nor for the `*_UNBOUND` aliases, which carry + * neither gate, so a denial there is not a lock denial at all. */ class KeystoreDeviceLockedException( /** Keystore alias whose operation was denied (or would be, for the pre-check). */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreManager.kt index ef99c57f884..f5dd8ac04e7 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreManager.kt @@ -34,6 +34,15 @@ import javax.crypto.spec.PSource * - [MASTER_ALIAS] `org.dashfoundation.wallet.master` — mnemonics and * general wallet secrets, under a non-auth AES-256-GCM key (name parity * with the iOS keychain service `org.dashfoundation.wallet`). + * - [MASTER_ALIAS_UNBOUND] `org.dashfoundation.wallet.master.unbound` — + * the same non-auth AES-256-GCM parameters as [MASTER_ALIAS] but + * guaranteed to NEVER carry `setUnlockedDeviceRequired`. The degradation + * target [WalletStorage] moves mnemonic blobs to on devices whose + * Keystore denies lock-bound operations while `KeyguardManager` reports + * the device unlocked (the persistent false-locked defect — an OEM + * unlock that never satisfies `UNLOCKED_DEVICE_REQUIRED`; see + * [KeystoreDeviceLockedException]). Provisioned lazily on first use, + * only ever on a device that demonstrated the defect. * - [KEYS_ALIAS_AUTH_GATED] `org.dashfoundation.wallet.keys.authgated` — * identity private keys under the default [KeySecurityPolicy.AUTH_GATED], * wrapped by an RSA-2048 OAEP(SHA-256) keypair. The PUBLIC key encrypts and @@ -338,6 +347,18 @@ open class KeystoreManager( e.addSuppressed(deleteError) } throw e + } catch (e: Exception) { + // A lock-bound identity alias denies exactly like the master + // AES key does, and Android reports it with the SAME + // `UserNotAuthenticatedException` it uses for a closed auth + // window. Left unclassified this arrives at `KeystoreSigner` + // looking like an expired auth window on a key that has no + // window at all (MO-972: DEVICE_BOUND signing died with + // "User not authenticated" one second after a successful + // biometric). The mapping is alias-gated — see + // [UNAMBIGUOUS_LOCK_BOUND_ALIASES] — so the auth-gated alias + // still reaches the BiometricGate untouched. + rethrowClassifyingDeviceLockedDenial(e, alias, operation = "decrypt") } return cipher.doFinal(blob.ciphertext) } @@ -387,7 +408,7 @@ open class KeystoreManager( alias: String, operation: String, ): Nothing { - if (alias == MASTER_ALIAS && isDeviceLockedKeystoreDenial(e)) { + if (alias in UNAMBIGUOUS_LOCK_BOUND_ALIASES && isDeviceLockedKeystoreDenial(e)) { throw KeystoreDeviceLockedException( alias = alias, operation = operation, @@ -454,6 +475,22 @@ open class KeystoreManager( open fun hasLegacyKeysKey(): Boolean = (androidKeyStore().getKey(KEYS_ALIAS, null) as? SecretKey) != null + /** + * Whether the never-lock-bound [MASTER_ALIAS_UNBOUND] AES key exists in + * THIS device's Keystore. + * + * Device-local evidence that the false-locked defect was demonstrated + * HERE. Keystore keys are non-exportable and never restored by Android + * backup or device-to-device transfer, so unlike the DataStore flag that + * records the defect this cannot travel to another handset — which is + * exactly what [WalletStorage.isMasterKeyLockBindingDefectObserved] + * needs to avoid authorizing the lock-gate downgrade on a healthy device + * that merely inherited a restored preference. Presence check only: no + * crypto, no prompt, and it never generates the key. + */ + open fun hasUnboundMasterKey(): Boolean = + (androidKeyStore().getKey(MASTER_ALIAS_UNBOUND, null) as? SecretKey) != null + /** * Whether [KEYS_ALIAS] currently holds the **former RSA identity-keys * keypair** from the pre-alias-split scheme (dashpay/platform#4060) — the @@ -640,6 +677,19 @@ open class KeystoreManager( KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE, ) + // MASTER_ALIAS_UNBOUND's whole contract is the ABSENCE of lock + // binding — it exists only as the false-locked degradation target — + // so it never enters the lock-screen ladder: the params are dropped + // unconditionally, not probed. StrongBox→TEE fallback still applies. + if (alias == MASTER_ALIAS_UNBOUND) { + return try { + generator.init(spec(strongBox = true, lockBound = false)) + generator.generateKey() + } catch (_: StrongBoxUnavailableException) { + generator.init(spec(strongBox = false, lockBound = false)) + generator.generateKey() + } + } return generateWithLockScreenDegradation(alias) { strongBox, lockBound -> generator.init(spec(strongBox, lockBound)) generator.generateKey() @@ -846,6 +896,19 @@ open class KeystoreManager( generator.initialize(spec(strongBox = false, lockBound = true)) generator.generateKeyPair() } + } else if (alias == KEYS_ALIAS_DEVICE_BOUND_UNBOUND) { + // This alias's whole contract is the ABSENCE of lock binding — it + // exists only as the false-locked degradation target — so it never + // enters the lock-screen ladder: the parameter is dropped + // unconditionally, not probed (the MASTER_ALIAS_UNBOUND rule). + // StrongBox→TEE fallback still applies. + try { + generator.initialize(spec(strongBox = true, lockBound = false)) + generator.generateKeyPair() + } catch (_: StrongBoxUnavailableException) { + generator.initialize(spec(strongBox = false, lockBound = false)) + generator.generateKeyPair() + } } else { // DEVICE_BOUND: no auth gate exists to lie about — dropping the // (inherently lock-dependent) setUnlockedDeviceRequired bit on a @@ -873,6 +936,24 @@ open class KeystoreManager( companion object { const val MASTER_ALIAS = "org.dashfoundation.wallet.master" + /** + * Never-lock-bound variant of [MASTER_ALIAS]: identical non-auth + * AES-256-GCM parameters, but `setUnlockedDeviceRequired` is never + * applied at generation regardless of the lock-screen probe (see + * [generateAesKey]). [WalletStorage] writes mnemonic blobs under + * this alias INSTEAD of [MASTER_ALIAS] once a device has + * demonstrated the persistent false-locked Keystore defect — the + * Keystore denying a lock-bound operation while `KeyguardManager` + * reports the device unlocked, past the bounded retry (an OEM + * unlock class that never satisfies `UNLOCKED_DEVICE_REQUIRED`; + * Google Issue Tracker 506989112). The same downgrade + * [generateWithLockScreenDegradation] already performs for lockless + * devices (dashpay/platform#4060), here triggered by operational + * evidence instead of a missing lock screen. Healthy devices never + * provision this alias. + */ + const val MASTER_ALIAS_UNBOUND = "org.dashfoundation.wallet.master.unbound" + /** * **Legacy** identity-keys alias. Across the SDK's history this single * alias has held, in turn, two now-superseded wrapping keys, so on an @@ -911,6 +992,35 @@ open class KeystoreManager( */ const val KEYS_ALIAS_DEVICE_BOUND = "org.dashfoundation.wallet.keys.devicebound" + /** + * Never-lock-bound variant of [KEYS_ALIAS_DEVICE_BOUND]: the same + * non-auth-gated RSA-2048 OAEP wrapping pair, but + * `setUnlockedDeviceRequired` is never applied at generation + * regardless of the lock-screen probe (see [ensureKeysKeyPair]). + * + * The identity-key counterpart of [MASTER_ALIAS_UNBOUND], and the + * degradation target [WalletStorage] moves identity-key blobs to on a + * device whose Keystore denies lock-bound operations while + * `KeyguardManager` reports it unlocked. Dropping the lock binding + * costs nothing this policy ever promised — + * [KeySecurityPolicy.DEVICE_BOUND] guarantees hardware-backed, + * non-exportable and NOT auth-gated, and both survive here; only the + * incidental "device must be unlocked right now" hardening is given + * up, on a device where that gate is broken anyway. + * + * There is deliberately NO auth-gated counterpart: dropping lock + * binding under [KeySecurityPolicy.AUTH_GATED] would leave that + * policy's real control (the authentication gate) as the only + * protection while making its failures harder to tell apart, and no + * field evidence puts a defective device on that alias. An auth-gated + * install on a defective device keeps failing honestly instead. + * + * Provisioned lazily on first use, only ever on a device that + * demonstrated the defect. + */ + const val KEYS_ALIAS_DEVICE_BOUND_UNBOUND = + "org.dashfoundation.wallet.keys.devicebound.unbound" + /** Auth window for the auth-gated identity-keys alias, in seconds. */ const val AUTH_VALIDITY_SECONDS = 30 @@ -921,7 +1031,32 @@ open class KeystoreManager( * never through the RSA encrypt/decrypt path. */ fun isIdentityKeysAlias(alias: String): Boolean = - alias == KEYS_ALIAS_AUTH_GATED || alias == KEYS_ALIAS_DEVICE_BOUND + alias == KEYS_ALIAS_AUTH_GATED || + alias == KEYS_ALIAS_DEVICE_BOUND || + alias == KEYS_ALIAS_DEVICE_BOUND_UNBOUND + + /** + * Aliases whose keys carry `setUnlockedDeviceRequired` but NO + * `setUserAuthenticationRequired` — the only ones where a Keystore + * `UserNotAuthenticatedException` is unambiguous. With no + * authentication gate to be "not authenticated" against, Keystore + * raises it solely for the unlocked-device requirement, so + * [rethrowClassifyingDeviceLockedDenial] can safely map it to the + * typed, retryable [KeystoreDeviceLockedException]. + * + * [KEYS_ALIAS_AUTH_GATED] is excluded and must stay excluded: it + * carries BOTH gates, so the same exception means either "the device + * is locked" or "the auth window closed", and only the latter is + * fixable by prompting. Classifying it would strand the + * `BiometricGate` prompt-and-retry contract. + * + * The `*_UNBOUND` aliases are excluded for the opposite reason — + * they carry neither gate, so a denial there is not a lock denial at + * all and must surface raw rather than as a "retry after unlock" + * that can never come good. + */ + private val UNAMBIGUOUS_LOCK_BOUND_ALIASES = + setOf(MASTER_ALIAS, KEYS_ALIAS_DEVICE_BOUND) /** * Whether the lock-screen-bound key-generation parameters diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt index 53cfebcde3b..5a2fbb82631 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt @@ -331,6 +331,20 @@ class KeystoreSigner( /** * Decrypt the key; on an expired auth window, run the biometric gate * once and retry — mirroring KeychainSigner's LAContext flow. + * + * A [KeystoreDeviceLockedException] deliberately does NOT come here. + * It is the typed "the Keystore refused a lock-bound key" signal, which + * `WalletStorage` now raises for the non-auth-gated identity alias too + * (MO-972). Android reports that denial with the very same + * `UserNotAuthenticatedException` as a closed auth window, and treating + * the two alike is what made the field failure unreadable: signing on a + * `DEVICE_BOUND` install — a policy with no auth window at all — was + * reported as "Keystore auth window expired" one second after a + * successful biometric. Prompting cannot help either: the gate tracks + * the device's lock state, not recency of authentication, so a prompt + * would burn a user interaction and fail identically. Letting the typed + * exception escape completes the sign with its own explicit message + * (which alias, and what `KeyguardManager` said at the time) instead. */ private suspend fun retrieveKeyWithAuth(storageKey: String): ByteArray? = try { diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt index 2a5f8b95235..cda484ee34b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt @@ -7,6 +7,7 @@ import android.security.keystore.UserNotAuthenticatedException import android.util.Log import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey @@ -31,7 +32,11 @@ private val Context.secretsStore: DataStore by preferencesDataStore * Values are ciphertext under [KeystoreManager]'s non-exportable Keystore * keys, stored base64 in a dedicated Preferences DataStore. * Key layout mirrors the iOS account naming: - * - `mnemonic.` — wallet mnemonics (master alias, AES-GCM) + * - `mnemonic.` — wallet mnemonics (master alias, AES-GCM; + * the producing alias is recorded per blob in + * `mnemonicalias.` once the false-locked degradation has + * moved writes to [KeystoreManager.MASTER_ALIAS_UNBOUND] — see + * [storeMnemonic]) * - `privkey.` — identity private keys (the [keystore]'s * [KeystoreManager.keysAlias]: RSA public-key encrypt / private-key * decrypt that is auth-gated or not per the keystore's @@ -235,11 +240,25 @@ class WalletStorage( * re-parameterizes anything — on a fresh install the probe provisions * the master key exactly as the first [storeMnemonic] would have. A * no-op — no Keystore access at all — when the device is unlocked - * (including keyguard-showing-but-not-secured states). + * (including keyguard-showing-but-not-secured states), and likewise + * once the false-locked defect is on record for this device + * ([isMasterKeyLockBindingDefectObserved]): from then on + * [storeMnemonic] writes under the never-lock-bound + * [MASTER_ALIAS_UNBOUND][KeystoreManager.MASTER_ALIAS_UNBOUND], which + * no lock state can deny, so there is nothing to preflight. */ - fun ensureMasterKeyNotLockBlocked(operation: String) { + internal suspend fun ensureMasterKeyNotLockBlocked(operation: String) { val state = keystore.sampleDeviceLockState() if (!state.isDeviceLocked) return + if (isMasterKeyLockBindingDefectObserved()) { + Log.i( + TAG, + "$operation: device is locked but this device's false-locked defect is on " + + "record — mnemonic writes target the never-lock-bound master alias, " + + "which no lock state can deny; proceeding", + ) + return + } try { keystore.encrypt(ByteArray(1)) Log.i( @@ -269,18 +288,43 @@ class WalletStorage( /** * Encrypt and persist the mnemonic under the - * [MASTER_ALIAS][KeystoreManager.MASTER_ALIAS] AES key. + * [MASTER_ALIAS][KeystoreManager.MASTER_ALIAS] AES key — or under the + * never-lock-bound + * [MASTER_ALIAS_UNBOUND][KeystoreManager.MASTER_ALIAS_UNBOUND] once + * this device has demonstrated the PERSISTENT false-locked Keystore + * defect (see below); the producing alias is recorded per blob + * (`mnemonicalias.`, same atomic edit) so reads decrypt + * under whichever alias actually wrote it. * - * Retries the FALSE-LOCKED Keystore denial only: when the encrypt is - * denied as device-locked but the sampled `KeyguardManager` state says - * the device is NOT actually locked ([KeystoreDeviceLockedException] - * with `deviceReportsLocked == false` — the Keystore2 lock-state - * misreporting defect, hit on two QA devices during wallet creation), - * the store is retried up to 3 times over ~2s (the - * [DEVICE_FALSE_LOCKED_RETRY_DELAYS_MS] backoff schedule) before the - * exception propagates. A GENUINELY locked - * device (`deviceReportsLocked == true`) fails fast with no retry — - * waiting 2s cannot unlock a phone; the caller retries after unlock. + * Device-locked denial handling, in escalation order: + * - A GENUINELY locked device ([KeystoreDeviceLockedException] with + * `deviceReportsLocked == true`) fails fast with no retry — waiting + * 2s cannot unlock a phone; the caller retries after unlock. + * - A FALSE-LOCKED denial (Keystore denies as device-locked while the + * sampled `KeyguardManager` state says unlocked) is retried up to 3 + * times over ~2s ([DEVICE_FALSE_LOCKED_RETRY_DELAYS_MS]) — enough + * for the transient Keystore2 misreporting first seen on two QA + * devices. + * - When the schedule exhausts still false-locked, the defect is + * persistent, not transient — an OEM unlock class that never + * satisfies `UNLOCKED_DEVICE_REQUIRED`, so no in-session retry can + * ever succeed (observed on HONOR/MagicOS Android 16; same + * mechanism as Google Issue Tracker 506989112). The store then + * DEGRADES instead of failing: the blob is encrypted under + * [MASTER_ALIAS_UNBOUND][KeystoreManager.MASTER_ALIAS_UNBOUND] and + * the defect durably recorded ([MASTER_LOCK_DEFECT_KEY], in the + * same atomic edit), after which every mnemonic write on this + * device goes straight to the unbound alias and + * [ensureMasterKeyNotLockBlocked] stops preflighting. This is the + * dashpay/platform#4060 no-lock-screen downgrade — hardware-backed + * AES, no lock binding — triggered by operational evidence instead + * of a missing lock screen, and only ever on the defective device. + * Nothing is deleted or re-keyed: existing [MASTER_ALIAS] blobs + * stay decryptable under their recorded alias and are re-wrapped + * opportunistically on their next successful read (see + * [retrieveMnemonicUtf8]). If the unbound encrypt itself fails, the + * original typed denial propagates with the heal failure attached + * as suppressed, and nothing is recorded. */ suspend fun storeMnemonic(walletId: ByteArray, mnemonic: String) { // The plaintext copy lives across the whole backoff schedule, so scrub @@ -289,36 +333,230 @@ class WalletStorage( // handling of its other raw secret arrays. val plaintext = mnemonic.encodeToByteArray() try { - var attempt = 0 - while (true) { - try { + if (isMasterKeyLockBindingDefectObserved()) { + storeMnemonicUnbound(walletId, plaintext) + return + } + retryingFalseLockedDenial( + operation = "storeMnemonic", + denied = "encrypt", + attempt = { val blob = keystore.encrypt(plaintext) - store.edit { it[mnemonicKey(walletId)] = encode(blob) } - return - } catch (e: KeystoreDeviceLockedException) { - if (e.deviceReportsLocked || - attempt >= DEVICE_FALSE_LOCKED_RETRY_DELAYS_MS.size - ) { - throw e + store.edit { + it[mnemonicKey(walletId)] = encode(blob) + // A MASTER_ALIAS blob is the untagged default. + it.remove(mnemonicAliasKey(walletId)) } - val delayMs = DEVICE_FALSE_LOCKED_RETRY_DELAYS_MS[attempt] - attempt++ - Log.w( - TAG, - "storeMnemonic: Keystore denied encrypt as device-locked but " + - "KeyguardManager reports UNLOCKED (${e.lockState}) — the " + - "false-locked Keystore2 defect; retry $attempt/" + - "${DEVICE_FALSE_LOCKED_RETRY_DELAYS_MS.size} in ${delayMs}ms", - e, - ) - delay(delayMs) - } - } + }, + onExhausted = { denial -> + healFalseLockedMnemonicStore(walletId, plaintext, denial) + }, + ) } finally { plaintext.fill(0) } } + /** + * Whether THIS device has demonstrated the persistent false-locked + * Keystore defect — a lock-bound master-alias operation denied as + * device-locked past the full bounded retry + * ([retryingFalseLockedDenial]) while `KeyguardManager` reported the + * device unlocked. + * + * Recorded durably from EITHER side of the alias, since either can be + * the first to meet the defect: + * - a denied WRITE, atomically with the first unbound-alias blob + * ([healFalseLockedMnemonicStore]); + * - a denied READ, which cannot heal itself but must still register + * the device ([recordLockBindingDefectFromDeniedRead]) — the only + * route on a wallet whose blob predates the degradation. + * + * Two-part evidence, and BOTH halves are required. The durable flag says + * the defect was seen; [KeystoreManager.hasUnboundMasterKey] says it was + * seen *on this device*. The flag is an ordinary DataStore boolean, so a + * host app that permits Android backup or device-to-device transfer could + * carry it to a different handset — where, trusted alone, it would + * authorize the lock-gate downgrade on a healthy phone that never + * demonstrated anything. Keystore keys are non-exportable and never + * restored, so requiring the unbound alias to exist locally pins the + * decision to the device that earned it. A flag arriving without its key + * is simply not believed; it is deliberately NOT cleared here, because + * this is a read and writing from a read path is what the re-wrap races + * taught us to avoid — an inert flag costs nothing. + * + * Never cleared by any targeted mutator — the defect is a property of + * the device's OS build, not of any wallet, and a healed device + * staying healed costs nothing on a healthy one, which never sets it. + * A full [deleteAll] IS a reset, though: it drops the record with + * everything else, and the next mnemonic write simply re-derives it + * through the ladder. Host-legible so apps can surface the degraded + * protection level in telemetry/support flows, the + * [KeystoreManager.effectiveKeySecurityPolicy] discipline. + */ + suspend fun isMasterKeyLockBindingDefectObserved(): Boolean = + store.data.first()[MASTER_LOCK_DEFECT_KEY] == true && + keystore.hasUnboundMasterKey() + + /** + * Write [plaintext]'s blob under the never-lock-bound + * [MASTER_ALIAS_UNBOUND][KeystoreManager.MASTER_ALIAS_UNBOUND], tagging + * the blob with its producing alias in the same atomic edit. The write + * path once the defect is on record. Does not scrub [plaintext] — the + * caller owns the buffer. + */ + private suspend fun storeMnemonicUnbound(walletId: ByteArray, plaintext: ByteArray) { + val blob = keystore.encrypt(plaintext, KeystoreManager.MASTER_ALIAS_UNBOUND) + store.edit { + it[mnemonicKey(walletId)] = encode(blob) + it[mnemonicAliasKey(walletId)] = KeystoreManager.MASTER_ALIAS_UNBOUND + } + } + + /** + * [storeMnemonic]'s last rung: the false-locked retry schedule + * exhausted, so the device's `UNLOCKED_DEVICE_REQUIRED` implementation + * is treated as defective — store under the never-lock-bound alias and + * record the defect ([MASTER_LOCK_DEFECT_KEY]) atomically with the + * blob, so a crash between them cannot record a defect with no healed + * blob or vice versa. A failure of the unbound encrypt itself rethrows + * the original typed [denial] (still the truthful signal — retryable + * after a credential unlock) with the heal failure suppressed, and + * records nothing. + */ + private suspend fun healFalseLockedMnemonicStore( + walletId: ByteArray, + plaintext: ByteArray, + denial: KeystoreDeviceLockedException, + ) { + Log.w( + TAG, + "storeMnemonic: Keystore still denied the lock-bound master-alias encrypt as " + + "device-locked after the full false-locked retry schedule, with " + + "KeyguardManager reporting UNLOCKED (${denial.lockState}) — treating this " + + "device's UNLOCKED_DEVICE_REQUIRED implementation as defective and " + + "degrading mnemonic storage to the never-lock-bound " + + "'${KeystoreManager.MASTER_ALIAS_UNBOUND}' (the dashpay/platform#4060 " + + "downgrade, driven by operational evidence; cf. Google Issue Tracker " + + "506989112)", + denial, + ) + val blob = try { + keystore.encrypt(plaintext, KeystoreManager.MASTER_ALIAS_UNBOUND) + } catch (healError: Exception) { + denial.addSuppressed(healError) + throw denial + } + store.edit { + it[mnemonicKey(walletId)] = encode(blob) + it[mnemonicAliasKey(walletId)] = KeystoreManager.MASTER_ALIAS_UNBOUND + it[MASTER_LOCK_DEFECT_KEY] = true + } + } + + /** + * Run [attempt] under the bounded false-locked retry schedule shared by + * every lock-bound master-alias operation. + * + * The three outcomes, in the order they are decided: + * - **Success** (first try or any retry) — returned as-is. A retry that + * succeeds is evidence of the TRANSIENT Keystore2 blip, so nothing is + * recorded: the device is not defective, it merely blipped. + * - **Genuinely locked** ([KeystoreDeviceLockedException.deviceReportsLocked]) + * — rethrown immediately with no retry. Waiting cannot unlock a phone. + * - **False-locked past the whole schedule** — handed to [onExhausted], + * the caller's degradation rung. Keystore denied a lock-bound + * operation for ~2s while `KeyguardManager` insisted the device was + * unlocked, which is the persistent OEM defect rather than a blip. + * + * [operation] and [denied] only shape the retry log line (`"storeMnemonic"` + * / `"encrypt"`). + */ + private suspend fun retryingFalseLockedDenial( + operation: String, + denied: String, + attempt: suspend () -> T, + onExhausted: suspend (KeystoreDeviceLockedException) -> T, + ): T { + var retries = 0 + while (true) { + try { + return attempt() + } catch (e: KeystoreDeviceLockedException) { + if (e.deviceReportsLocked) throw e + if (retries >= DEVICE_FALSE_LOCKED_RETRY_DELAYS_MS.size) return onExhausted(e) + val delayMs = DEVICE_FALSE_LOCKED_RETRY_DELAYS_MS[retries] + retries++ + Log.w( + TAG, + "$operation: Keystore denied $denied as device-locked but " + + "KeyguardManager reports UNLOCKED (${e.lockState}) — the " + + "false-locked Keystore2 defect; retry $retries/" + + "${DEVICE_FALSE_LOCKED_RETRY_DELAYS_MS.size} in ${delayMs}ms", + e, + ) + delay(delayMs) + } + } + } + + /** + * Record the false-locked defect observed on a READ, then let the + * original denial propagate. + * + * A denied read cannot heal itself the way [storeMnemonic] can: the heal + * is a re-encrypt under the never-lock-bound alias, and a read that was + * refused never obtained the plaintext to re-encrypt. What it CAN do is + * put the defect on record, which is load-bearing for two later paths + * that are otherwise unreachable on a wallet created before the defect + * appeared (the field case — the blob predates the degradation, so no + * mnemonic is ever written again and the write ladder never runs): + * + * - [retrieveMnemonicUtf8]'s opportunistic [rewrapMnemonicUnbound] is + * gated on the record, so the first read that DOES get through (the + * Keystore denies lock-bound operations only for stretches of a + * session) finally moves the blob off the defective gate. Without a + * read-side record that re-wrap can never fire. + * - [storeMnemonic] and [ensureMasterKeyNotLockBlocked] stop betting on + * the lock-bound alias for everything they do afterwards. + * + * Best-effort: a DataStore failure here must not replace the truthful, + * retryable denial the caller needs to see, so it is attached as + * suppressed and the denial still wins. + */ + private suspend fun recordLockBindingDefectFromDeniedRead( + operation: String, + denial: KeystoreDeviceLockedException, + ): Nothing { + Log.w( + TAG, + "$operation: Keystore still denied the lock-bound '${denial.alias}' decrypt as " + + "device-locked after the full false-locked retry schedule, with " + + "KeyguardManager reporting UNLOCKED (${denial.lockState}) — recording this " + + "device's UNLOCKED_DEVICE_REQUIRED implementation as defective so the next " + + "read that gets through re-wraps the blob under the matching " + + "never-lock-bound alias (cf. Google Issue Tracker 506989112). This read " + + "still fails — a refused decrypt has no plaintext to re-encrypt.", + denial, + ) + try { + // Provision the device-local witness in the same breath as the + // flag. [isMasterKeyLockBindingDefectObserved] requires BOTH, so a + // flag without this Keystore key is inert — and unlike the + // write-heal path this one holds no plaintext to encrypt, so the + // key has to be created explicitly (the + // ensureMasterKeyNotLockBlocked probe-encrypt idiom). If it cannot + // be created, nothing is recorded and the next read retries. + keystore.encrypt(ByteArray(1), KeystoreManager.MASTER_ALIAS_UNBOUND) + store.edit { it[MASTER_LOCK_DEFECT_KEY] = true } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + denial.addSuppressed(e) + } + throw denial + } + /** * Decrypt the mnemonic as a display `String`. For explicit * user-facing reveal flows ONLY (seed backup, biometric reveal) — @@ -326,8 +564,7 @@ class WalletStorage( * (the FFI resolver, signers) must use [retrieveMnemonicUtf8]. */ suspend fun retrieveMnemonic(walletId: ByteArray): String? { - val encoded = store.data.first()[mnemonicKey(walletId)] ?: return null - val plain = keystore.decrypt(decode(encoded)) + val plain = retrieveMnemonicUtf8(walletId) ?: return null val phrase = plain.decodeToString() plain.fill(0) return phrase @@ -340,10 +577,135 @@ class WalletStorage( * bytes are consumed — unlike a String, a ByteArray can actually be * scrubbed, so the plaintext exposure window is bounded by the call * instead of by the garbage collector. + * + * Decrypts under the blob's RECORDED alias — the lock-bound + * [MASTER_ALIAS][KeystoreManager.MASTER_ALIAS] default, or + * [MASTER_ALIAS_UNBOUND][KeystoreManager.MASTER_ALIAS_UNBOUND] for a + * blob written after this device's false-locked degradation (see + * [storeMnemonic]). On a device with the defect on record, a + * successful read of a still-lock-bound blob also re-wraps it under + * the unbound alias (best-effort, see [rewrapMnemonicUnbound]) so it + * stops being hostage to the defective `UNLOCKED_DEVICE_REQUIRED` + * gate. + * + * A lock-bound decrypt runs the SAME bounded false-locked ladder as + * [storeMnemonic] ([retryingFalseLockedDenial]): a genuinely-locked + * denial fails fast, a transient one is retried, and one that outlasts + * the schedule puts the defect on record before the denial propagates + * ([recordLockBindingDefectFromDeniedRead]). The read itself still + * fails — there is no plaintext to re-encrypt — but recording it is + * what ARMS the re-wrap above on a wallet whose blob predates the + * degradation, where no mnemonic is ever written again and the write + * ladder therefore never runs. */ suspend fun retrieveMnemonicUtf8(walletId: ByteArray): ByteArray? { - val encoded = store.data.first()[mnemonicKey(walletId)] ?: return null - return keystore.decrypt(decode(encoded)) + val prefs = store.data.first() + val encoded = prefs[mnemonicKey(walletId)] ?: return null + val alias = prefs[mnemonicAliasKey(walletId)] ?: KeystoreManager.MASTER_ALIAS + val blob = decode(encoded) + val plain = retryingFalseLockedDenial( + operation = "retrieveMnemonicUtf8", + denied = "decrypt", + attempt = { keystore.decrypt(blob, alias) }, + onExhausted = { denial -> + recordLockBindingDefectFromDeniedRead("retrieveMnemonicUtf8", denial) + }, + ) + // Deliberately the PRE-decrypt snapshot: this is the hot resolver + // path (Rust calls it synchronously for every derivation), so it must + // not pay a second DataStore read. The only writer that could have + // set the flag during the decrypt is the exhausted-ladder recorder + // above, and that path throws instead of reaching here. + if (alias == KeystoreManager.MASTER_ALIAS && prefs[MASTER_LOCK_DEFECT_KEY] == true) { + try { + rewrapMnemonicUnbound(walletId, plain, encoded) + } catch (t: Throwable) { + // The caller owns [plain] and scrubs it — but only ever + // receives it by RETURN. rewrapMnemonicUnbound deliberately + // rethrows CancellationException (never swallow structured + // concurrency), so a cancellation inside its suspending + // store.edit would unwind past the return and strand decrypted + // seed bytes on the heap with nobody left to zero them. Scrub + // here before propagating. Ordinary re-wrap failures never + // reach this — they stay best-effort inside the helper. + plain.fill(0) + throw t + } + } + return plain + } + + /** + * Opportunistic re-wrap for wallets that predate the false-locked + * degradation on a defective device: their blobs still live under the + * lock-bound [MASTER_ALIAS][KeystoreManager.MASTER_ALIAS], which this + * device's Keystore denies for stretches of every unlock session, so + * the first read that DOES get through (e.g. after a credential + * unlock) moves the blob to the never-lock-bound alias — after which + * it is always readable. Best-effort by design: any failure leaves the + * original blob and its key fully intact (decryptable exactly as often + * as before) and the next successful read simply tries again. Never + * scrubs [plain] — the caller owns that buffer. + */ + private suspend fun rewrapMnemonicUnbound( + walletId: ByteArray, + plain: ByteArray, + sourceEncoded: String, + ) { + try { + rewrapMnemonicUnboundIfUnchanged(walletId, plain, sourceEncoded) + Log.i( + TAG, + "re-wrapped a lock-bound master-alias mnemonic blob under the " + + "never-lock-bound '${KeystoreManager.MASTER_ALIAS_UNBOUND}' (this " + + "device's false-locked defect is on record) — future reads no longer " + + "depend on the defective UNLOCKED_DEVICE_REQUIRED gate", + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w( + TAG, + "best-effort re-wrap under '${KeystoreManager.MASTER_ALIAS_UNBOUND}' " + + "failed; the blob remains under the lock-bound master alias and the " + + "next successful read will retry", + e, + ) + } + } + + /** + * Compare-and-set half of [rewrapMnemonicUnbound]: replace the blob ONLY + * while the entry still holds exactly what the read observed. + * + * [retrieveMnemonicUtf8] runs without any mnemonic lock, so between its + * DataStore snapshot and this edit another coroutine can legitimately + * [deleteMnemonic] the wallet or [storeMnemonic] a new phrase over it. An + * unconditional write would then RESURRECT a just-deleted mnemonic, or + * clobber a newer one with the stale ciphertext this read happened to + * decrypt — the atomicity of the edit itself does not cover the + * read-to-write interval. Guarding on the exact encoded blob AND on the + * alias tag still being absent (an untagged entry is the lock-bound + * [KeystoreManager.MASTER_ALIAS] default — see [storeMnemonic]) makes the + * re-wrap a no-op in both races, which is the correct outcome: the winner + * already wrote the state the user asked for. + * + * The migrateToPolicyAlias discipline, applied to mnemonics. + */ + private suspend fun rewrapMnemonicUnboundIfUnchanged( + walletId: ByteArray, + plain: ByteArray, + sourceEncoded: String, + ) { + val blob = keystore.encrypt(plain, KeystoreManager.MASTER_ALIAS_UNBOUND) + store.edit { prefs -> + val stillOriginal = prefs[mnemonicKey(walletId)] == sourceEncoded && + prefs[mnemonicAliasKey(walletId)] == null + if (stillOriginal) { + prefs[mnemonicKey(walletId)] = encode(blob) + prefs[mnemonicAliasKey(walletId)] = KeystoreManager.MASTER_ALIAS_UNBOUND + } + } } /** @@ -354,7 +716,10 @@ class WalletStorage( store.data.first().contains(mnemonicKey(walletId)) suspend fun deleteMnemonic(walletId: ByteArray) { - store.edit { it.remove(mnemonicKey(walletId)) } + store.edit { + it.remove(mnemonicKey(walletId)) + it.remove(mnemonicAliasKey(walletId)) + } } /** Wallet ids (hex) that have a stored mnemonic — drives orphan detection. */ @@ -564,7 +929,7 @@ class WalletStorage( privateKey: ByteArray, ownerWalletId: ByteArray?, ) { - val encrypted = keystore.encryptForIdentityKeys(privateKey) + val encrypted = encryptIdentityKeyOffDefectiveGate(privateKey) val blob = encrypted.blob val fingerprint = encrypted.keyFingerprint val alias = encrypted.alias @@ -579,6 +944,45 @@ class WalletStorage( } } + /** + * Encrypt identity-key material, routing AROUND the lock-bound alias on a + * device that has demonstrated the false-locked Keystore defect. + * + * Normally this is just [KeystoreManager.encryptForIdentityKeys] — the + * policy alias, chosen by [KeySecurityPolicy]. Once the defect is on + * record, a [KeySecurityPolicy.DEVICE_BOUND] write goes to + * [KeystoreManager.KEYS_ALIAS_DEVICE_BOUND_UNBOUND] instead, because the + * policy alias carries `setUnlockedDeviceRequired` and this device's + * Keystore denies that gate for stretches of every unlock session — + * which at signing time surfaces as MO-972 ("User not authenticated" on + * a key with no auth window). The unbound alias keeps everything + * DEVICE_BOUND actually promises: hardware-backed where the device + * provides it, non-exportable, and never auth-gated. + * + * [KeySecurityPolicy.AUTH_GATED] is deliberately NOT redirected — see + * [KeystoreManager.KEYS_ALIAS_DEVICE_BOUND_UNBOUND]. Its authentication + * gate is the real control, and there is no evidence of a defective + * device on that alias; it keeps failing honestly instead of quietly + * shedding a gate. + * + * The producing alias rides back on the blob and is persisted per entry + * (`privkeyalias.`), so reads route to whichever alias + * actually wrote each one and nothing already stored is invalidated. + */ + private suspend fun encryptIdentityKeyOffDefectiveGate( + privateKey: ByteArray, + ): KeystoreManager.KeysAliasEncryptedBlob = + if (keystore.keySecurityPolicy == KeySecurityPolicy.DEVICE_BOUND && + isMasterKeyLockBindingDefectObserved() + ) { + keystore.encryptForIdentityKeysAlias( + KeystoreManager.KEYS_ALIAS_DEVICE_BOUND_UNBOUND, + privateKey, + ) + } else { + keystore.encryptForIdentityKeys(privateKey) + } + /** * The RSA identity-keys alias recorded as having written [pubkeyHex]'s * blob (`privkeyalias.`), falling back to the current policy @@ -701,7 +1105,32 @@ class WalletStorage( // former key opens it — a re-derive signal) — never stale // plaintext, and never an uncaught crypto exception. return try { - keystore.decrypt(blob, alias = recordedAlias) + val plain = retryingFalseLockedDenial( + operation = "retrievePrivateKey", + denied = "decrypt", + attempt = { keystore.decrypt(blob, alias = recordedAlias) }, + onExhausted = { denial -> + recordLockBindingDefectFromDeniedRead("retrievePrivateKey", denial) + }, + ) + // A device that just proved its lock gate is defective must + // stop keeping THIS key behind it. Re-encrypting under the + // effective write alias (now the never-lock-bound one) is the + // same best-effort, conditional rewrite the legacy migration + // uses, so a failure simply retries on the next read. + if (recordedAlias == KeystoreManager.KEYS_ALIAS_DEVICE_BOUND && + isMasterKeyLockBindingDefectObserved() + ) { + migrateToPolicyAlias(pubkeyHex, plain, encoded) + } + plain + } catch (e: KeystoreDeviceLockedException) { + // Retryable lock denial, NOT a wrong-key signal. It is a + // GeneralSecurityException, so without this clause it would + // fall into the recovery ladder below and end as `null` — a + // spurious "re-derive this key" for a key that is perfectly + // intact and readable as soon as the gate lets go. + throw e } catch (e: UserNotAuthenticatedException) { throw e // closed auth window — prompt and retry, never recovery } catch (e: KeyPermanentlyInvalidatedException) { @@ -757,6 +1186,10 @@ class WalletStorage( private fun tryFormerRsaRecovery(blob: KeystoreManager.EncryptedBlob): ByteArray? = try { keystore.decryptLegacyRsaKeysBlob(blob) + } catch (e: KeystoreDeviceLockedException) { + // "The device is locked", never "not this key" — absorbing it to + // null would report an intact blob unrecoverable. + throw e } catch (e: UserNotAuthenticatedException) { throw e } catch (e: KeyPermanentlyInvalidatedException) { @@ -793,7 +1226,12 @@ class WalletStorage( sourceEncoded: String, ) { try { - val migrated = keystore.encryptForIdentityKeys(plain) + // The EFFECTIVE write alias, not blindly the policy alias: on a + // device with the false-locked defect on record that is the + // never-lock-bound alias, which is what makes this the re-wrap + // that gets a stranded key off the defective gate as well as the + // forward-migration for a recovered legacy blob. + val migrated = encryptIdentityKeyOffDefectiveGate(plain) store.edit { val key = privateKeyKey(pubkeyHex) if (it[key] == sourceEncoded) { @@ -811,6 +1249,15 @@ class WalletStorage( // coroutine was cancelled during the encrypt / store.edit suspend // points, rethrow so the cancellation propagates. Only genuine // rewrite failures below stay best-effort (retry on the next read). + // + // Scrub first. Every caller hands us the plaintext it is about to + // RETURN, and a cancellation here unwinds past that return, so the + // owner never gets the chance to zero it — stranding a decrypted + // identity key on the heap. The mnemonic re-wrap takes the same + // precaution; doing it inside this helper covers all three callers + // (legacy migration, recovery ladder, and the defective-gate + // re-wrap) at once. + plain.fill(0) throw cancellation } catch (_: Throwable) { // Best-effort: a rewrite failure must not lose the value the caller @@ -1017,6 +1464,16 @@ class WalletStorage( } else { false } + } catch (e: KeystoreDeviceLockedException) { + // The device's lock gate is shut (genuinely, or the false-locked + // defect). The blob and its key are intact and open as soon as + // the gate lets go, so this is RECOVERABLE — unconditionally, + // unlike UNAE. The [unaeProvesRecoverable] caveat exists because + // a closed AUTH gate hides WHICH key was asked; a device-locked + // denial carries its alias and proves nothing about ownership + // either way, so reporting "strandable" here would offer a + // re-derive for a perfectly good key. + true } catch (e: UserNotAuthenticatedException) { unaeProvesRecoverable } catch (e: GeneralSecurityException) { @@ -1030,6 +1487,11 @@ class WalletStorage( suspend fun deleteAll() { // Clears privkey.* entries too — take the same exclusion as the // targeted mutators so it can't interleave with a compound sweep. + // [MASTER_LOCK_DEFECT_KEY] goes with it: the record is scoped to + // THIS store, and re-deriving it costs one false-locked retry + // schedule (~2s) on the next mnemonic write, which a wiped store + // always has ahead of it. Carving it out instead would make a full + // wipe unable to restore a clean slate — including between tests. privateKeyMutex.withLock { store.edit { it.clear() } } @@ -1038,6 +1500,9 @@ class WalletStorage( private fun mnemonicKey(walletId: ByteArray) = stringPreferencesKey(MNEMONIC_PREFIX + walletId.toHex()) + private fun mnemonicAliasKey(walletId: ByteArray) = + stringPreferencesKey(MNEMONIC_ALIAS_PREFIX + walletId.toHex()) + private fun privateKeyKey(pubkeyHex: String) = stringPreferencesKey(PRIVKEY_PREFIX + pubkeyHex.lowercase()) @@ -1058,6 +1523,18 @@ class WalletStorage( private companion object { const val MNEMONIC_PREFIX = "mnemonic." + + /** + * Per-wallet record of the AES alias that produced the mnemonic + * blob (`mnemonicalias.`), written atomically with the + * blob. Routes reads to the exact producing alias after the + * false-locked degradation moves writes to + * [KeystoreManager.MASTER_ALIAS_UNBOUND]; a missing tag means the + * lock-bound [KeystoreManager.MASTER_ALIAS] (every blob written + * before the tag existed). The `privkeyalias.` discipline, applied + * to mnemonics. + */ + const val MNEMONIC_ALIAS_PREFIX = "mnemonicalias." const val PRIVKEY_PREFIX = "privkey." /** Per-alias [KeystoreManager.keysAliasFingerprint] snapshot, taken at write time. */ @@ -1124,7 +1601,18 @@ class WalletStorage( * `KeyguardManager` reported the device unlocked): 3 retries, * ~2s total. Genuinely-locked denials never retry. */ - internal val DEVICE_FALSE_LOCKED_RETRY_DELAYS_MS = longArrayOf(250, 750, 1000) + private val DEVICE_FALSE_LOCKED_RETRY_DELAYS_MS = longArrayOf(250, 750, 1000) + + /** + * Durable device-scoped record that the false-locked Keystore + * defect was demonstrated here — a lock-bound master-alias denial + * that outlasted the full [DEVICE_FALSE_LOCKED_RETRY_DELAYS_MS] + * schedule while `KeyguardManager` reported the device unlocked. + * Set by [storeMnemonic]'s heal path atomically with the first + * [KeystoreManager.MASTER_ALIAS_UNBOUND] blob; never cleared. Read + * via [isMasterKeyLockBindingDefectObserved]. + */ + private val MASTER_LOCK_DEFECT_KEY = booleanPreferencesKey("masterkeylockdefect") private const val TAG = "WalletStorage" } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index aba0c4ceb2d..eb1101e7a66 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -767,10 +767,14 @@ class PlatformWalletManager( * encrypt, and thrown BEFORE the native create, so nothing was * created and nothing needs rolling back — or if the Keystore denies * the mnemonic store as device-locked after the false-locked bounded - * retry in [WalletStorage.storeMnemonic] is exhausted (that path runs - * the full rollback below first). A locked device whose master key is - * NOT lock-bound (generated before a PIN was enrolled) proceeds - * normally. + * retry in [WalletStorage.storeMnemonic] is exhausted AND its + * last-rung degradation (re-encrypting under the never-lock-bound + * master alias) also failed (that path runs the full rollback below + * first). A locked device whose master key is NOT lock-bound + * (generated before a PIN was enrolled) proceeds normally, as does a + * device whose false-locked Keystore defect is already on record + * (mnemonic writes target the never-lock-bound alias, which no lock + * state can deny). */ suspend fun createWallet( mnemonic: String, diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeystoreDeviceLockedDenialTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeystoreDeviceLockedDenialTest.kt index 6c61f681e0f..ccfae398869 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeystoreDeviceLockedDenialTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeystoreDeviceLockedDenialTest.kt @@ -143,6 +143,55 @@ class KeystoreDeviceLockedDenialTest { assertTrue(thrown.message.orEmpty().contains("genuinely locked")) } + @Test + fun shouldMapDeviceBoundIdentityAliasDenialToTypedException() { + // MO-972. KEYS_ALIAS_DEVICE_BOUND carries setUnlockedDeviceRequired + // but NO auth gate, so — exactly like MASTER_ALIAS — a Keystore + // "user not authenticated" from it can only be the unlocked-device + // denial. Left unclassified it reached KeystoreSigner looking like an + // expired auth window on a policy that HAS no auth window, and the + // wallet reported "Keystore auth window expired" one second after a + // successful biometric. + val manager = managerSampling( + DeviceLockState(isDeviceLocked = false, isKeyguardLocked = false), + ) + val denial = SimulatedUserNotAuthenticatedException() + + val thrown = assertThrows(KeystoreDeviceLockedException::class.java) { + manager.rethrowClassifyingDeviceLockedDenial( + denial, + KeystoreManager.KEYS_ALIAS_DEVICE_BOUND, + operation = "decrypt", + ) + } + assertEquals(KeystoreManager.KEYS_ALIAS_DEVICE_BOUND, thrown.alias) + assertEquals("decrypt", thrown.operation) + assertFalse(thrown.deviceReportsLocked) + assertSame(denial, thrown.cause) + } + + @Test + fun shouldRethrowUnboundAliasDenialsUnclassified() { + // The *_UNBOUND aliases carry NEITHER gate, so a denial there is not + // a lock denial at all. Classifying it would promise "retry after + // unlock" for a failure no unlock can fix, and would send the + // degradation ladders chasing a device defect that is not the one + // they heal. + val manager = managerSampling( + DeviceLockState(isDeviceLocked = true, isKeyguardLocked = true), + ) + for (alias in listOf( + KeystoreManager.MASTER_ALIAS_UNBOUND, + KeystoreManager.KEYS_ALIAS_DEVICE_BOUND_UNBOUND, + )) { + val raw = SimulatedUserNotAuthenticatedException() + val thrown = assertThrows(SimulatedUserNotAuthenticatedException::class.java) { + manager.rethrowClassifyingDeviceLockedDenial(raw, alias, operation = "decrypt") + } + assertSame(raw, thrown) + } + } + @Test fun shouldRethrowAuthGatedAliasUserNotAuthenticatedUnclassified() { // The auth-gated identity-keys alias' NORMAL pre-prompt contract: diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageDeviceLockedRetryTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageDeviceLockedRetryTest.kt index 8ebac6044d6..94050db301a 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageDeviceLockedRetryTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageDeviceLockedRetryTest.kt @@ -2,6 +2,7 @@ package org.dashfoundation.dashsdk.security import androidx.test.core.app.ApplicationProvider import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -32,6 +33,13 @@ import org.robolectric.RobolectricTestRunner * whose sampled `KeyguardManager` state says the device is NOT locked * (the Keystore2 misreporting defect) is retried up to 3 times; a * genuinely-locked denial fails fast with no retry. + * 3. The last-rung DEGRADATION when that schedule exhausts still + * false-locked (the persistent defect — an OEM unlock class that never + * satisfies `UNLOCKED_DEVICE_REQUIRED`): the store re-encrypts under + * the never-lock-bound [KeystoreManager.MASTER_ALIAS_UNBOUND], records + * the defect durably, and from then on writes go straight to the + * unbound alias, reads route by the blob's recorded alias, and + * still-lock-bound blobs are re-wrapped on their first successful read. * * The real AndroidKeyStore crypto cannot run on the JVM (see * [KeySecurityPolicyTest]), so a fake [KeystoreManager] scripts the @@ -43,6 +51,7 @@ import org.robolectric.RobolectricTestRunner class WalletStorageDeviceLockedRetryTest { private val walletId = ByteArray(32) { (it + 1).toByte() } + private val siblingWalletId = ByteArray(32) { (it + 101).toByte() } private val mnemonic = "abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon about" @@ -53,7 +62,8 @@ class WalletStorageDeviceLockedRetryTest { fun setUp() = runBlocking { fake = FalseLockedFakeKeystoreManager() storage = WalletStorage(ApplicationProvider.getApplicationContext(), fake) - // Isolate from any state a prior test left in the shared DataStore file. + // Isolate from any state a prior test left in the shared DataStore + // file — including the durable false-locked defect record. storage.deleteAll() } @@ -66,7 +76,7 @@ class WalletStorageDeviceLockedRetryTest { fake.lockState = DeviceLockState(isDeviceLocked = true, isKeyguardLocked = true) val thrown = assertThrows(KeystoreDeviceLockedException::class.java) { - storage.ensureMasterKeyNotLockBlocked(operation = "createWallet") + runBlocking { storage.ensureMasterKeyNotLockBlocked(operation = "createWallet") } } assertEquals("createWallet", thrown.operation) assertEquals(KeystoreManager.MASTER_ALIAS, thrown.alias) @@ -78,7 +88,7 @@ class WalletStorageDeviceLockedRetryTest { } @Test - fun shouldPassPreCheckWhenDeviceIsUnlocked() { + fun shouldPassPreCheckWhenDeviceIsUnlocked() = runBlocking { fake.lockState = DeviceLockState(isDeviceLocked = false, isKeyguardLocked = false) storage.ensureMasterKeyNotLockBlocked(operation = "createWallet") // must not throw // Unlocked is decided from KeyguardManager alone — prompt-free AND @@ -87,7 +97,7 @@ class WalletStorageDeviceLockedRetryTest { } @Test - fun shouldPassPreCheckWhenKeyguardShowsButDeviceIsNotSecurelyLocked() { + fun shouldPassPreCheckWhenKeyguardShowsButDeviceIsNotSecurelyLocked() = runBlocking { // isKeyguardLocked without isDeviceLocked (e.g. a non-secure swipe // screen): the Keystore unlocked-device gate keys off the SECURE // lock, so this state must not block wallet creation. @@ -97,7 +107,7 @@ class WalletStorageDeviceLockedRetryTest { } @Test - fun shouldPassPreCheckWhenDeviceIsLockedButMasterKeyIsNotLockBound() { + fun shouldPassPreCheckWhenDeviceIsLockedButMasterKeyIsNotLockBound() = runBlocking { // A master key generated while the device had NO secure lock screen // carries no setUnlockedDeviceRequired // ([KeystoreManager]'s generateWithLockScreenDegradation) and existing @@ -115,6 +125,24 @@ class WalletStorageDeviceLockedRetryTest { assertEquals(1, fake.masterEncryptCalls) } + @Test + fun shouldPassPreCheckOnLockedDeviceOnceDefectIsOnRecord() = runBlocking { + // Demonstrate the persistent defect (unlocked, denials outlast the + // schedule) so the degradation records it... + fake.lockState = DeviceLockState(isDeviceLocked = false, isKeyguardLocked = false) + fake.failMasterEncrypts = Int.MAX_VALUE + storage.storeMnemonic(walletId, mnemonic) + assertTrue(storage.isMasterKeyLockBindingDefectObserved()) + val masterEncryptsSoFar = fake.masterEncryptCalls + + // ...then a GENUINELY locked entry must proceed with no probe at + // all: writes target the never-lock-bound alias, which no lock + // state can deny — there is nothing to preflight. + fake.lockState = DeviceLockState(isDeviceLocked = true, isKeyguardLocked = true) + storage.ensureMasterKeyNotLockBlocked(operation = "createWallet") // must not throw + assertEquals(masterEncryptsSoFar, fake.masterEncryptCalls) + } + // ── storeMnemonic bounded FALSE-LOCKED retry ───────────────────────── @Test @@ -125,14 +153,22 @@ class WalletStorageDeviceLockedRetryTest { storage.storeMnemonic(walletId, mnemonic) assertEquals(2, fake.masterEncryptCalls) + // A transient blip must NOT record the persistent defect or touch + // the unbound alias — the retry alone absorbed it. + assertEquals(0, fake.unboundEncryptCalls) + assertFalse(storage.isMasterKeyLockBindingDefectObserved()) // The store really landed: the mnemonic round-trips. assertEquals(mnemonic, storage.retrieveMnemonic(walletId)) } @Test - fun shouldGiveUpAfterThreeFalseLockedRetries() = runBlocking { - fake.lockState = DeviceLockState(isDeviceLocked = false, isKeyguardLocked = false) - fake.failMasterEncrypts = Int.MAX_VALUE // never heals + fun shouldNotRetryWhenDeviceIsGenuinelyLocked() = runBlocking { + // The denial is CORRECT here — a 2s in-process retry cannot unlock + // a phone, so the exception must propagate immediately, and the + // degradation must NOT fire (a locked phone denying a lock-bound + // key is the gate working, not the defect). + fake.lockState = DeviceLockState(isDeviceLocked = true, isKeyguardLocked = true) + fake.failMasterEncrypts = Int.MAX_VALUE var thrown: KeystoreDeviceLockedException? = null try { @@ -142,19 +178,65 @@ class WalletStorageDeviceLockedRetryTest { } assertTrue("expected the typed denial to propagate", thrown != null) - assertFalse(thrown!!.deviceReportsLocked) + assertTrue(thrown!!.deviceReportsLocked) + assertEquals(1, fake.masterEncryptCalls) + assertEquals(0, fake.unboundEncryptCalls) + assertFalse(storage.isMasterKeyLockBindingDefectObserved()) + } + + @Test + fun shouldStoreWithoutRetryMachineryWhenKeystoreIsHealthy() = runBlocking { + fake.lockState = DeviceLockState(isDeviceLocked = false, isKeyguardLocked = false) + + storage.storeMnemonic(walletId, mnemonic) + + assertEquals(1, fake.masterEncryptCalls) + assertEquals(0, fake.unboundEncryptCalls) + assertEquals(mnemonic, storage.retrieveMnemonic(walletId)) + } + + // ── last-rung degradation: the PERSISTENT false-locked defect ──────── + + @Test + fun shouldDegradeToUnboundAliasWhenFalseLockedRetriesExhaust() = runBlocking { + fake.lockState = DeviceLockState(isDeviceLocked = false, isKeyguardLocked = false) + fake.failMasterEncrypts = Int.MAX_VALUE // never heals — the persistent defect + + storage.storeMnemonic(walletId, mnemonic) + // Initial attempt + the full 3-retry schedule (250/750/1000ms), - // then give up. + // then ONE unbound-alias encrypt instead of giving up. assertEquals(4, fake.masterEncryptCalls) - assertEquals(null, storage.retrieveMnemonic(walletId)) + assertEquals(1, fake.unboundEncryptCalls) + assertTrue(storage.isMasterKeyLockBindingDefectObserved()) + // The store really landed, and the read routes to the recorded + // alias (the fake rejects a blob decrypted under the wrong one). + assertEquals(mnemonic, storage.retrieveMnemonic(walletId)) + assertEquals(1, fake.unboundDecryptCalls) + assertEquals(0, fake.masterDecryptCalls) } @Test - fun shouldNotRetryWhenDeviceIsGenuinelyLocked() = runBlocking { - // The denial is CORRECT here — a 2s in-process retry cannot unlock - // a phone, so the exception must propagate immediately. - fake.lockState = DeviceLockState(isDeviceLocked = true, isKeyguardLocked = true) + fun shouldWriteStraightToUnboundAliasOnceDefectIsOnRecord() = runBlocking { + fake.lockState = DeviceLockState(isDeviceLocked = false, isKeyguardLocked = false) + fake.failMasterEncrypts = Int.MAX_VALUE + storage.storeMnemonic(walletId, mnemonic) // demonstrates + records the defect + val masterEncryptsSoFar = fake.masterEncryptCalls + + storage.storeMnemonic(siblingWalletId, mnemonic) + + // No lock-bound attempt, no retry dance — straight to the alias + // that works on this device. + assertEquals(masterEncryptsSoFar, fake.masterEncryptCalls) + assertEquals(2, fake.unboundEncryptCalls) + assertEquals(mnemonic, storage.retrieveMnemonic(siblingWalletId)) + } + + @Test + fun shouldPropagateOriginalDenialWhenDegradationEncryptAlsoFails() = runBlocking { + fake.lockState = DeviceLockState(isDeviceLocked = false, isKeyguardLocked = false) fake.failMasterEncrypts = Int.MAX_VALUE + fake.failUnboundEncrypts = Int.MAX_VALUE // even the last rung fails var thrown: KeystoreDeviceLockedException? = null try { @@ -164,18 +246,243 @@ class WalletStorageDeviceLockedRetryTest { } assertTrue("expected the typed denial to propagate", thrown != null) - assertTrue(thrown!!.deviceReportsLocked) - assertEquals(1, fake.masterEncryptCalls) + assertFalse(thrown!!.deviceReportsLocked) + assertEquals(4, fake.masterEncryptCalls) + assertEquals(1, fake.unboundEncryptCalls) + // The heal failure rides along for diagnosis... + assertTrue(thrown.suppressed.any { it is IllegalStateException }) + // ...and nothing was recorded or persisted: the failed heal must + // not brand the device defective with no healed blob to show. + assertFalse(storage.isMasterKeyLockBindingDefectObserved()) + assertEquals(null, storage.retrieveMnemonic(walletId)) } @Test - fun shouldStoreWithoutRetryMachineryWhenKeystoreIsHealthy() = runBlocking { + fun shouldRewrapLockBoundBlobOnFirstSuccessfulReadAfterDefectRecorded() = runBlocking { fake.lockState = DeviceLockState(isDeviceLocked = false, isKeyguardLocked = false) + // A pre-existing wallet stored healthily under the lock-bound alias... + storage.storeMnemonic(walletId, mnemonic) + // ...then a sibling wallet's store demonstrates the persistent defect. + fake.failMasterEncrypts = Int.MAX_VALUE + storage.storeMnemonic(siblingWalletId, mnemonic) + fake.failMasterEncrypts = 0 + + // The first successful read of the still-lock-bound blob re-wraps it + // under the unbound alias (sibling's heal + this re-wrap = 2). + assertEquals(mnemonic, storage.retrieveMnemonic(walletId)) + assertEquals(1, fake.masterDecryptCalls) + assertEquals(2, fake.unboundEncryptCalls) + // Subsequent reads route to the unbound alias — the lock-bound key + // is no longer consulted. + assertEquals(mnemonic, storage.retrieveMnemonic(walletId)) + assertEquals(1, fake.masterDecryptCalls) + assertTrue(fake.unboundDecryptCalls >= 1) + } + + @Test + fun shouldKeepLockBoundBlobReadableWhenRewrapFails() = runBlocking { + fake.lockState = DeviceLockState(isDeviceLocked = false, isKeyguardLocked = false) storage.storeMnemonic(walletId, mnemonic) + fake.failMasterEncrypts = Int.MAX_VALUE + storage.storeMnemonic(siblingWalletId, mnemonic) // records the defect + fake.failMasterEncrypts = 0 - assertEquals(1, fake.masterEncryptCalls) + // Re-wrap is best-effort: its failure must not fail the read or + // corrupt the blob, and the next successful read tries again. + fake.failUnboundEncrypts = 1 + assertEquals(mnemonic, storage.retrieveMnemonic(walletId)) + assertEquals(mnemonic, storage.retrieveMnemonic(walletId)) // retried re-wrap landed + assertEquals(2, fake.masterDecryptCalls) + assertEquals(mnemonic, storage.retrieveMnemonic(walletId)) + assertEquals(2, fake.masterDecryptCalls) // now routed to unbound + } + + // ── retrieveMnemonicUtf8's false-locked ladder ─────────────────────── + + @Test + fun shouldFailFastWhenMnemonicReadIsDeniedOnGenuinelyLockedDevice() { + runBlocking { storage.storeMnemonic(walletId, mnemonic) } + fake.masterDecryptCalls = 0 + // Genuinely locked: the denial is CORRECT. Retrying cannot unlock a + // phone, and the device is not defective — nothing may be recorded. + fake.lockState = DeviceLockState(isDeviceLocked = true, isKeyguardLocked = true) + + val thrown = assertThrows(KeystoreDeviceLockedException::class.java) { + runBlocking { storage.retrieveMnemonicUtf8(walletId) } + } + assertTrue(thrown.deviceReportsLocked) + assertEquals("decrypt", thrown.operation) + assertEquals(1, fake.masterDecryptCalls) + assertFalse(runBlocking { storage.isMasterKeyLockBindingDefectObserved() }) + } + + @Test + fun shouldRetryFalseLockedMnemonicReadAndSucceedOnSecondAttempt() = runBlocking { + storage.storeMnemonic(walletId, mnemonic) + fake.masterDecryptCalls = 0 + // One denial while KeyguardManager reports UNLOCKED — the transient + // Keystore2 blip the schedule exists to outwait. + fake.failMasterDecrypts = 1 + + assertEquals(mnemonic, storage.retrieveMnemonic(walletId)) + assertEquals(2, fake.masterDecryptCalls) + // A retry that SUCCEEDS is a blip, not the persistent defect: the + // device must not be branded, or every healthy phone that ever + // blipped would downgrade itself permanently. + assertFalse(storage.isMasterKeyLockBindingDefectObserved()) + } + + @Test + fun shouldRecordDefectWhenFalseLockedMnemonicReadRetriesExhaust() = runBlocking { + storage.storeMnemonic(walletId, mnemonic) + fake.masterDecryptCalls = 0 + fake.failMasterDecrypts = Int.MAX_VALUE + + // The read itself still fails — a refused decrypt never obtained the + // plaintext, so unlike storeMnemonic it cannot heal in place. + assertThrows(KeystoreDeviceLockedException::class.java) { + runBlocking { storage.retrieveMnemonicUtf8(walletId) } + } + // One initial attempt plus the full DEVICE_FALSE_LOCKED_RETRY_DELAYS_MS + // schedule (3 delays). + assertEquals(4, fake.masterDecryptCalls) + // What it CAN do — and must — is put the device on record. + assertTrue(storage.isMasterKeyLockBindingDefectObserved()) + } + + @Test + fun shouldRewrapOffTheDefectiveGateAfterOnlyReadsEverObservedIt() = runBlocking { + // The field shape (MO-972's sibling): a wallet created before the + // degradation existed. Its blob sits under the lock-bound alias and + // no mnemonic is EVER written again, so the write ladder never runs + // and only a read can discover the defect. + storage.storeMnemonic(walletId, mnemonic) + fake.masterDecryptCalls = 0 + fake.unboundEncryptCalls = 0 + + // Session 1 — the gate is jammed. The read fails, but registers. + fake.failMasterDecrypts = Int.MAX_VALUE + assertThrows(KeystoreDeviceLockedException::class.java) { + runBlocking { storage.retrieveMnemonicUtf8(walletId) } + } + assertTrue(storage.isMasterKeyLockBindingDefectObserved()) + // The denied read provisions the device-local witness (one unbound + // encrypt of a probe byte) but CANNOT re-wrap the blob — it never got + // the plaintext — so the entry is still under the lock-bound alias. + assertEquals(1, fake.unboundEncryptCalls) + assertEquals(0, fake.unboundDecryptCalls) + + // Session 2 — the gate lets a read through (e.g. after a credential + // unlock). The record armed the re-wrap, which now fires. + fake.failMasterDecrypts = 0 + fake.unboundEncryptCalls = 0 assertEquals(mnemonic, storage.retrieveMnemonic(walletId)) + assertEquals(1, fake.unboundEncryptCalls) + + // The lock-bound key is never consulted again, so a future jam + // cannot strand this wallet. + val masterDecryptsBefore = fake.masterDecryptCalls + assertEquals(mnemonic, storage.retrieveMnemonic(walletId)) + assertEquals(masterDecryptsBefore, fake.masterDecryptCalls) + assertTrue(fake.unboundDecryptCalls >= 1) + } + + @Test + fun shouldIgnoreADefectFlagWithoutItsDeviceLocalKeystoreWitness() = runBlocking { + // Earn the defect record on "this" device. + fake.failMasterEncrypts = Int.MAX_VALUE + storage.storeMnemonic(walletId, mnemonic) + fake.failMasterEncrypts = 0 + assertTrue(storage.isMasterKeyLockBindingDefectObserved()) + + // Now model a restore onto a DIFFERENT handset: Android can carry the + // DataStore preference, but never the Keystore key. A healthy phone + // must not inherit the lock-gate downgrade from a portable boolean. + fake.unboundKeyProvisioned = false + + assertFalse( + "a flag without its device-local witness must not be believed", + storage.isMasterKeyLockBindingDefectObserved(), + ) + + // ...and the write path must go back to the lock-bound alias. + fake.unboundEncryptCalls = 0 + fake.masterEncryptCalls = 0 + storage.storeMnemonic(siblingWalletId, mnemonic) + assertEquals(1, fake.masterEncryptCalls) + assertEquals(0, fake.unboundEncryptCalls) + } + + // ── re-wrap must never outrun a concurrent write ───────────────────── + + /** Put the defect on record without disturbing [walletId]'s own blob. */ + private suspend fun recordDefectViaSiblingWrite() { + fake.failMasterEncrypts = Int.MAX_VALUE + storage.storeMnemonic(siblingWalletId, mnemonic) + fake.failMasterEncrypts = 0 + assertTrue(storage.isMasterKeyLockBindingDefectObserved()) + } + + @Test + fun shouldNotResurrectAMnemonicDeletedDuringTheRewrap() = runBlocking { + storage.storeMnemonic(walletId, mnemonic) + recordDefectViaSiblingWrite() + + // retrieveMnemonicUtf8 holds a snapshot taken before the delete. + // Without a compare-and-set the re-wrap writes that stale ciphertext + // back and resurrects a seed the user just destroyed. + fake.onUnboundEncrypt = { + fake.onUnboundEncrypt = null + runBlocking { storage.deleteMnemonic(walletId) } + } + + storage.retrieveMnemonic(walletId) // the read itself still succeeds + + assertFalse("a deleted mnemonic must stay deleted", storage.hasMnemonic(walletId)) + } + + @Test + fun shouldNotClobberAMnemonicRewrittenDuringTheRewrap() = runBlocking { + val replacement = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong" + storage.storeMnemonic(walletId, mnemonic) + recordDefectViaSiblingWrite() + + // Same window, but the racing writer stores a NEW phrase. The stale + // re-wrap must not overwrite it with the one this read decrypted. + fake.onUnboundEncrypt = { + fake.onUnboundEncrypt = null + runBlocking { storage.storeMnemonic(walletId, replacement) } + } + + storage.retrieveMnemonic(walletId) + + assertEquals( + "the newer mnemonic must survive the stale re-wrap", + replacement, + storage.retrieveMnemonic(walletId), + ) + } + + @Test + fun shouldScrubPlaintextWhenCancelledDuringTheRewrap() { + runBlocking { + storage.storeMnemonic(walletId, mnemonic) + recordDefectViaSiblingWrite() + } + fake.lastMasterDecryptRef = null + + // Cancellation inside the re-wrap unwinds PAST the return, so the + // caller never receives the buffer and can never scrub it. + fake.onUnboundEncrypt = { + fake.onUnboundEncrypt = null + throw CancellationException("cancelled mid-re-wrap") + } + + assertThrows(CancellationException::class.java) { + runBlocking { storage.retrieveMnemonicUtf8(walletId) } + } + assertBufferScrubbed(fake.lastMasterDecryptRef) } // ── storeMnemonic plaintext-buffer scrubbing ───────────────────────── @@ -194,10 +501,24 @@ class WalletStorageDeviceLockedRetryTest { assertEquals(mnemonic, storage.retrieveMnemonic(walletId)) } + @Test + fun shouldScrubMnemonicBufferAfterDegradedStore() = runBlocking { + fake.lockState = DeviceLockState(isDeviceLocked = false, isKeyguardLocked = false) + fake.failMasterEncrypts = Int.MAX_VALUE // the degradation path runs + + storage.storeMnemonic(walletId, mnemonic) + + assertBufferScrubbed(fake.lastMasterPlaintextRef) + // The unbound encrypt saw the same (single) buffer — scrubbed too. + assertBufferScrubbed(fake.lastUnboundPlaintextRef) + assertEquals(mnemonic, storage.retrieveMnemonic(walletId)) + } + @Test fun shouldScrubMnemonicBufferWhenFinalDenialPropagates() = runBlocking { fake.lockState = DeviceLockState(isDeviceLocked = false, isKeyguardLocked = false) - fake.failMasterEncrypts = Int.MAX_VALUE // never heals — the schedule exhausts + fake.failMasterEncrypts = Int.MAX_VALUE + fake.failUnboundEncrypts = Int.MAX_VALUE // degradation fails too — it propagates var thrown = false try { @@ -246,8 +567,15 @@ class WalletStorageDeviceLockedRetryTest { * `setUnlockedDeviceRequired`), any encrypt while [lockState] reports the * device locked is denied, exactly as the real Keystore gate behaves; when * false (a key generated on a then-lockless device, never regenerated), - * encrypts succeed regardless of lock state. Identity-key aliases are out of - * scope here — see [WalletStorageUpgradeMatrixTest]'s fake for that ladder. + * encrypts succeed regardless of lock state. + * + * [KeystoreManager.MASTER_ALIAS_UNBOUND] is modeled per ITS contract: never + * lock-bound, so never denied by any lock state; [failUnboundEncrypts] + * scripts unclassified failures for the degradation-also-fails paths. Each + * blob's iv marks the alias that produced it and [decrypt] rejects a + * mismatch, so the tests prove reads route to the recorded alias. Identity- + * key aliases are out of scope here — see [WalletStorageUpgradeMatrixTest]'s + * fake for that ladder. */ private class FalseLockedFakeKeystoreManager : KeystoreManager() { @@ -255,40 +583,121 @@ private class FalseLockedFakeKeystoreManager : KeystoreManager() { var failMasterEncrypts = 0 var masterEncryptCalls = 0 + /** + * Scripted device-locked denials for MASTER_ALIAS **decrypts** — the + * read-side mirror of [failMasterEncrypts]. `Int.MAX_VALUE` models the + * persistent defect (the gate stays jammed for the whole session); + * a small count models the transient Keystore2 blip. + */ + var failMasterDecrypts = 0 + var failUnboundEncrypts = 0 + var unboundEncryptCalls = 0 + var masterDecryptCalls = 0 + var unboundDecryptCalls = 0 + /** Whether the fake master key carries the unlocked-device requirement. */ var masterKeyLockBound = true + /** + * Whether MASTER_ALIAS_UNBOUND exists in this fake's Keystore — the + * device-local witness. Set by any unbound-alias encrypt (which + * provisions the key for real), and clearable to model a DataStore + * restored onto a DIFFERENT device, where the preference survives but + * the Keystore key cannot. + */ + var unboundKeyProvisioned = false + /** The exact buffer reference the last master encrypt received. */ var lastMasterPlaintextRef: ByteArray? = null /** Snapshot of that buffer's content AT CALL TIME (pre-scrub evidence). */ var lastMasterPlaintextAtCall: ByteArray? = null + /** The exact buffer reference the last unbound-alias encrypt received. */ + var lastUnboundPlaintextRef: ByteArray? = null + /** Invoked at each master encrypt attempt (test synchronization hook). */ var onMasterEncrypt: (() -> Unit)? = null + /** + * Invoked at each UNBOUND-alias encrypt, i.e. inside the re-wrap and + * BEFORE its `store.edit` — the exact window in which a concurrent + * delete/overwrite must be able to win. + */ + var onUnboundEncrypt: (() -> Unit)? = null + + /** The buffer the last master decrypt handed back (scrub evidence). */ + var lastMasterDecryptRef: ByteArray? = null + override fun sampleDeviceLockState(): DeviceLockState = lockState - override fun encrypt(plaintext: ByteArray, alias: String): EncryptedBlob { - check(alias == MASTER_ALIAS) { "test fake only models the master alias" } - masterEncryptCalls++ - lastMasterPlaintextRef = plaintext - lastMasterPlaintextAtCall = plaintext.copyOf() - onMasterEncrypt?.invoke() - val scriptedDenial = failMasterEncrypts > 0 - if (scriptedDenial) failMasterEncrypts-- - if (scriptedDenial || (masterKeyLockBound && lockState.isDeviceLocked)) { - throw KeystoreDeviceLockedException( - alias = alias, - operation = "encrypt", - lockState = sampleDeviceLockState(), - ) + override fun hasUnboundMasterKey(): Boolean = unboundKeyProvisioned + + override fun encrypt(plaintext: ByteArray, alias: String): EncryptedBlob = when (alias) { + MASTER_ALIAS -> { + masterEncryptCalls++ + lastMasterPlaintextRef = plaintext + lastMasterPlaintextAtCall = plaintext.copyOf() + onMasterEncrypt?.invoke() + val scriptedDenial = failMasterEncrypts > 0 + if (scriptedDenial) failMasterEncrypts-- + if (scriptedDenial || (masterKeyLockBound && lockState.isDeviceLocked)) { + throw KeystoreDeviceLockedException( + alias = alias, + operation = "encrypt", + lockState = sampleDeviceLockState(), + ) + } + blob(MASTER_IV_MARKER, plaintext) + } + MASTER_ALIAS_UNBOUND -> { + unboundEncryptCalls++ + lastUnboundPlaintextRef = plaintext + onUnboundEncrypt?.invoke() + unboundKeyProvisioned = true + val scriptedFailure = failUnboundEncrypts > 0 + if (scriptedFailure) failUnboundEncrypts-- + check(!scriptedFailure) { "scripted unbound-alias encrypt failure" } + blob(UNBOUND_IV_MARKER, plaintext) } - return EncryptedBlob(iv = ByteArray(12) { 7 }, ciphertext = plaintext.copyOf()) + else -> error("test fake only models the master aliases, got '$alias'") } override fun decrypt(blob: EncryptedBlob, alias: String): ByteArray { - check(alias == MASTER_ALIAS) { "test fake only models the master alias" } - return blob.ciphertext.copyOf() + val expectedMarker = when (alias) { + MASTER_ALIAS -> { + masterDecryptCalls++ + val scriptedDenial = failMasterDecrypts > 0 + if (scriptedDenial) failMasterDecrypts-- + if (scriptedDenial || (masterKeyLockBound && lockState.isDeviceLocked)) { + throw KeystoreDeviceLockedException( + alias = alias, + operation = "decrypt", + lockState = sampleDeviceLockState(), + ) + } + MASTER_IV_MARKER + } + MASTER_ALIAS_UNBOUND -> { + unboundDecryptCalls++ + UNBOUND_IV_MARKER + } + else -> error("test fake only models the master aliases, got '$alias'") + } + check(blob.iv.all { it == expectedMarker }) { + "blob was decrypted under the wrong alias: '$alias' cannot open a blob " + + "whose iv marker is ${blob.iv.firstOrNull()}" + } + return blob.ciphertext.copyOf().also { + if (alias == MASTER_ALIAS) lastMasterDecryptRef = it + } + } + + private fun blob(ivMarker: Byte, plaintext: ByteArray) = + EncryptedBlob(iv = ByteArray(12) { ivMarker }, ciphertext = plaintext.copyOf()) + + private companion object { + const val MASTER_IV_MARKER: Byte = 7 + const val UNBOUND_IV_MARKER: Byte = 9 } } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageIdentityKeyLockDefectTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageIdentityKeyLockDefectTest.kt new file mode 100644 index 00000000000..aab8175f970 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageIdentityKeyLockDefectTest.kt @@ -0,0 +1,384 @@ +package org.dashfoundation.dashsdk.security + +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins MO-972: identity-key **signing** dying on a device whose Keystore + * denies lock-bound keys while `KeyguardManager` reports it unlocked. + * + * [KeySecurityPolicy.DEVICE_BOUND] exists precisely so a host with its own + * PIN never meets an authentication gate, and the production wallet adopted + * it for that reason. But [KeystoreManager.KEYS_ALIAS_DEVICE_BOUND] still + * carries `setUnlockedDeviceRequired`, and Android reports THAT denial with + * the very same `UserNotAuthenticatedException` as a closed auth window — so + * on the defective device the failure came back as "User not authenticated" + * from a policy with no auth window, one second after a successful biometric. + * + * The storage-side contract this fixes, in the order a device meets it: + * 1. a genuinely-locked denial still fails fast, unretried and unrecorded; + * 2. a denial that outlasts the bounded false-locked schedule records the + * device, and the read still fails truthfully; + * 3. once recorded, new identity-key writes go to the never-lock-bound + * [KeystoreManager.KEYS_ALIAS_DEVICE_BOUND_UNBOUND]; + * 4. the first read that DOES get through re-wraps the stranded blob onto + * that alias, after which the defective gate is out of the signing path; + * 5. throughout, a lock denial is never mistaken for a wrong key — the + * recovery ladder must not report an intact key as needing a re-derive. + * + * The classification itself (which aliases may map the ambiguous exception) + * is pinned separately and prompt-free in [KeystoreDeviceLockedDenialTest]; + * the fake here raises the typed exception directly, exactly as a classified + * [KeystoreManager.decrypt] would. + */ +@RunWith(RobolectricTestRunner::class) +class WalletStorageIdentityKeyLockDefectTest { + + private val pubkeyHex = "02" + "ab".repeat(32) + private val privateKey = ByteArray(32) { (it + 3).toByte() } + private val walletId = ByteArray(32) { (it + 11).toByte() } + private val mnemonic = "abandon abandon abandon abandon abandon abandon " + + "abandon abandon abandon abandon abandon about" + + private lateinit var fake: DeviceBoundLockDefectFakeKeystore + private lateinit var storage: WalletStorage + + @Before + fun setUp() = runBlocking { + fake = DeviceBoundLockDefectFakeKeystore() + storage = WalletStorage(ApplicationProvider.getApplicationContext(), fake) + // Shared DataStore file — clear prior state, the defect record included. + storage.deleteAll() + } + + /** Drive the master-alias write ladder until the defect is on record. */ + private suspend fun recordDefectViaMnemonicWrite() { + fake.failMasterEncrypts = Int.MAX_VALUE + storage.storeMnemonic(walletId, mnemonic) + fake.failMasterEncrypts = 0 + assertTrue(storage.isMasterKeyLockBindingDefectObserved()) + } + + @Test + fun shouldFailFastWhenIdentityKeyReadIsDeniedOnGenuinelyLockedDevice() { + runBlocking { storage.storePrivateKey(pubkeyHex, privateKey) } + fake.deviceBoundDecryptCalls = 0 + fake.lockState = DeviceLockState(isDeviceLocked = true, isKeyguardLocked = true) + fake.failDeviceBoundDecrypts = Int.MAX_VALUE + + val thrown = assertThrows(KeystoreDeviceLockedException::class.java) { + runBlocking { storage.retrievePrivateKey(pubkeyHex) } + } + assertTrue(thrown.deviceReportsLocked) + assertEquals(KeystoreManager.KEYS_ALIAS_DEVICE_BOUND, thrown.alias) + // Genuinely locked is a CORRECT denial: no retry, and the device is + // not branded defective. + assertEquals(1, fake.deviceBoundDecryptCalls) + assertFalse(runBlocking { storage.isMasterKeyLockBindingDefectObserved() }) + } + + @Test + fun shouldNotTreatALockDenialAsAWrongKey() = runBlocking { + storage.storePrivateKey(pubkeyHex, privateKey) + fake.failDeviceBoundDecrypts = Int.MAX_VALUE + + // KeystoreDeviceLockedException IS a GeneralSecurityException, so + // without an explicit clause it would fall into the recovery ladder + // and surface as `null` — which the signer and key-health paths read + // as "this key is stranded, re-derive it". The key is intact; only + // the gate is shut. + assertThrows(KeystoreDeviceLockedException::class.java) { + runBlocking { storage.retrievePrivateKey(pubkeyHex) } + } + // Same reasoning for the health probe: an intact key behind a shut + // gate must not be offered for repair. + assertTrue(storage.probeIdentityKeyRecoverability(pubkeyHex)) + } + + @Test + fun shouldRetryFalseLockedIdentityReadAndSucceedWithoutBrandingTheDevice() = runBlocking { + storage.storePrivateKey(pubkeyHex, privateKey) + fake.deviceBoundDecryptCalls = 0 + fake.failDeviceBoundDecrypts = 1 // one transient blip + + assertArrayEquals(privateKey, storage.retrievePrivateKey(pubkeyHex)) + assertEquals(2, fake.deviceBoundDecryptCalls) + assertFalse(storage.isMasterKeyLockBindingDefectObserved()) + assertEquals(0, fake.unboundEncryptCalls) + } + + @Test + fun shouldRecordDefectWhenFalseLockedIdentityReadRetriesExhaust() = runBlocking { + storage.storePrivateKey(pubkeyHex, privateKey) + fake.deviceBoundDecryptCalls = 0 + fake.failDeviceBoundDecrypts = Int.MAX_VALUE + + assertThrows(KeystoreDeviceLockedException::class.java) { + runBlocking { storage.retrievePrivateKey(pubkeyHex) } + } + // One attempt plus the full DEVICE_FALSE_LOCKED_RETRY_DELAYS_MS + // schedule (3 delays). + assertEquals(4, fake.deviceBoundDecryptCalls) + assertTrue(storage.isMasterKeyLockBindingDefectObserved()) + } + + @Test + fun shouldWriteNewIdentityKeysToTheUnboundAliasOnceDefectIsOnRecord() = runBlocking { + recordDefectViaMnemonicWrite() + fake.unboundEncryptCalls = 0 + fake.deviceBoundEncryptCalls = 0 + + storage.storePrivateKey(pubkeyHex, privateKey) + + // The lock-bound alias is not consulted at all, so this key can never + // be taken hostage by the defective gate. + assertEquals(1, fake.unboundEncryptCalls) + assertEquals(0, fake.deviceBoundEncryptCalls) + assertArrayEquals(privateKey, storage.retrievePrivateKey(pubkeyHex)) + assertEquals(0, fake.deviceBoundDecryptCalls) + } + + @Test + fun shouldRewrapStrandedIdentityKeyOffTheDefectiveGateOnFirstReadThatSucceeds() = runBlocking { + // The MO-972 field shape: the key was stored BEFORE the device's + // defect was known, so it sits under the lock-bound alias. + storage.storePrivateKey(pubkeyHex, privateKey) + assertEquals(1, fake.deviceBoundEncryptCalls) + fake.unboundEncryptCalls = 0 + + // Session 1 — the gate is jammed. Signing fails, but registers the + // device. Only a read can discover this: the key is already stored, + // so nothing writes an identity key again. + fake.failDeviceBoundDecrypts = Int.MAX_VALUE + assertThrows(KeystoreDeviceLockedException::class.java) { + runBlocking { storage.retrievePrivateKey(pubkeyHex) } + } + assertTrue(storage.isMasterKeyLockBindingDefectObserved()) + assertEquals(0, fake.unboundEncryptCalls) + + // Session 2 — the gate lets a read through. The record armed the + // re-wrap, which moves the blob to the never-lock-bound alias. + fake.failDeviceBoundDecrypts = 0 + assertArrayEquals(privateKey, storage.retrievePrivateKey(pubkeyHex)) + assertEquals(1, fake.unboundEncryptCalls) + + // From here signing never touches the defective gate again — even + // while it is jammed solid. + fake.deviceBoundDecryptCalls = 0 + fake.failDeviceBoundDecrypts = Int.MAX_VALUE + assertArrayEquals(privateKey, storage.retrievePrivateKey(pubkeyHex)) + assertEquals(0, fake.deviceBoundDecryptCalls) + assertTrue(fake.unboundDecryptCalls >= 1) + } + + @Test + fun shouldScrubIdentityKeyPlaintextWhenCancelledDuringMigration() { + runBlocking { + storage.storePrivateKey(pubkeyHex, privateKey) + recordDefectViaMnemonicWrite() + } + fake.lastIdentityDecryptRef = null + + // migrateToPolicyAlias rethrows CancellationException by design, so a + // cancellation inside it unwinds PAST retrievePrivateKey's return and + // the owner never gets the buffer to scrub. Covers the legacy + // migration and recovery ladder too — they share the helper. + fake.onUnboundIdentityEncrypt = { + fake.onUnboundIdentityEncrypt = null + throw kotlin.coroutines.cancellation.CancellationException("cancelled mid-migration") + } + + assertThrows(kotlin.coroutines.cancellation.CancellationException::class.java) { + runBlocking { storage.retrievePrivateKey(pubkeyHex) } + } + val buf = fake.lastIdentityDecryptRef + assertNotNull("expected the identity-key plaintext to have been captured", buf) + assertTrue( + "decrypted identity key must be zeroed when it cannot be returned", + buf!!.all { it == 0.toByte() }, + ) + } + + @Test + fun shouldKeepStrandedKeyIntactWhenRewrapFails() = runBlocking { + storage.storePrivateKey(pubkeyHex, privateKey) + recordDefectViaMnemonicWrite() + fake.unboundEncryptCalls = 0 + + // Re-wrap is best-effort: its failure must neither fail the read nor + // corrupt the blob, and the next read tries again. + fake.failUnboundEncrypts = 1 + assertArrayEquals(privateKey, storage.retrievePrivateKey(pubkeyHex)) + assertNotNull(storage.retrievePrivateKey(pubkeyHex)) // retried re-wrap lands + + fake.deviceBoundDecryptCalls = 0 + assertArrayEquals(privateKey, storage.retrievePrivateKey(pubkeyHex)) + assertEquals(0, fake.deviceBoundDecryptCalls) // now on the unbound alias + } +} + +/** + * Fake [KeystoreManager] for the DEVICE_BOUND identity aliases plus the + * master alias (needed only to drive the write ladder that records the + * defect). + * + * [KeystoreManager.KEYS_ALIAS_DEVICE_BOUND] is modeled per its real + * contract — lock-bound, so deniable — and raises the TYPED + * [KeystoreDeviceLockedException] the production `decrypt` now produces for + * it. [KeystoreManager.KEYS_ALIAS_DEVICE_BOUND_UNBOUND] is modeled per ITS + * contract: never lock-bound, so never denied by any lock state. Each blob's + * leading byte marks the alias that produced it and [decrypt] rejects a + * mismatch, so the tests prove reads route to the recorded alias. + */ +private class DeviceBoundLockDefectFakeKeystore : + KeystoreManager(KeySecurityPolicy.DEVICE_BOUND) { + + var lockState = DeviceLockState(isDeviceLocked = false, isKeyguardLocked = false) + + /** Scripted device-locked denials for the LOCK-BOUND identity alias. */ + var failDeviceBoundDecrypts = 0 + + /** Scripted device-locked denials for the master-alias encrypt. */ + var failMasterEncrypts = 0 + + /** Scripted unclassified failures of the unbound-alias encrypt. */ + var failUnboundEncrypts = 0 + + var deviceBoundDecryptCalls = 0 + var unboundDecryptCalls = 0 + var deviceBoundEncryptCalls = 0 + var unboundEncryptCalls = 0 + + override fun sampleDeviceLockState(): DeviceLockState = lockState + + /** The device-local witness: provisioned by any unbound-alias encrypt. */ + var unboundMasterKeyProvisioned = false + + /** + * Invoked inside the UNBOUND identity-alias encrypt — i.e. inside + * `migrateToPolicyAlias`, after the plaintext is in hand and before the + * caller can return it. + */ + var onUnboundIdentityEncrypt: (() -> Unit)? = null + + /** The buffer the last identity decrypt handed back (scrub evidence). */ + var lastIdentityDecryptRef: ByteArray? = null + + override fun hasUnboundMasterKey(): Boolean = unboundMasterKeyProvisioned + + override fun effectiveKeySecurityPolicy(): KeySecurityPolicy = keySecurityPolicy + + override fun hasIdentityKeysKey(alias: String): Boolean = isIdentityKeysAlias(alias) + + override fun keysAliasFingerprintOrNull(alias: String): String? = + if (isIdentityKeysAlias(alias)) fpOf(alias) else null + + override fun keysAliasFingerprint(alias: String): String = fpOf(alias) + + override fun hasLegacyKeysKey(): Boolean = false + + override fun hasLegacyRsaKeysKey(): Boolean = false + + override fun decryptLegacyKeysBlob(blob: EncryptedBlob): ByteArray? = null + + override fun decryptLegacyRsaKeysBlob(blob: EncryptedBlob): ByteArray? = null + + override fun opensUnderNonGatedDeviceBoundSibling(blob: EncryptedBlob): Boolean = false + + override fun encryptForIdentityKeys(plaintext: ByteArray): KeysAliasEncryptedBlob = + encryptForIdentityKeysAlias(KEYS_ALIAS_DEVICE_BOUND, plaintext) + + override fun encryptForIdentityKeysAlias( + alias: String, + plaintext: ByteArray, + ): KeysAliasEncryptedBlob { + when (alias) { + KEYS_ALIAS_DEVICE_BOUND -> deviceBoundEncryptCalls++ + KEYS_ALIAS_DEVICE_BOUND_UNBOUND -> { + unboundEncryptCalls++ + onUnboundIdentityEncrypt?.invoke() + val scripted = failUnboundEncrypts > 0 + if (scripted) failUnboundEncrypts-- + check(!scripted) { "scripted unbound-alias encrypt failure" } + } + else -> error("fake models only the DEVICE_BOUND identity aliases, got '$alias'") + } + return KeysAliasEncryptedBlob(rsaBlob(alias, plaintext), fpOf(alias), alias) + } + + override fun encrypt(plaintext: ByteArray, alias: String): EncryptedBlob = when (alias) { + MASTER_ALIAS -> { + val scripted = failMasterEncrypts > 0 + if (scripted) failMasterEncrypts-- + if (scripted || lockState.isDeviceLocked) { + throw KeystoreDeviceLockedException( + alias = alias, + operation = "encrypt", + lockState = sampleDeviceLockState(), + ) + } + EncryptedBlob(iv = ByteArray(12) { 9 }, ciphertext = plaintext.copyOf()) + } + MASTER_ALIAS_UNBOUND -> { + unboundMasterKeyProvisioned = true + EncryptedBlob(iv = ByteArray(12) { 8 }, ciphertext = plaintext.copyOf()) + } + else -> error("fake models only the master aliases for AES, got '$alias'") + } + + override fun decrypt(blob: EncryptedBlob, alias: String): ByteArray { + when (alias) { + KEYS_ALIAS_DEVICE_BOUND -> { + deviceBoundDecryptCalls++ + val scripted = failDeviceBoundDecrypts > 0 + if (scripted) failDeviceBoundDecrypts-- + if (scripted || lockState.isDeviceLocked) { + // What the production decrypt now raises for this alias: + // it is lock-bound and NOT auth-gated, so Keystore's + // "user not authenticated" can only be the lock gate. + throw KeystoreDeviceLockedException( + alias = alias, + operation = "decrypt", + lockState = sampleDeviceLockState(), + ) + } + } + KEYS_ALIAS_DEVICE_BOUND_UNBOUND -> unboundDecryptCalls++ + MASTER_ALIAS, MASTER_ALIAS_UNBOUND -> return blob.ciphertext.copyOf() + else -> error("fake cannot decrypt under '$alias'") + } + check(blob.ciphertext[0] == aliasTag(alias)) { + "blob was decrypted under the wrong alias: '$alias' cannot open a blob " + + "produced by tag ${blob.ciphertext[0]}" + } + val len = blob.ciphertext[1].toInt() and 0xFF + return blob.ciphertext.copyOfRange(2, 2 + len).also { lastIdentityDecryptRef = it } + } + + private fun fpOf(alias: String): String = "fake-fp-$alias" + + private fun aliasTag(alias: String): Byte = + if (alias == KEYS_ALIAS_DEVICE_BOUND) 1 else 2 + + private fun rsaBlob(alias: String, plain: ByteArray): EncryptedBlob { + val ct = ByteArray(RSA_BLOB_BYTES) + ct[0] = aliasTag(alias) + ct[1] = plain.size.toByte() + plain.copyInto(ct, 2) + return EncryptedBlob(iv = ByteArray(0), ciphertext = ct) + } + + private companion object { + const val RSA_BLOB_BYTES = 2048 / 8 + } +}