From 3ee676b9adcafbb4d606f126fd5368d6799c8d02 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:25:11 -0400 Subject: [PATCH 1/7] feat(kotlin-sdk): configurable identity-key security policy + queryable pending-key state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #4053. Identity private keys were always wrapped under the auth-gated Keystore alias (KEYS_ALIAS, setUserAuthenticationRequired, ~30s post-unlock validity) with no way for a host app that gates wallet access behind its own auth model (e.g. an app-side PIN, as the Dash Android wallet does) to opt out — sign-time decrypts outside the window fail, and there was no way to even see that a key had been persisted watch-only. - New KeySecurityPolicy enum: AUTH_GATED (default — behavior unchanged, same alias, same auth window) and DEVICE_BOUND (a separate non-auth-gated but still hardware-backed / StrongBox-preferring, non-exportable RSA wrapping alias). Distinct aliases because Keystore auth parameters are fixed at key generation. - KeystoreManager takes the policy at construction and exposes the selected keysAlias; the RSA helpers are parameterized per alias and the user-auth gate is applied only to the AUTH_GATED alias. - WalletStorage gains a policy-taking convenience constructor and routes identity-key encrypt/decrypt through the keystore's keysAlias. - PlatformWalletPersistenceHandler no longer skips a failed identity-key derive/store silently: it logs at ERROR (the key is persisted watch-only and cannot sign) and records a queryable PendingIdentityKey entry on a StateFlow, cleared automatically when a later persist round stores the key. PlatformWalletManager exposes the flow as pendingIdentityKeys so hosts can surface a repair path (repairIdentityKey). Defaults are unchanged everywhere: constructing KeystoreManager / WalletStorage without a policy keeps the exact historical AUTH_GATED behavior. Co-Authored-By: Claude Fable 5 --- .../PlatformWalletPersistenceHandler.kt | 96 ++++++++++++- .../dashsdk/security/KeySecurityPolicy.kt | 57 ++++++++ .../dashsdk/security/KeystoreManager.kt | 126 ++++++++++++------ .../dashsdk/security/WalletStorage.kt | 53 +++++--- .../dashsdk/wallet/PlatformWalletManager.kt | 11 ++ .../PlatformWalletPersistenceHandlerTest.kt | 93 +++++++++++++ .../dashsdk/security/KeySecurityPolicyTest.kt | 71 ++++++++++ 7 files changed, 449 insertions(+), 58 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyTest.kt diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 9c29fe8e8f..f5ea851496 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -4,6 +4,9 @@ import android.util.Log import androidx.room.withTransaction import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.dashfoundation.dashsdk.ffi.AccountSpecData @@ -122,6 +125,44 @@ class PlatformWalletPersistenceHandler( /** Open rounds keyed by walletId hex (a round is per-walletId). */ private val buffers = HashMap() + /** + * An identity key whose private-half derivation/storage failed — the + * key was persisted **watch-only** and cannot sign until re-derived + * (e.g. via `PlatformWalletManager.repairIdentityKey`). + */ + data class PendingIdentityKey( + /** Hex of the wallet the key belongs to. */ + val walletIdHex: String, + /** Base58 of the owning identity id. */ + val identityIdBase58: String, + /** On-identity key id. */ + val keyId: Int, + /** Lowercase hex of the compressed public key (the storage key). */ + val publicKeyHex: String, + /** Derivation breadcrumb: identity index. */ + val identityIndex: Int, + /** Derivation breadcrumb: key index. */ + val keyIndex: Int, + /** Human-readable failure reason (exception message or contract miss). */ + val reason: String, + /** Epoch millis of the (latest) failure. */ + val failedAtMs: Long, + ) + + private val _pendingIdentityKeys = + MutableStateFlow>(emptyMap()) + + /** + * Queryable "keys pending" state: identity keys whose private half + * could not be derived/stored by [onPersistIdentityKeyUpsert] (keyed by + * public-key hex). Such keys are persisted watch-only — signing with + * them fails — so hosts should watch this flow and surface a repair + * path. An entry clears automatically when a later persist round (or an + * explicit re-derive that replays the upsert) stores the key. + */ + val pendingIdentityKeys: StateFlow> = + _pendingIdentityKeys.asStateFlow() + /** * Stage a write. If a round is open for [walletId] the op is buffered * for the round's single transaction; otherwise it runs immediately @@ -783,17 +824,50 @@ class PlatformWalletPersistenceHandler( val deriver = privateKeyDeriver val derivedKeychainId: String? = if (derivationIndicesIsSome && deriver != null && !readOnly) { - runCatching { + val keyOwnerWalletId = if (walletIdIsSome) keyWalletId else walletId + val outcome = runCatching { deriver.deriveAndStore( - walletId = if (walletIdIsSome) keyWalletId else walletId, + walletId = keyOwnerWalletId, publicKeyData = publicKeyData, identityIndex = identityIndex, keyIndex = keyIndex, ) - }.getOrElse { t -> - Log.w(TAG, "identity private-key derive/store failed; key stays watch-only", t) - null } + val id = outcome.getOrNull() + if (id != null) { + // Stored — clear any earlier failure for this pubkey. + clearPendingIdentityKey(publicKeyData.toHex()) + } else { + // NOT silent (dashpay/platform#4053): the key is being + // persisted watch-only, so every signature with it will + // fail until it is re-derived. Log loudly and record a + // queryable pending entry (see [pendingIdentityKeys]). + val reason = outcome.exceptionOrNull()?.let { t -> + t.message ?: t.javaClass.simpleName + } ?: "deriver returned no storage identifier" + Log.e( + TAG, + "identity private-key derive/store FAILED — key " + + "${publicKeyData.toHex()} (identity ${identityId.toBase58String()}, " + + "keyId $keyId, slot $identityIndex/$keyIndex) is persisted " + + "WATCH-ONLY and cannot sign until re-derived " + + "(see PlatformWalletPersistenceHandler.pendingIdentityKeys): $reason", + outcome.exceptionOrNull(), + ) + recordPendingIdentityKey( + PendingIdentityKey( + walletIdHex = keyOwnerWalletId.toHex(), + identityIdBase58 = identityId.toBase58String(), + keyId = keyId, + publicKeyHex = publicKeyData.toHex(), + identityIndex = identityIndex, + keyIndex = keyIndex, + reason = reason, + failedAtMs = System.currentTimeMillis(), + ), + ) + } + id } else { null } @@ -2056,6 +2130,18 @@ class PlatformWalletPersistenceHandler( } } + // ── Pending identity-key bookkeeping (#4053) ────────────────────── + + private fun recordPendingIdentityKey(entry: PendingIdentityKey) { + _pendingIdentityKeys.value = _pendingIdentityKeys.value + (entry.publicKeyHex to entry) + } + + private fun clearPendingIdentityKey(publicKeyHex: String) { + if (publicKeyHex in _pendingIdentityKeys.value) { + _pendingIdentityKeys.value = _pendingIdentityKeys.value - publicKeyHex + } + } + // ── Error / threading guards ────────────────────────────────────── /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt new file mode 100644 index 0000000000..301f530881 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt @@ -0,0 +1,57 @@ +package org.dashfoundation.dashsdk.security + +/** + * Security policy for the Keystore alias that wraps **identity private + * keys** ([WalletStorage.storePrivateKey] / [WalletStorage.retrievePrivateKey]). + * + * The SDK's default alias gates every identity-key decrypt behind Android + * user authentication (biometric / device credential) with a short + * post-unlock validity window — the right model when the SDK owns the + * auth UX. Host apps that already gate wallet access behind their own + * auth model (e.g. an app-level PIN that decrypts the wallet) end up with + * a *second*, redundant auth prompt at signing time — and signing fails + * outside the ~30 s window entirely when no [BiometricGate] is wired. + * [DEVICE_BOUND] lets such hosts opt into a non-gated (but still + * hardware-backed, non-exportable) wrapping key instead. + * + * ## Semantics + * + * - [AUTH_GATED] — the default; behavior is exactly the historical one. + * Identity keys are wrapped under [KeystoreManager.KEYS_ALIAS]: encrypt + * (store) never prompts, decrypt (sign) requires user authentication + * within [KeystoreManager.AUTH_VALIDITY_SECONDS] of a biometric / + * device-credential auth, re-promptable through a [BiometricGate]. + * - [DEVICE_BOUND] — identity keys are wrapped under the separate + * [KeystoreManager.KEYS_ALIAS_DEVICE_BOUND] alias: the same + * StrongBox-preferring, non-exportable RSA wrapping pair, but with **no** + * `setUserAuthenticationRequired` gate, so decrypts never throw + * `UserNotAuthenticatedException` and never need a [BiometricGate]. + * Keys remain bound to this device's Keystore (`setUnlockedDeviceRequired` + * still applies) — the host app is responsible for gating *access* to + * signing flows (PIN, biometrics, session policy) itself. + * + * ## Choosing and switching + * + * The two policies use **distinct Keystore aliases**, and a blob written + * under one alias can only be decrypted by that alias's private key. Pick + * the policy once per install and construct [WalletStorage] / + * [KeystoreManager] with it consistently: switching an existing install to + * the other policy leaves previously stored identity keys undecryptable + * (they surface through the key-health / re-derive path, e.g. + * `PlatformWalletManager.repairIdentityKey`, which re-encrypts under the + * current policy's alias). Mnemonics ([KeystoreManager.MASTER_ALIAS]) are + * unaffected — this policy governs identity keys only. + */ +enum class KeySecurityPolicy { + /** + * Identity-key decrypts require Android user authentication within + * [KeystoreManager.AUTH_VALIDITY_SECONDS] (the historical default). + */ + AUTH_GATED, + + /** + * Identity-key decrypts are hardware-backed but not auth-gated; the + * host app supplies its own authentication model. + */ + DEVICE_BOUND, +} 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 669463efe2..d4c411701b 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 @@ -40,21 +40,49 @@ import javax.crypto.spec.PSource * requires auth (the read-with-auth hardening mirrors the iOS seed * policy). A symmetric auth-required key would gate encrypt too and break * those unprompted write paths. + * - [KEYS_ALIAS_DEVICE_BOUND] `org.dashfoundation.wallet.keys.devicebound` — + * the [KeySecurityPolicy.DEVICE_BOUND] variant of the identity-keys + * alias: the same RSA-2048 OAEP wrapping pair, but withOUT the + * user-authentication gate on the private key, for host apps that gate + * signing behind their own auth model (see [KeySecurityPolicy]). + * + * Which identity-keys alias a manager instance writes/reads is selected by + * the [keySecurityPolicy] constructor parameter (default: the historical + * [KeySecurityPolicy.AUTH_GATED]) and exposed as [keysAlias]. * * StrongBox is used when available, with a software-Keystore fallback. + * + * @param keySecurityPolicy security policy for the identity-keys alias. + * Defaults to [KeySecurityPolicy.AUTH_GATED], which preserves the + * historical behavior exactly. */ -class KeystoreManager { +class KeystoreManager( + val keySecurityPolicy: KeySecurityPolicy = KeySecurityPolicy.AUTH_GATED, +) { + + /** + * The identity-keys alias this manager targets, per + * [keySecurityPolicy]: [KEYS_ALIAS] (auth-gated decrypt) or + * [KEYS_ALIAS_DEVICE_BOUND] (non-gated decrypt). [WalletStorage] passes + * this to [encrypt]/[decrypt] for identity-key material. + */ + val keysAlias: String + get() = when (keySecurityPolicy) { + KeySecurityPolicy.AUTH_GATED -> KEYS_ALIAS + KeySecurityPolicy.DEVICE_BOUND -> KEYS_ALIAS_DEVICE_BOUND + } /** * Encrypt [plaintext] under [alias]; returns (iv, ciphertext). - * [MASTER_ALIAS] uses AES-256-GCM (iv is the GCM nonce). [KEYS_ALIAS] - * uses the RSA public key (no iv — the blob's iv is empty) and never - * requires authentication, so identity-key writes never prompt. + * [MASTER_ALIAS] uses AES-256-GCM (iv is the GCM nonce). The + * identity-keys aliases ([KEYS_ALIAS] / [KEYS_ALIAS_DEVICE_BOUND]) + * use the RSA public key (no iv — the blob's iv is empty) and never + * require authentication, so identity-key writes never prompt. */ fun encrypt(plaintext: ByteArray, alias: String = MASTER_ALIAS): EncryptedBlob { - if (alias == KEYS_ALIAS) { + if (isIdentityKeysAlias(alias)) { val cipher = Cipher.getInstance(RSA_TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, keysPublicKey(), oaepSpec()) + cipher.init(Cipher.ENCRYPT_MODE, keysPublicKey(alias), oaepSpec()) return EncryptedBlob(iv = ByteArray(0), ciphertext = cipher.doFinal(plaintext)) } val cipher = Cipher.getInstance(AES_TRANSFORMATION) @@ -67,12 +95,13 @@ class KeystoreManager { * [KEYS_ALIAS] RSA private-key decrypt throws * `UserNotAuthenticatedException` when the [AUTH_VALIDITY_SECONDS] auth * window is closed — the caller (`KeystoreSigner`) prompts via the - * `BiometricGate` and retries. + * `BiometricGate` and retries. The [KEYS_ALIAS_DEVICE_BOUND] decrypt is + * never auth-gated (see [KeySecurityPolicy.DEVICE_BOUND]). */ fun decrypt(blob: EncryptedBlob, alias: String = MASTER_ALIAS): ByteArray { - if (alias == KEYS_ALIAS) { + if (isIdentityKeysAlias(alias)) { val cipher = Cipher.getInstance(RSA_TRANSFORMATION) - cipher.init(Cipher.DECRYPT_MODE, keysPrivateKey(), oaepSpec()) + cipher.init(Cipher.DECRYPT_MODE, keysPrivateKey(alias), oaepSpec()) return cipher.doFinal(blob.ciphertext) } val cipher = Cipher.getInstance(AES_TRANSFORMATION) @@ -154,18 +183,23 @@ class KeystoreManager { } } - // ── KEYS_ALIAS: RSA-2048 OAEP keypair (identity private keys) ── - // Public key encrypts (never auth-gated → unprompted writes); private key - // decrypts under user auth within AUTH_VALIDITY_SECONDS (signing prompts). + // ── Identity-keys aliases: RSA-2048 OAEP keypair per alias ── + // Public key encrypts (never auth-gated → unprompted writes); the + // KEYS_ALIAS private key decrypts under user auth within + // AUTH_VALIDITY_SECONDS (signing prompts), while the + // KEYS_ALIAS_DEVICE_BOUND private key decrypts without a gate + // (KeySecurityPolicy.DEVICE_BOUND). - private fun keysPublicKey(): PublicKey = - androidKeyStore().getCertificate(KEYS_ALIAS)?.publicKey ?: ensureKeysKeyPair().public + private fun keysPublicKey(alias: String): PublicKey = + androidKeyStore().getCertificate(alias)?.publicKey ?: ensureKeysKeyPair(alias).public - private fun keysPrivateKey(): PrivateKey = - (androidKeyStore().getKey(KEYS_ALIAS, null) as? PrivateKey) ?: ensureKeysKeyPair().private + private fun keysPrivateKey(alias: String): PrivateKey = + (androidKeyStore().getKey(alias, null) as? PrivateKey) ?: ensureKeysKeyPair(alias).private /** - * Return the RSA [KEYS_ALIAS] keypair, creating it on first use. + * Return the RSA identity-keys keypair for [alias], creating it on + * first use. The user-authentication gate is applied only to + * [KEYS_ALIAS]; [KEYS_ALIAS_DEVICE_BOUND] is generated without one. * * Serialized on a process-wide lock (the AndroidKeyStore alias is * process-global, and this manager is instantiated per [WalletStorage]) @@ -177,10 +211,11 @@ class KeystoreManager { * public key the first already encrypted with, leaving that stored * private key undecryptable by the surviving alias. */ - private fun ensureKeysKeyPair(): KeyPair = synchronized(KEYS_ALIAS_LOCK) { + private fun ensureKeysKeyPair(alias: String): KeyPair = synchronized(KEYS_ALIAS_LOCK) { + val authGated = alias == KEYS_ALIAS val keyStore = androidKeyStore() - val existingPrivate = keyStore.getKey(KEYS_ALIAS, null) as? PrivateKey - val existingCert = keyStore.getCertificate(KEYS_ALIAS) + val existingPrivate = keyStore.getKey(alias, null) as? PrivateKey + val existingCert = keyStore.getCertificate(alias) if (existingPrivate != null && existingCert != null) { // A valid RSA pair already exists (possibly just created by a // thread that raced us) — reuse it, never delete it. @@ -188,11 +223,11 @@ class KeystoreManager { } // Absent, or a stale symmetric entry from an earlier build (which // gated encrypt too and broke unprompted writes) — drop and recreate. - runCatching { keyStore.deleteEntry(KEYS_ALIAS) } + runCatching { keyStore.deleteEntry(alias) } fun spec(strongBox: Boolean): KeyGenParameterSpec { val builder = KeyGenParameterSpec.Builder( - KEYS_ALIAS, + alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, ) .setKeySize(RSA_KEY_SIZE) @@ -203,20 +238,23 @@ class KeystoreManager { .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA1) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) .setUnlockedDeviceRequired(true) - .setUserAuthenticationRequired(true) - // setUserAuthenticationParameters is API 30+ (Android 11); on the - // minSdk-29 (Android 10) floor fall back to the deprecated pre-30 - // time-bound API. Pre-30 the key accepts any enrolled authenticator - // for the window; the STRONG|DEVICE_CREDENTIAL restriction (and the - // AuthPrompt that requests it) still applies on 30+. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - builder.setUserAuthenticationParameters( - AUTH_VALIDITY_SECONDS, - KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL, - ) - } else { - @Suppress("DEPRECATION") - builder.setUserAuthenticationValidityDurationSeconds(AUTH_VALIDITY_SECONDS) + if (authGated) { + builder.setUserAuthenticationRequired(true) + // setUserAuthenticationParameters is API 30+ (Android 11); on + // the minSdk-29 (Android 10) floor fall back to the deprecated + // pre-30 time-bound API. Pre-30 the key accepts any enrolled + // authenticator for the window; the STRONG|DEVICE_CREDENTIAL + // restriction (and the AuthPrompt that requests it) still + // applies on 30+. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setUserAuthenticationParameters( + AUTH_VALIDITY_SECONDS, + KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL, + ) + } else { + @Suppress("DEPRECATION") + builder.setUserAuthenticationValidityDurationSeconds(AUTH_VALIDITY_SECONDS) + } } if (strongBox) builder.setIsStrongBoxBacked(true) return builder.build() @@ -248,11 +286,23 @@ class KeystoreManager { const val MASTER_ALIAS = "org.dashfoundation.wallet.master" const val KEYS_ALIAS = "org.dashfoundation.wallet.keys" - /** Auth window for the identity-keys alias, in seconds. */ + /** + * Non-auth-gated identity-keys alias, selected by + * [KeySecurityPolicy.DEVICE_BOUND]. Distinct from [KEYS_ALIAS] + * because Keystore auth parameters are fixed at generation — the two + * policies can never share one alias. + */ + const val KEYS_ALIAS_DEVICE_BOUND = "org.dashfoundation.wallet.keys.devicebound" + + /** Auth window for the auth-gated identity-keys alias, in seconds. */ const val AUTH_VALIDITY_SECONDS = 30 + /** Whether [alias] is one of the RSA-wrapped identity-keys aliases. */ + fun isIdentityKeysAlias(alias: String): Boolean = + alias == KEYS_ALIAS || alias == KEYS_ALIAS_DEVICE_BOUND + // Guards first-use creation/migration of the process-global - // KEYS_ALIAS entry across concurrent callers (and across the + // identity-keys entries across concurrent callers (and across the // per-WalletStorage KeystoreManager instances). private val KEYS_ALIAS_LOCK = Any() 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 0604d93484..9701a5b13f 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 @@ -22,15 +22,35 @@ private val Context.secretsStore: DataStore by preferencesDataStore * keys, stored base64 in a dedicated Preferences DataStore. * Key layout mirrors the iOS account naming: * - `mnemonic.` — wallet mnemonics (master alias, AES-GCM) - * - `privkey.` — identity private keys (keys alias: RSA - * public-key encrypt / auth-gated private-key decrypt) + * - `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 + * [KeySecurityPolicy]) + * + * The identity-key security policy is fixed by the [keystore] this storage + * wraps; use the policy-taking constructor to opt into + * [KeySecurityPolicy.DEVICE_BOUND] (see [KeySecurityPolicy] for the + * semantics and the stability requirement). The default is the historical + * [KeySecurityPolicy.AUTH_GATED] behavior, unchanged. */ class WalletStorage( context: Context, private val keystore: KeystoreManager = KeystoreManager(), ) { + /** + * Construct with an explicit identity-key [keySecurityPolicy] — + * convenience for host apps that don't otherwise need to touch + * [KeystoreManager]. `WalletStorage(context)` keeps the + * [KeySecurityPolicy.AUTH_GATED] default. + */ + constructor(context: Context, keySecurityPolicy: KeySecurityPolicy) : + this(context, KeystoreManager(keySecurityPolicy)) + private val store = context.secretsStore + /** The identity-key security policy this storage was constructed with. */ + val keySecurityPolicy: KeySecurityPolicy get() = keystore.keySecurityPolicy + // ── Mnemonics ───────────────────────────────────────────────────── suspend fun storeMnemonic(walletId: ByteArray, mnemonic: String) { @@ -87,29 +107,32 @@ class WalletStorage( /** * Store raw private-key bytes for [pubkeyHex], encrypted with the - * [KeystoreManager.KEYS_ALIAS] RSA public key. Public-key encrypt is - * never auth-gated, so this never prompts and never throws - * `UserNotAuthenticatedException` — matching iOS's silent identity-key - * write, and letting the persistence callback (which runs on a Rust - * Tokio thread under the wallet-manager write lock, where a prompt is - * impossible) store keys. Per the CLAUDE.md doctrine this is the one - * allowed Kotlin-side persistence of key material: Rust derives, we - * encrypt. Reads ([retrievePrivateKey]) still require auth. + * [KeystoreManager.keysAlias] RSA public key. Public-key encrypt is + * never auth-gated (under either [KeySecurityPolicy]), so this never + * prompts and never throws `UserNotAuthenticatedException` — matching + * iOS's silent identity-key write, and letting the persistence callback + * (which runs on a Rust Tokio thread under the wallet-manager write + * lock, where a prompt is impossible) store keys. Per the CLAUDE.md + * doctrine this is the one allowed Kotlin-side persistence of key + * material: Rust derives, we encrypt. Reads ([retrievePrivateKey]) + * require auth only under [KeySecurityPolicy.AUTH_GATED]. */ suspend fun storePrivateKey(pubkeyHex: String, privateKey: ByteArray) { - val blob = keystore.encrypt(privateKey, alias = KeystoreManager.KEYS_ALIAS) + val blob = keystore.encrypt(privateKey, alias = keystore.keysAlias) store.edit { it[privateKeyKey(pubkeyHex)] = encode(blob) } } /** - * Decrypt the private key for [pubkeyHex]. Throws + * Decrypt the private key for [pubkeyHex]. Under + * [KeySecurityPolicy.AUTH_GATED] this throws * `UserNotAuthenticatedException` when the auth window expired — the - * caller (KeystoreSigner) routes through [BiometricGate] and retries. + * caller (KeystoreSigner) routes through [BiometricGate] and retries; + * under [KeySecurityPolicy.DEVICE_BOUND] it never auth-gates. * Callers must zero the returned array after use. */ suspend fun retrievePrivateKey(pubkeyHex: String): ByteArray? { val encoded = store.data.first()[privateKeyKey(pubkeyHex)] ?: return null - return keystore.decrypt(decode(encoded), alias = KeystoreManager.KEYS_ALIAS) + return keystore.decrypt(decode(encoded), alias = keystore.keysAlias) } suspend fun deletePrivateKey(pubkeyHex: String) { @@ -121,7 +144,7 @@ class WalletStorage( /** * Whether the blob stored for [pubkeyHex] is decryptable under the - * current [KeystoreManager.KEYS_ALIAS] RSA scheme. Blobs written by the + * current [KeystoreManager.keysAlias] RSA scheme. Blobs written by the * pre-RSA AES-GCM scheme survive in the DataStore but lost their key * when the RSA pair replaced it, so signing with them can only fail — * key-health treats them as missing and offers a re-derive. Structural 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 3112dad896..01f8311083 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 @@ -242,6 +242,17 @@ class PlatformWalletManager( network = network, ) + /** + * Identity keys whose private half could not be derived/stored during + * persistence (keyed by public-key hex) — the queryable "keys pending" + * state of dashpay/platform#4053. Such keys were persisted watch-only + * and cannot sign; repair via [repairIdentityKey]. Empty in the healthy + * case. + */ + val pendingIdentityKeys: + kotlinx.coroutines.flow.StateFlow> + get() = persistenceHandler.pendingIdentityKeys + /** `MnemonicResolverHandle` for FFI calls that derive from a stored mnemonic. */ val mnemonicResolverHandle: Long get() = mnemonicResolver.nativeHandle diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 181ca0e76f..c4a9ec389b 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -470,6 +470,99 @@ class PlatformWalletPersistenceHandlerTest { assertNull(row!!.privateKeyKeychainIdentifier) } + // ── Pending identity keys (dashpay/platform#4053: no silent skip) ── + + /** Deriver that always throws — the derive/storage-failure path. */ + private class ThrowingDeriver : PrivateKeyDeriver { + override fun deriveAndStore( + walletId: ByteArray, + publicKeyData: ByteArray, + identityIndex: Int, + keyIndex: Int, + ): String = throw IllegalStateException("keystore unavailable") + } + + private fun upsertIdentityKey(pubkey: ByteArray, identityId: ByteArray) { + handler.onChangesetBegin(walletId) + handler.onPersistIdentityKeyUpsert( + walletId, identityId, 0, 0, 0, 0, false, false, 0, + pubkey, ByteArray(20), true, walletId, + true, 3, 5, 0, ByteArray(32), null, + ) + handler.onChangesetEnd(walletId, success = true) + } + + @Test + fun derivationFailureIsRecordedAsAPendingIdentityKey() = runTest { + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + + val identityId = ByteArray(32) { 15 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 10 } + upsertIdentityKey(pubkey, identityId) + + // The key row persists watch-only (no identifier) — same as before… + val row = db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0) + assertNotNull(row) + assertNull(row!!.privateKeyKeychainIdentifier) + + // …but the failure is now queryable instead of silent. + val pending = handler.pendingIdentityKeys.value + val entry = pending[pubkey.toHex()] + assertNotNull("expected a pending entry for the failed key", entry) + assertEquals(walletId.toHex(), entry!!.walletIdHex) + assertEquals(identityId.toBase58String(), entry.identityIdBase58) + assertEquals(0, entry.keyId) + assertEquals(3, entry.identityIndex) + assertEquals(5, entry.keyIndex) + assertEquals("keystore unavailable", entry.reason) + } + + @Test + fun deriverReturningNullIsAlsoRecordedAsPending() = runTest { + handler = PlatformWalletPersistenceHandler( + db, Dispatchers.Unconfined, FakeDeriver(id = null), + ) + + val identityId = ByteArray(32) { 16 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 11 } + upsertIdentityKey(pubkey, identityId) + + val entry = handler.pendingIdentityKeys.value[pubkey.toHex()] + assertNotNull(entry) + assertEquals("deriver returned no storage identifier", entry!!.reason) + } + + @Test + fun laterSuccessfulDeriveClearsThePendingEntry() = runTest { + // First round fails… + var boom = true + val flaky = object : PrivateKeyDeriver { + override fun deriveAndStore( + walletId: ByteArray, + publicKeyData: ByteArray, + identityIndex: Int, + keyIndex: Int, + ): String = + if (boom) throw IllegalStateException("transient") else "privkey.cafebabe" + } + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, flaky) + + val identityId = ByteArray(32) { 17 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 12 } + upsertIdentityKey(pubkey, identityId) + assertNotNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + + // …a re-persist (e.g. the next sync round) succeeds and clears it. + boom = false + upsertIdentityKey(pubkey, identityId) + assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + val row = db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0) + assertEquals("privkey.cafebabe", row!!.privateKeyKeychainIdentifier) + } + // ── Shielded load round-trip ────────────────────────────────────── @Test diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyTest.kt new file mode 100644 index 0000000000..4bf8cb320d --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyTest.kt @@ -0,0 +1,71 @@ +package org.dashfoundation.dashsdk.security + +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins the dashpay/platform#4053 policy plumbing: the identity-key + * security policy selects the Keystore alias new identity keys are wrapped + * under, defaults to the historical [KeySecurityPolicy.AUTH_GATED] + * behavior everywhere, and rides [WalletStorage] construction so host apps + * with their own auth model can opt into + * [KeySecurityPolicy.DEVICE_BOUND] without touching [KeystoreManager]. + * + * (The Keystore key-generation semantics themselves — auth-gated vs not — + * can't run on the JVM: AndroidKeyStore has no Robolectric provider. The + * alias split is the load-bearing part: Keystore auth parameters are fixed + * at key generation, so the policy MUST resolve to distinct aliases.) + */ +@RunWith(RobolectricTestRunner::class) +class KeySecurityPolicyTest { + + @Test + fun keystoreManagerDefaultsToAuthGated() { + val manager = KeystoreManager() + assertEquals(KeySecurityPolicy.AUTH_GATED, manager.keySecurityPolicy) + assertEquals(KeystoreManager.KEYS_ALIAS, manager.keysAlias) + } + + @Test + fun deviceBoundPolicySelectsTheDedicatedAlias() { + val manager = KeystoreManager(KeySecurityPolicy.DEVICE_BOUND) + assertEquals(KeySecurityPolicy.DEVICE_BOUND, manager.keySecurityPolicy) + assertEquals(KeystoreManager.KEYS_ALIAS_DEVICE_BOUND, manager.keysAlias) + } + + @Test + fun policiesResolveToDistinctAliases() { + // Keystore auth parameters are immutable post-generation — sharing + // one alias across policies would silently keep the first policy. + assertFalse( + KeystoreManager.KEYS_ALIAS == KeystoreManager.KEYS_ALIAS_DEVICE_BOUND, + ) + } + + @Test + fun bothIdentityKeyAliasesAreRecognized() { + assertTrue(KeystoreManager.isIdentityKeysAlias(KeystoreManager.KEYS_ALIAS)) + assertTrue(KeystoreManager.isIdentityKeysAlias(KeystoreManager.KEYS_ALIAS_DEVICE_BOUND)) + assertFalse(KeystoreManager.isIdentityKeysAlias(KeystoreManager.MASTER_ALIAS)) + } + + @Test + fun walletStoragePlumbsThePolicyThrough() { + val storage = WalletStorage( + ApplicationProvider.getApplicationContext(), + KeySecurityPolicy.DEVICE_BOUND, + ) + assertEquals(KeySecurityPolicy.DEVICE_BOUND, storage.keySecurityPolicy) + } + + @Test + fun walletStorageDefaultStaysAuthGated() { + val storage = WalletStorage(ApplicationProvider.getApplicationContext()) + assertEquals(KeySecurityPolicy.AUTH_GATED, storage.keySecurityPolicy) + } +} From 40baa06b32dd6568fe27a42ba32b26d9b82bf735 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:25:33 -0400 Subject: [PATCH 2/7] feat(kotlin-sdk): typed SigningKeyUnavailable error for missing signing keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #4052. A signing failure caused by a missing stored private key ("no private key stored for ") surfaced as the opaque DashSdkError.PlatformWallet.Generic, indistinguishable from any other wallet failure — hosts could not route the user to key repair. The error text originates in Kotlin (KeystoreSigner's completeSign error string), travels through Rust as free text, and returns under the catch-all platform-wallet codes (ErrorUnknown via the blanket PlatformWalletError conversion, or ErrorWalletOperation), so the typed mapping happens on the Kotlin boundary where it is reliable: - New DashSdkError.PlatformWallet.SigningKeyUnavailable with a MESSAGE_MARKER constant; KeystoreSigner now builds its "missing key" completion error from the same constant, so the emitter and the matcher cannot drift. - fromPlatformWalletNative recognizes the marker on the catch-all codes only — the dedicated retry-semantics codes (16/18/19/20) are never overridden. Also pins PlatformWalletFFIResultCode::NotFound (98) as PLATFORM_WALLET_NOT_FOUND_CODE for the #4051 translation. Co-Authored-By: Claude Fable 5 --- .../dashsdk/errors/DashSdkError.kt | 60 ++++++++++++++++++- .../dashsdk/security/KeystoreSigner.kt | 12 +++- .../dashsdk/errors/DashSdkErrorTest.kt | 57 ++++++++++++++++++ 3 files changed, 124 insertions(+), 5 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 54b97461c7..15d0aa4c02 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -144,6 +144,35 @@ sealed class DashSdkError( cause, ) + /** + * A state transition could not be signed because the signer has no + * usable private key for the requested public key — the stored blob + * is missing (never derived, wiped, or written under a different + * Keystore alias/policy) rather than the operation itself failing. + * + * Signing failures originate as free text in + * `KeystoreSigner.completeSign` (the "[MESSAGE_MARKER] " + * string), travel through Rust, and come back under the catch-all + * platform-wallet codes; [fromPlatformWalletNative] recognizes the + * marker on the Kotlin boundary and surfaces this typed error so + * hosts can route users to key repair (e.g. + * `PlatformWalletManager.repairIdentityKey`) instead of treating it + * as an opaque [Generic] failure. Not retryable as-is — the key must + * be (re-)derived first. + */ + class SigningKeyUnavailable(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) { + companion object { + /** + * Stable prefix of the `KeystoreSigner` "missing key" + * completion error. `KeystoreSigner` builds its message from + * this constant, so the emitter and the matcher cannot + * drift. + */ + const val MESSAGE_MARKER = "no private key stored for" + } + } + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -186,12 +215,24 @@ sealed class DashSdkError( } } + /** + * `PlatformWalletFFIResultCode::NotFound` (98) — the code the FFI's + * blanket `Option → result` conversion emits for every "requested + * not found" miss (e.g. an identity id that is not managed + * by the wallet). + */ + const val PLATFORM_WALLET_NOT_FOUND_CODE = 98 + /** * Map a de-offset `PlatformWalletFFIResultCode` value into the * [PlatformWallet] subtree — mirror of Swift's * `PlatformWalletError(result:)` construction. Retry-semantics-bearing * codes get dedicated types; the rest fall through to - * [PlatformWallet.Generic]. + * [PlatformWallet.Generic] — except the `KeystoreSigner` "missing + * key" completion error, which travels as free text through Rust and + * is recognized by its [PlatformWallet.SigningKeyUnavailable] + * message marker here (only on the catch-all codes, so the dedicated + * retry-semantics types are never overridden). */ private fun fromPlatformWalletNative( code: Int, @@ -200,13 +241,26 @@ sealed class DashSdkError( ): DashSdkError = when (code) { // PlatformWalletFFIResultCode variants (platform-wallet-ffi/src/error.rs) 1 -> PlatformWallet.InvalidHandle(message, cause) // ErrorInvalidHandle - 6 -> PlatformWallet.WalletOperation(message, cause) // ErrorWalletOperation + 6 -> // ErrorWalletOperation + if (isSigningKeyUnavailable(message)) { + PlatformWallet.SigningKeyUnavailable(message, cause) + } else { + PlatformWallet.WalletOperation(message, cause) + } 16 -> PlatformWallet.ShieldedBroadcastFailed(message, cause) // ErrorShieldedBroadcastFailed 18 -> PlatformWallet.ShieldedSpendUnconfirmed(message, cause) // ErrorShieldedSpendUnconfirmed 19 -> PlatformWallet.ShieldedNoRecordedAnchor(message, cause) // ErrorShieldedNoRecordedAnchor 20 -> PlatformWallet.TransactionBroadcastUnconfirmed(message, cause) // ErrorTransactionBroadcastUnconfirmed - else -> PlatformWallet.Generic(code, message, cause) + else -> + if (isSigningKeyUnavailable(message)) { + PlatformWallet.SigningKeyUnavailable(message, cause) + } else { + PlatformWallet.Generic(code, message, cause) + } } + + private fun isSigningKeyUnavailable(message: String): Boolean = + message.contains(PlatformWallet.SigningKeyUnavailable.MESSAGE_MARKER) } } 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 7fef99c9ee..65923bab29 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 @@ -8,6 +8,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.util.concurrent.ConcurrentHashMap import org.dashfoundation.dashsdk.Network +import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.ffi.NativeSignerBridge import org.dashfoundation.dashsdk.ffi.SignerNative import org.dashfoundation.dashsdk.persistence.dao.PlatformAddressDao @@ -100,10 +101,15 @@ class KeystoreSigner( val storageKey = storageKeyFor(pubkeyBytes) key = retrieveKeyWithAuth(storageKey) if (key == null) { + // Built from the shared marker so the error survives the + // Rust round-trip and comes back typed as + // DashSdkError.PlatformWallet.SigningKeyUnavailable (the + // fromPlatformWalletNative message match) instead of Generic. SignerNative.completeSign( completionToken, null, - "no private key stored for ${storageKey.take(16)}…", + "${DashSdkError.PlatformWallet.SigningKeyUnavailable.MESSAGE_MARKER} " + + "${storageKey.take(16)}…", ) return } @@ -201,7 +207,9 @@ class KeystoreSigner( /** * Decrypt the key; on an expired auth window, run the biometric gate - * once and retry — mirroring KeychainSigner's LAContext flow. + * once and retry — mirroring KeychainSigner's LAContext flow. Under + * [KeySecurityPolicy.DEVICE_BOUND] storage the decrypt never + * auth-gates, so the gate is never consulted. */ private suspend fun retrieveKeyWithAuth(storageKey: String): ByteArray? = try { diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index 8ae498a8f0..419fc053f3 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -96,6 +96,63 @@ class DashSdkErrorTest { assertFalse("Generic platform-wallet errors are not retryable", mapped.isRetryable) } + @Test + fun signingKeyUnavailableIsRecognizedByItsMessageMarker() { + val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET + val marker = DashSdkError.PlatformWallet.SigningKeyUnavailable.MESSAGE_MARKER + // The KeystoreSigner completion error travels as free text through + // Rust and returns under the catch-all codes (ErrorUnknown = 99 via + // the blanket PlatformWalletError conversion, sometimes wrapped as + // ErrorWalletOperation = 6) — both must surface typed (#4052). + for (code in intArrayOf(6, 99)) { + val mapped = DashSdkError.fromNative( + DashSDKException(offset + code, "Signing failed: $marker deadbeef00112233…"), + ) + assertTrue( + "code $code with marker → SigningKeyUnavailable", + mapped is DashSdkError.PlatformWallet.SigningKeyUnavailable, + ) + assertFalse(mapped.isRetryable) + } + // Without the marker the catch-all mappings are untouched. + val walletOp = DashSdkError.fromNative(DashSDKException(offset + 6, "op failed")) + assertTrue(walletOp is DashSdkError.PlatformWallet.WalletOperation) + val generic = DashSdkError.fromNative(DashSDKException(offset + 99, "boom")) + assertTrue(generic is DashSdkError.PlatformWallet.Generic) + } + + @Test + fun signingKeyMarkerNeverOverridesRetrySemanticsCodes() { + val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET + val marker = DashSdkError.PlatformWallet.SigningKeyUnavailable.MESSAGE_MARKER + // A dedicated retry-semantics code keeps its type even if the Rust + // message happens to embed the marker text. + val mapped = DashSdkError.fromNative( + DashSDKException(offset + 19, "anchor missing; $marker something"), + ) + assertTrue(mapped is DashSdkError.PlatformWallet.ShieldedNoRecordedAnchor) + } + + @Test + fun platformWalletNotFoundCodeMapsToGeneric() { + // PlatformWalletFFIResultCode::NotFound (98) — the code the Option → + // result conversion emits for "requested not found". Stays + // Generic in the hierarchy; Dashpay's managed-identity reads + // translate it to null before it ever escapes (#4051). + val mapped = DashSdkError.fromNative( + DashSDKException( + DashSdkError.PLATFORM_WALLET_CODE_OFFSET + + DashSdkError.PLATFORM_WALLET_NOT_FOUND_CODE, + "requested platform_wallet::identity::ManagedIdentity not found", + ), + ) + assertTrue(mapped is DashSdkError.PlatformWallet.Generic) + assertEquals( + DashSdkError.PLATFORM_WALLET_NOT_FOUND_CODE, + (mapped as DashSdkError.PlatformWallet.Generic).nativeCode, + ) + } + @Test fun mapNativeErrorsConvertsAtTheBoundary() { try { From 16f54d1e5fe1eb73c34d99348fa90a2f5fadb7cf Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:25:34 -0400 Subject: [PATCH 3/7] fix(kotlin-sdk): Dashpay reads return null/empty for unmanaged identities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #4051. Dashpay.syncState (and payments/contacts/acceptIncomingRequest) threw DashSdkError.PlatformWallet.Generic("requested …ManagedIdentity not found") when called for an identity the wallet does not manage — e.g. a contact's identity — even though every signature already models the absence (nullable String, empty Contacts, false). The native platform_wallet_get_managed_identity reports the miss through the FFI's blanket Option → result conversion (PlatformWalletFFIResultCode::NotFound, 98) instead of returning a zero handle, so the fix translates that specific error to a zero handle on the Kotlin boundary (translateManagedIdentityNotFoundToZero) and routes all managed-identity snapshots through it: syncState/payments now return null, contacts returns empty lists, acceptIncomingRequest returns false. Any other native error is rethrown untouched. Co-Authored-By: Claude Fable 5 --- .../dashfoundation/dashsdk/tokens/Dashpay.kt | 45 ++++++++++++++-- .../ManagedIdentityNotFoundTranslationTest.kt | 53 +++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/tokens/ManagedIdentityNotFoundTranslationTest.kt diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index 5bf3b9c2e2..1a130e8bfa 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -3,7 +3,9 @@ package org.dashfoundation.dashsdk.tokens import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.errors.mapNativeErrors +import org.dashfoundation.dashsdk.ffi.DashSDKException import org.dashfoundation.dashsdk.ffi.DashpayNative import org.dashfoundation.dashsdk.ffi.NativeCleaner import org.dashfoundation.dashsdk.ffi.TokensNative @@ -194,7 +196,7 @@ class Dashpay internal constructor(private val walletHandle: Long) { */ suspend fun contacts(identityId: ByteArray): Contacts = withContext(Dispatchers.IO) { mapNativeErrors { - val identityHandle = TokensNative.getManagedIdentity(walletHandle, identityId) + val identityHandle = managedIdentityHandleOrZero(identityId) if (identityHandle == 0L) { return@mapNativeErrors Contacts(emptyList(), emptyList(), emptyList()) } @@ -232,7 +234,7 @@ class Dashpay internal constructor(private val walletHandle: Long) { coreSignerHandle: Long, ): Boolean = withContext(Dispatchers.IO) { mapNativeErrors { - val identityHandle = TokensNative.getManagedIdentity(walletHandle, ourIdentityId) + val identityHandle = managedIdentityHandleOrZero(ourIdentityId) if (identityHandle == 0L) return@mapNativeErrors false try { val requestHandle = @@ -425,11 +427,23 @@ class Dashpay internal constructor(private val walletHandle: Long) { * [acceptIncomingRequest] discipline). Returns null when the * identity isn't managed by this wallet. */ + /** + * Snapshot the managed identity for [identityId], or 0 when the wallet + * does not manage it. The native side reports an unmanaged identity as + * a platform-wallet NotFound error rather than a zero handle, so the + * "not managed" outcome is translated here — every local-read caller + * treats it as an absence (null / empty / false), never an exception. + */ + private fun managedIdentityHandleOrZero(identityId: ByteArray): Long = + translateManagedIdentityNotFoundToZero { + TokensNative.getManagedIdentity(walletHandle, identityId) + } + private inline fun withManagedIdentity( identityId: ByteArray, block: (Long) -> T, ): T? { - val handle = TokensNative.getManagedIdentity(walletHandle, identityId) + val handle = managedIdentityHandleOrZero(identityId) if (handle == 0L) return null return try { block(handle) @@ -526,3 +540,28 @@ class EstablishedContactRef internal constructor(handle: Long) : AutoCloseable { } } } + +// ── Free functions (unit-testable, no `this`) ───────────────────────── + +/** + * Run [getHandle] (a `TokensNative.getManagedIdentity` call), translating + * the platform-wallet NotFound error the native layer raises for an + * identity the wallet does not manage into a zero handle — the same "not + * managed" signal the callers already handle by returning null / empty. + * + * The FFI's blanket `Option → result` conversion reports the miss as + * `PlatformWalletFFIResultCode::NotFound` (98, offset into the + * `DashSDKException` code by [DashSdkError.PLATFORM_WALLET_CODE_OFFSET]), + * so without this every local read over an unmanaged identity — e.g. + * [Dashpay.syncState] on a contact's identity — would throw + * `DashSdkError.PlatformWallet.Generic("…ManagedIdentity not found")` + * instead of returning null. Any other error is rethrown untouched. + */ +internal inline fun translateManagedIdentityNotFoundToZero(getHandle: () -> Long): Long = + try { + getHandle() + } catch (e: DashSDKException) { + val notFound = DashSdkError.PLATFORM_WALLET_CODE_OFFSET + + DashSdkError.PLATFORM_WALLET_NOT_FOUND_CODE + if (e.code == notFound) 0L else throw e + } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/tokens/ManagedIdentityNotFoundTranslationTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/tokens/ManagedIdentityNotFoundTranslationTest.kt new file mode 100644 index 0000000000..f97706bd19 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/tokens/ManagedIdentityNotFoundTranslationTest.kt @@ -0,0 +1,53 @@ +package org.dashfoundation.dashsdk.tokens + +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.dashfoundation.dashsdk.ffi.DashSDKException +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test + +/** + * Pins the dashpay/platform#4051 fix: `TokensNative.getManagedIdentity` + * reports an identity the wallet does not manage as a platform-wallet + * NotFound error (code 98, via the FFI's blanket `Option → result` + * conversion), which [translateManagedIdentityNotFoundToZero] turns into a + * zero handle so [Dashpay.syncState] / [Dashpay.payments] / + * [Dashpay.contacts] return null / empty instead of throwing + * `DashSdkError.PlatformWallet.Generic("…ManagedIdentity not found")`. + */ +class ManagedIdentityNotFoundTranslationTest { + + private val notFoundCode = + DashSdkError.PLATFORM_WALLET_CODE_OFFSET + DashSdkError.PLATFORM_WALLET_NOT_FOUND_CODE + + @Test + fun notFoundBecomesAZeroHandle() { + val handle = translateManagedIdentityNotFoundToZero { + throw DashSDKException( + notFoundCode, + "requested platform_wallet::identity::ManagedIdentity not found", + ) + } + assertEquals(0L, handle) + } + + @Test + fun aRealHandlePassesThrough() { + assertEquals(42L, translateManagedIdentityNotFoundToZero { 42L }) + } + + @Test + fun otherNativeErrorsAreRethrownUntouched() { + val other = DashSDKException( + DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 1, // ErrorInvalidHandle + "stale wallet handle", + ) + try { + translateManagedIdentityNotFoundToZero { throw other } + fail("expected the non-NotFound error to be rethrown") + } catch (e: DashSDKException) { + assertEquals(other.code, e.code) + assertEquals(other.message, e.message) + } + } +} From d3aa78663038181447578b83ada374542414bf8d Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:01:50 -0400 Subject: [PATCH 4/7] fix(kotlin-sdk): stop upgrade deleting the legacy identity-key AES key; clear pending on repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two blocking correctness fixes from review. 1) KeystoreManager: the default AUTH_GATED policy generated the RSA wrapping keypair at KEYS_ALIAS — the same alias the pre-RSA scheme used for an auth-gated AES-256-GCM key — and ensureKeysKeyPair deleted that AES key before creating the RSA pair, stranding existing installs' AES-GCM identity- key blobs (data loss). Give AUTH_GATED its own RSA alias (KEYS_ALIAS_AUTH_GATED) and keep KEYS_ALIAS as a read-only legacy alias that is never deleted or regenerated. WalletStorage.retrievePrivateKey now detects a legacy AES-GCM blob, decrypts it with the retained legacy key, and transparently re-encrypts (migrates) it under the RSA alias; a fresh install has no legacy blobs and always takes the RSA path. isPrivateKeyDecryptable reports a legacy blob as recoverable while its key survives, so key-health no longer offers a needless re-derive for migratable keys. 2) PlatformWalletManager.repairIdentityKey wrote the re-derived key straight through the deriver, bypassing onPersistIdentityKeyUpsert (the only path that clears pendingIdentityKeys), so a repaired key lingered as pending. On a successful repair it now clears the entry via a new internal PlatformWalletPersistenceHandler.markIdentityKeyRepaired hook. Tests: alias split + blob-type discrimination (RSA vs legacy AES) in KeySecurityPolicyTest; markIdentityKeyRepaired clears the pending entry in PlatformWalletPersistenceHandlerTest. (The Keystore crypto round trip itself can't run on the JVM — AndroidKeyStore has no Robolectric provider — so the alias/blob decision logic is what's unit-pinned.) Co-Authored-By: Claude Fable 5 --- .../PlatformWalletPersistenceHandler.kt | 16 +++ .../dashsdk/security/KeySecurityPolicy.kt | 12 +- .../dashsdk/security/KeystoreManager.kt | 123 ++++++++++++++---- .../dashsdk/security/WalletStorage.kt | 43 ++++-- .../dashsdk/wallet/PlatformWalletManager.kt | 12 +- .../PlatformWalletPersistenceHandlerTest.kt | 22 ++++ .../dashsdk/security/KeySecurityPolicyTest.kt | 49 ++++++- 7 files changed, 231 insertions(+), 46 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index f5ea851496..5a6dddb831 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -2142,6 +2142,22 @@ class PlatformWalletPersistenceHandler( } } + /** + * Drop [publicKeyHex] from [pendingIdentityKeys] after a successful + * out-of-band repair. + * + * [onPersistIdentityKeyUpsert] is the only *persist-callback* path that + * clears a pending entry, but [org.dashfoundation.dashsdk.wallet.PlatformWalletManager.repairIdentityKey] + * re-derives and stores the private key directly through the deriver, + * bypassing that callback — so it must call this on success or a repaired + * key would linger in [pendingIdentityKeys] until an unrelated re-persist + * happens to fire for the same key. Idempotent: clearing an absent key is a + * no-op. + */ + internal fun markIdentityKeyRepaired(publicKeyHex: String) { + clearPendingIdentityKey(publicKeyHex) + } + // ── Error / threading guards ────────────────────────────────────── /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt index 301f530881..b4c3c7edd9 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt @@ -16,11 +16,13 @@ package org.dashfoundation.dashsdk.security * * ## Semantics * - * - [AUTH_GATED] — the default; behavior is exactly the historical one. - * Identity keys are wrapped under [KeystoreManager.KEYS_ALIAS]: encrypt - * (store) never prompts, decrypt (sign) requires user authentication - * within [KeystoreManager.AUTH_VALIDITY_SECONDS] of a biometric / - * device-credential auth, re-promptable through a [BiometricGate]. + * - [AUTH_GATED] — the default; behavior matches the historical one. New + * identity keys are wrapped under [KeystoreManager.KEYS_ALIAS_AUTH_GATED] + * (the legacy [KeystoreManager.KEYS_ALIAS] is kept read-only so pre-RSA + * blobs migrate rather than strand): encrypt (store) never prompts, decrypt + * (sign) requires user authentication within + * [KeystoreManager.AUTH_VALIDITY_SECONDS] of a biometric / device-credential + * auth, re-promptable through a [BiometricGate]. * - [DEVICE_BOUND] — identity keys are wrapped under the separate * [KeystoreManager.KEYS_ALIAS_DEVICE_BOUND] alias: the same * StrongBox-preferring, non-exportable RSA wrapping pair, but with **no** 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 d4c411701b..294849b475 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 @@ -29,9 +29,10 @@ 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`). - * - [KEYS_ALIAS] `org.dashfoundation.wallet.keys` — identity private keys, - * under an RSA-2048 OAEP(SHA-256) keypair. The PUBLIC key encrypts and is - * never auth-gated, so **storing** a key never prompts (parity with iOS, + * - [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 + * is never auth-gated, so **storing** a key never prompts (parity with iOS, * which stores identity keys with no access control) — crucially the * persistence callback stores from a Rust Tokio thread holding the * wallet-manager write lock, where a prompt is impossible. The PRIVATE key @@ -45,6 +46,11 @@ import javax.crypto.spec.PSource * alias: the same RSA-2048 OAEP wrapping pair, but withOUT the * user-authentication gate on the private key, for host apps that gate * signing behind their own auth model (see [KeySecurityPolicy]). + * - [KEYS_ALIAS] `org.dashfoundation.wallet.keys` — the **legacy** pre-RSA + * alias that wrapped identity keys under an auth-gated AES-256-GCM key. + * Retained read-only so existing installs' AES-GCM blobs stay recoverable: + * [WalletStorage.retrievePrivateKey] decrypts a legacy blob with it and + * migrates the value to [KEYS_ALIAS_AUTH_GATED]. Never written by new keys. * * Which identity-keys alias a manager instance writes/reads is selected by * the [keySecurityPolicy] constructor parameter (default: the historical @@ -62,22 +68,23 @@ class KeystoreManager( /** * The identity-keys alias this manager targets, per - * [keySecurityPolicy]: [KEYS_ALIAS] (auth-gated decrypt) or + * [keySecurityPolicy]: [KEYS_ALIAS_AUTH_GATED] (auth-gated decrypt) or * [KEYS_ALIAS_DEVICE_BOUND] (non-gated decrypt). [WalletStorage] passes * this to [encrypt]/[decrypt] for identity-key material. */ val keysAlias: String get() = when (keySecurityPolicy) { - KeySecurityPolicy.AUTH_GATED -> KEYS_ALIAS + KeySecurityPolicy.AUTH_GATED -> KEYS_ALIAS_AUTH_GATED KeySecurityPolicy.DEVICE_BOUND -> KEYS_ALIAS_DEVICE_BOUND } /** * Encrypt [plaintext] under [alias]; returns (iv, ciphertext). - * [MASTER_ALIAS] uses AES-256-GCM (iv is the GCM nonce). The - * identity-keys aliases ([KEYS_ALIAS] / [KEYS_ALIAS_DEVICE_BOUND]) + * [MASTER_ALIAS] uses AES-256-GCM (iv is the GCM nonce). The RSA + * identity-keys aliases ([KEYS_ALIAS_AUTH_GATED] / [KEYS_ALIAS_DEVICE_BOUND]) * use the RSA public key (no iv — the blob's iv is empty) and never - * require authentication, so identity-key writes never prompt. + * require authentication, so identity-key writes never prompt. (The legacy + * [KEYS_ALIAS] is never an encrypt target — new keys use the RSA aliases.) */ fun encrypt(plaintext: ByteArray, alias: String = MASTER_ALIAS): EncryptedBlob { if (isIdentityKeysAlias(alias)) { @@ -92,11 +99,14 @@ class KeystoreManager( /** * Decrypt a blob produced by [encrypt] under the same [alias]. The - * [KEYS_ALIAS] RSA private-key decrypt throws + * [KEYS_ALIAS_AUTH_GATED] RSA private-key decrypt throws * `UserNotAuthenticatedException` when the [AUTH_VALIDITY_SECONDS] auth * window is closed — the caller (`KeystoreSigner`) prompts via the * `BiometricGate` and retries. The [KEYS_ALIAS_DEVICE_BOUND] decrypt is - * never auth-gated (see [KeySecurityPolicy.DEVICE_BOUND]). + * never auth-gated (see [KeySecurityPolicy.DEVICE_BOUND]). Legacy pre-RSA + * AES-GCM blobs are NOT handled here — [WalletStorage.retrievePrivateKey] + * detects them ([isLegacyKeysBlob]) and routes them through + * [decryptLegacyKeysBlob]. */ fun decrypt(blob: EncryptedBlob, alias: String = MASTER_ALIAS): ByteArray { if (isIdentityKeysAlias(alias)) { @@ -114,16 +124,50 @@ class KeystoreManager( } /** - * Whether [blob] is structurally a [KEYS_ALIAS] RSA blob the current + * Whether [blob] is structurally an RSA identity-keys blob the current * scheme can decrypt: no iv (RSA blobs never carry one) and exactly one - * RSA block of ciphertext. Blobs written by the pre-RSA AES-GCM scheme - * carry a GCM nonce in `iv` and became undecryptable when the AES key - * was dropped for the RSA pair — they need a re-derive. Never decrypts, - * so it never prompts for authentication. + * RSA block of ciphertext. Legacy pre-RSA AES-GCM blobs carry a GCM nonce + * in `iv` (see [isLegacyKeysBlob]) and are handled by the migration + * fallback instead. Never decrypts, so it never prompts for authentication. */ fun isKeysBlobDecryptable(blob: EncryptedBlob): Boolean = blob.iv.isEmpty() && blob.ciphertext.size == RSA_KEY_SIZE / 8 + /** + * Whether [blob] is a legacy pre-RSA identity-key blob — one written by the + * old scheme that wrapped identity keys under the auth-gated AES-256-GCM key + * at [KEYS_ALIAS]. Such blobs carry a GCM nonce in `iv`; the RSA scheme + * never does. Structural check only — never decrypts, never prompts. + * Decrypt these via [decryptLegacyKeysBlob]. + */ + fun isLegacyKeysBlob(blob: EncryptedBlob): Boolean = blob.iv.isNotEmpty() + + /** + * Whether the legacy AES key at [KEYS_ALIAS] still exists, so a legacy + * identity-key blob is recoverable via [decryptLegacyKeysBlob] (and can be + * migrated to the RSA scheme). A Keystore presence check only — no decrypt, + * no prompt. False once an older build already deleted the key, in which + * case any surviving legacy blob is unrecoverable and needs a re-derive. + */ + fun hasLegacyKeysKey(): Boolean = + (androidKeyStore().getKey(KEYS_ALIAS, null) as? SecretKey) != null + + /** + * Decrypt a legacy pre-RSA identity-key [blob] with the retained AES-GCM key + * at [KEYS_ALIAS], or return `null` if that key is gone (the blob is then + * unrecoverable). Deliberately fetches the existing key WITHOUT generating a + * fresh one — a new key could never open an old blob. The legacy key was + * auth-gated, so this throws `UserNotAuthenticatedException` when the auth + * window is closed, exactly as the RSA auth-gated decrypt does; the caller + * ([KeystoreSigner]) prompts and retries. + */ + fun decryptLegacyKeysBlob(blob: EncryptedBlob): ByteArray? { + val legacyKey = androidKeyStore().getKey(KEYS_ALIAS, null) as? SecretKey ?: return null + val cipher = Cipher.getInstance(AES_TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, legacyKey, GCMParameterSpec(GCM_TAG_BITS, blob.iv)) + return cipher.doFinal(blob.ciphertext) + } + /** IV + ciphertext pair, serialized as `iv.size || iv || ciphertext`. */ data class EncryptedBlob(val iv: ByteArray, val ciphertext: ByteArray) { fun encode(): ByteArray = @@ -185,7 +229,7 @@ class KeystoreManager( // ── Identity-keys aliases: RSA-2048 OAEP keypair per alias ── // Public key encrypts (never auth-gated → unprompted writes); the - // KEYS_ALIAS private key decrypts under user auth within + // KEYS_ALIAS_AUTH_GATED private key decrypts under user auth within // AUTH_VALIDITY_SECONDS (signing prompts), while the // KEYS_ALIAS_DEVICE_BOUND private key decrypts without a gate // (KeySecurityPolicy.DEVICE_BOUND). @@ -199,7 +243,8 @@ class KeystoreManager( /** * Return the RSA identity-keys keypair for [alias], creating it on * first use. The user-authentication gate is applied only to - * [KEYS_ALIAS]; [KEYS_ALIAS_DEVICE_BOUND] is generated without one. + * [KEYS_ALIAS_AUTH_GATED]; [KEYS_ALIAS_DEVICE_BOUND] is generated without + * one. * * Serialized on a process-wide lock (the AndroidKeyStore alias is * process-global, and this manager is instantiated per [WalletStorage]) @@ -212,7 +257,7 @@ class KeystoreManager( * private key undecryptable by the surviving alias. */ private fun ensureKeysKeyPair(alias: String): KeyPair = synchronized(KEYS_ALIAS_LOCK) { - val authGated = alias == KEYS_ALIAS + val authGated = alias == KEYS_ALIAS_AUTH_GATED val keyStore = androidKeyStore() val existingPrivate = keyStore.getKey(alias, null) as? PrivateKey val existingCert = keyStore.getCertificate(alias) @@ -221,8 +266,11 @@ class KeystoreManager( // thread that raced us) — reuse it, never delete it. return@synchronized KeyPair(existingCert.publicKey, existingPrivate) } - // Absent, or a stale symmetric entry from an earlier build (which - // gated encrypt too and broke unprompted writes) — drop and recreate. + // Absent, or a partial/wrong-type entry at THIS RSA alias — drop and + // recreate. Note [alias] is one of the RSA aliases + // ([KEYS_ALIAS_AUTH_GATED] / [KEYS_ALIAS_DEVICE_BOUND]), never the + // legacy [KEYS_ALIAS], so the pre-RSA AES key that still wraps existing + // installs' identity-key blobs is never deleted here. runCatching { keyStore.deleteEntry(alias) } fun spec(strongBox: Boolean): KeyGenParameterSpec { @@ -284,22 +332,47 @@ class KeystoreManager( companion object { const val MASTER_ALIAS = "org.dashfoundation.wallet.master" + + /** + * **Legacy** identity-keys alias from the pre-RSA scheme, where + * identity private keys were wrapped under an **auth-gated AES-256-GCM** + * key here. Retained (never deleted, never regenerated) purely so + * existing installs' AES-GCM blobs stay decryptable across the upgrade + * to the RSA scheme: [WalletStorage.retrievePrivateKey] falls back to + * this key for a legacy blob and migrates it to [KEYS_ALIAS_AUTH_GATED]. + * New identity keys are NEVER written here — see [keysAlias]. + */ const val KEYS_ALIAS = "org.dashfoundation.wallet.keys" + /** + * Auth-gated identity-keys alias: the RSA-2048 OAEP wrapping pair for + * [KeySecurityPolicy.AUTH_GATED]. A **new** alias distinct from the + * legacy [KEYS_ALIAS] so upgrading installs keep their old AES key (and + * thus their old blobs) intact instead of having it deleted out from + * under them. Fixed Keystore auth parameters also mean it can never + * share an alias with [KEYS_ALIAS_DEVICE_BOUND]. + */ + const val KEYS_ALIAS_AUTH_GATED = "org.dashfoundation.wallet.keys.authgated" + /** * Non-auth-gated identity-keys alias, selected by - * [KeySecurityPolicy.DEVICE_BOUND]. Distinct from [KEYS_ALIAS] - * because Keystore auth parameters are fixed at generation — the two - * policies can never share one alias. + * [KeySecurityPolicy.DEVICE_BOUND]. Distinct from the other identity + * aliases because Keystore auth parameters are fixed at generation — the + * two policies can never share one alias. */ const val KEYS_ALIAS_DEVICE_BOUND = "org.dashfoundation.wallet.keys.devicebound" /** Auth window for the auth-gated identity-keys alias, in seconds. */ const val AUTH_VALIDITY_SECONDS = 30 - /** Whether [alias] is one of the RSA-wrapped identity-keys aliases. */ + /** + * Whether [alias] is one of the RSA-wrapped identity-keys aliases new + * keys are written under. The legacy [KEYS_ALIAS] (AES-GCM) is + * deliberately excluded — it is read-only, reached only via the + * migration fallback, never through the RSA encrypt/decrypt path. + */ fun isIdentityKeysAlias(alias: String): Boolean = - alias == KEYS_ALIAS || alias == KEYS_ALIAS_DEVICE_BOUND + alias == KEYS_ALIAS_AUTH_GATED || alias == KEYS_ALIAS_DEVICE_BOUND // Guards first-use creation/migration of the process-global // identity-keys entries across concurrent callers (and across the 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 9701a5b13f..769efd8ce6 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 @@ -129,10 +129,34 @@ class WalletStorage( * caller (KeystoreSigner) routes through [BiometricGate] and retries; * under [KeySecurityPolicy.DEVICE_BOUND] it never auth-gates. * Callers must zero the returned array after use. + * + * Upgrade path: a blob written by the pre-RSA scheme (AES-GCM under the + * legacy [KeystoreManager.KEYS_ALIAS], never deleted) is decrypted with + * that retained key and then transparently re-encrypted under the current + * RSA alias and rewritten, so it is recovered — not stranded — and future + * reads use the new scheme. A fresh install has no legacy blobs and always + * takes the RSA path. */ suspend fun retrievePrivateKey(pubkeyHex: String): ByteArray? { val encoded = store.data.first()[privateKeyKey(pubkeyHex)] ?: return null - return keystore.decrypt(decode(encoded), alias = keystore.keysAlias) + val blob = decode(encoded) + if (keystore.isLegacyKeysBlob(blob)) { + // Legacy AES-GCM blob: recover with the retained legacy key (may + // throw UserNotAuthenticatedException — the legacy key was + // auth-gated — which the signer handles exactly as the RSA path), + // or null if that key is already gone (unrecoverable). + val plain = keystore.decryptLegacyKeysBlob(blob) ?: return null + // Opportunistically migrate to the RSA scheme. A rewrite failure + // must not lose the value we just recovered, so it stays best-effort + // and the read still returns the plaintext (migration retries next + // read). + runCatching { + val migrated = keystore.encrypt(plain, alias = keystore.keysAlias) + store.edit { it[privateKeyKey(pubkeyHex)] = encode(migrated) } + } + return plain + } + return keystore.decrypt(blob, alias = keystore.keysAlias) } suspend fun deletePrivateKey(pubkeyHex: String) { @@ -143,16 +167,19 @@ class WalletStorage( store.data.first().contains(privateKeyKey(pubkeyHex)) /** - * Whether the blob stored for [pubkeyHex] is decryptable under the - * current [KeystoreManager.keysAlias] RSA scheme. Blobs written by the - * pre-RSA AES-GCM scheme survive in the DataStore but lost their key - * when the RSA pair replaced it, so signing with them can only fail — - * key-health treats them as missing and offers a re-derive. Structural - * check only: never decrypts, never prompts. + * Whether the blob stored for [pubkeyHex] can be recovered: either it is a + * current-scheme RSA blob, or it is a legacy pre-RSA AES-GCM blob whose + * retained legacy key still exists (so [retrievePrivateKey] can decrypt and + * migrate it). Only a legacy blob whose key was already deleted by an older + * build is unrecoverable — key-health treats that as missing and offers a + * re-derive. Structural + Keystore-presence check only: never decrypts, + * never prompts. */ suspend fun isPrivateKeyDecryptable(pubkeyHex: String): Boolean { val encoded = store.data.first()[privateKeyKey(pubkeyHex)] ?: return false - return keystore.isKeysBlobDecryptable(decode(encoded)) + val blob = decode(encoded) + return keystore.isKeysBlobDecryptable(blob) || + (keystore.isLegacyKeysBlob(blob) && keystore.hasLegacyKeysKey()) } /** All entry names (masked listing for the Keystore Explorer screen). */ 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 01f8311083..a152aa3a9b 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 @@ -272,6 +272,12 @@ class PlatformWalletManager( * "one allowed exception"); Kotlin only encrypts the returned scalar. * Returns the recorded storage identifier (e.g. `privkey.`), * or throws on a derivation / storage failure. + * + * On success the key is dropped from [pendingIdentityKeys] via the + * persistence handler: the repair stores the private key directly through + * the deriver, bypassing `onPersistIdentityKeyUpsert` (the only persist + * path that clears pending), so it must clear the entry itself or the + * repaired key would keep showing as pending. */ fun repairIdentityKey( walletId: ByteArray, @@ -281,12 +287,16 @@ class PlatformWalletManager( ): String? { require(identityIndex >= 0) { "identityIndex must be non-negative, got $identityIndex" } require(keyIndex >= 0) { "keyIndex must be non-negative, got $keyIndex" } - return identityKeyDeriver.deriveAndStore( + val storageIdentifier = identityKeyDeriver.deriveAndStore( walletId = walletId, publicKeyData = publicKeyData, identityIndex = identityIndex, keyIndex = keyIndex, ) + if (storageIdentifier != null) { + persistenceHandler.markIdentityKeyRepaired(publicKeyData.toHex()) + } + return storageIdentifier } /** diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index c4a9ec389b..a693872303 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -563,6 +563,28 @@ class PlatformWalletPersistenceHandlerTest { assertEquals("privkey.cafebabe", row!!.privateKeyKeychainIdentifier) } + @Test + fun markIdentityKeyRepairedClearsThePendingEntry() = runTest { + // A derive failure records the key as pending… + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + val identityId = ByteArray(32) { 18 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 13 } + upsertIdentityKey(pubkey, identityId) + assertNotNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + + // …and a successful out-of-band repair (PlatformWalletManager.repairIdentityKey + // stores directly through the deriver, never re-firing onPersistIdentityKeyUpsert) + // clears it via this hook. + handler.markIdentityKeyRepaired(pubkey.toHex()) + assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + + // Idempotent: a second clear (or one for an unknown key) is a no-op. + handler.markIdentityKeyRepaired(pubkey.toHex()) + handler.markIdentityKeyRepaired(ByteArray(33) { 99 }.toHex()) + assertTrue(handler.pendingIdentityKeys.value.isEmpty()) + } + // ── Shielded load round-trip ────────────────────────────────────── @Test diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyTest.kt index 4bf8cb320d..0b47aec739 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyTest.kt @@ -28,7 +28,11 @@ class KeySecurityPolicyTest { fun keystoreManagerDefaultsToAuthGated() { val manager = KeystoreManager() assertEquals(KeySecurityPolicy.AUTH_GATED, manager.keySecurityPolicy) - assertEquals(KeystoreManager.KEYS_ALIAS, manager.keysAlias) + // The AUTH_GATED RSA keypair lives at its own alias — NOT the legacy + // KEYS_ALIAS, whose AES key must survive the upgrade so old blobs stay + // recoverable. + assertEquals(KeystoreManager.KEYS_ALIAS_AUTH_GATED, manager.keysAlias) + assertFalse(manager.keysAlias == KeystoreManager.KEYS_ALIAS) } @Test @@ -40,20 +44,51 @@ class KeySecurityPolicyTest { @Test fun policiesResolveToDistinctAliases() { - // Keystore auth parameters are immutable post-generation — sharing - // one alias across policies would silently keep the first policy. - assertFalse( - KeystoreManager.KEYS_ALIAS == KeystoreManager.KEYS_ALIAS_DEVICE_BOUND, + // Keystore auth parameters are immutable post-generation — sharing one + // alias across policies would silently keep the first policy. All three + // identity aliases (legacy AES + the two RSA) must also stay distinct so + // the legacy key is never regenerated or deleted. + val aliases = setOf( + KeystoreManager.KEYS_ALIAS, + KeystoreManager.KEYS_ALIAS_AUTH_GATED, + KeystoreManager.KEYS_ALIAS_DEVICE_BOUND, ) + assertEquals(3, aliases.size) } @Test - fun bothIdentityKeyAliasesAreRecognized() { - assertTrue(KeystoreManager.isIdentityKeysAlias(KeystoreManager.KEYS_ALIAS)) + fun onlyTheRsaIdentityAliasesAreRecognized() { + assertTrue(KeystoreManager.isIdentityKeysAlias(KeystoreManager.KEYS_ALIAS_AUTH_GATED)) assertTrue(KeystoreManager.isIdentityKeysAlias(KeystoreManager.KEYS_ALIAS_DEVICE_BOUND)) + // The legacy alias is read-only (migration fallback), never an RSA + // encrypt/decrypt target. + assertFalse(KeystoreManager.isIdentityKeysAlias(KeystoreManager.KEYS_ALIAS)) assertFalse(KeystoreManager.isIdentityKeysAlias(KeystoreManager.MASTER_ALIAS)) } + @Test + fun blobTypeDiscriminationSeparatesRsaFromLegacy() { + // Construction is inert (AndroidKeyStore access is lazy); the blob-type + // checks are pure structural predicates. + val km = KeystoreManager() + + // An RSA blob carries no iv and exactly one 2048-bit block. + val rsaBlob = KeystoreManager.EncryptedBlob( + iv = ByteArray(0), + ciphertext = ByteArray(2048 / 8), + ) + assertTrue(km.isKeysBlobDecryptable(rsaBlob)) + assertFalse(km.isLegacyKeysBlob(rsaBlob)) + + // A legacy AES-GCM blob carries a 12-byte GCM nonce. + val legacyBlob = KeystoreManager.EncryptedBlob( + iv = ByteArray(12) { 7 }, + ciphertext = ByteArray(48), + ) + assertFalse(km.isKeysBlobDecryptable(legacyBlob)) + assertTrue(km.isLegacyKeysBlob(legacyBlob)) + } + @Test fun walletStoragePlumbsThePolicyThrough() { val storage = WalletStorage( From f8dd367408f0d847d2c95fb5b00fa15f69ee9cf3 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:01:49 -0400 Subject: [PATCH 5/7] fix(kotlin-sdk): recover pre-alias-split RSA keys; distinguish invalid wallet handle; atomic pending-key updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the latest review on dashpay/platform#4060. WalletStorage/KeystoreManager (BLOCKING): the alias split routed only non-empty-IV AES blobs to the legacy fallback, so empty-IV RSA blobs written by the pre-alias-split scheme under KEYS_ALIAS were decrypted against a NEWLY generated key at the policy alias and stranded. retrievePrivateKey now recovers them with the retained former RSA keypair at KEYS_ALIAS (an upgrade fast path when the policy alias is unprovisioned, else a wrong-key catch-fallback) and migrates the value forward; UserNotAuthenticatedException still propagates so the signer can prompt. isPrivateKeyDecryptable now reflects real key presence (policy-alias OR former-RSA OR legacy-AES key), not blob shape alone. The relevant KeystoreManager methods are `open` so the routing can be unit-tested without AndroidKeyStore (no Robolectric provider), covering the full matrix: legacy-AES, legacy-RSA-at-KEYS_ALIAS, new-alias, stranded, mixed-window, and auth-propagation. dashpay.rs (BLOCKING): platform_wallet_get_managed_identity flattened both the invalid-wallet-handle (outer with_item miss) and identity-not-managed (inner miss) into NotFound, which the Kotlin Dashpay layer translates to an unmanaged (zero) handle — so a stale Dashpay instance masked a wallet failure as "unmanaged". The outer miss now returns ErrorInvalidHandle (untranslated, surfaces as PlatformWallet.InvalidHandle); only the inner miss stays NotFound. PlatformWalletPersistenceHandler (suggestion): recordPendingIdentityKey / clearPendingIdentityKey now use MutableStateFlow.update so a persistence-thread record and a host-thread repair-clear cannot drop each other's mutation. Also moves the orphaned withManagedIdentity KDoc onto its declaration. Co-Authored-By: Claude Fable 5 --- .../PlatformWalletPersistenceHandler.kt | 15 +- .../dashsdk/security/KeystoreManager.kt | 98 +++++-- .../dashsdk/security/WalletStorage.kt | 109 +++++-- .../dashfoundation/dashsdk/tokens/Dashpay.kt | 15 +- .../WalletStorageUpgradeMatrixTest.kt | 268 ++++++++++++++++++ .../rs-platform-wallet-ffi/src/dashpay.rs | 18 +- 6 files changed, 465 insertions(+), 58 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 5a6dddb831..c638c23b86 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.runBlocking import org.dashfoundation.dashsdk.ffi.AccountSpecData import org.dashfoundation.dashsdk.ffi.ContactProfileRestoreData @@ -2132,14 +2133,20 @@ class PlatformWalletPersistenceHandler( // ── Pending identity-key bookkeeping (#4053) ────────────────────── + // Both mutators use `MutableStateFlow.update` (atomic compare-and-set) + // rather than a plain read-modify-write on `.value`: the persistence + // callback records a pending key on the Rust caller thread while + // `markIdentityKeyRepaired` can clear one from an arbitrary host thread + // (via PlatformWalletManager.repairIdentityKey). A non-atomic + // read-then-write could interleave and drop one of the two mutations — + // losing a record leaves a watch-only key with no queryable pending state, + // losing a clear leaves a repaired key stale. private fun recordPendingIdentityKey(entry: PendingIdentityKey) { - _pendingIdentityKeys.value = _pendingIdentityKeys.value + (entry.publicKeyHex to entry) + _pendingIdentityKeys.update { it + (entry.publicKeyHex to entry) } } private fun clearPendingIdentityKey(publicKeyHex: String) { - if (publicKeyHex in _pendingIdentityKeys.value) { - _pendingIdentityKeys.value = _pendingIdentityKeys.value - publicKeyHex - } + _pendingIdentityKeys.update { if (publicKeyHex in it) it - publicKeyHex else it } } /** 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 294849b475..6bbef23b0a 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 @@ -62,7 +62,12 @@ import javax.crypto.spec.PSource * Defaults to [KeySecurityPolicy.AUTH_GATED], which preserves the * historical behavior exactly. */ -class KeystoreManager( +// `open` purely so unit tests can substitute a fake that simulates Keystore +// crypto: AndroidKeyStore has no Robolectric provider, so the real encrypt/ +// decrypt cannot run on the JVM (see KeySecurityPolicyTest). The seam lets +// WalletStorage's upgrade-recovery ROUTING be exercised across the full blob +// matrix without a device. Production always uses this concrete implementation. +open class KeystoreManager( val keySecurityPolicy: KeySecurityPolicy = KeySecurityPolicy.AUTH_GATED, ) { @@ -72,7 +77,7 @@ class KeystoreManager( * [KEYS_ALIAS_DEVICE_BOUND] (non-gated decrypt). [WalletStorage] passes * this to [encrypt]/[decrypt] for identity-key material. */ - val keysAlias: String + open val keysAlias: String get() = when (keySecurityPolicy) { KeySecurityPolicy.AUTH_GATED -> KEYS_ALIAS_AUTH_GATED KeySecurityPolicy.DEVICE_BOUND -> KEYS_ALIAS_DEVICE_BOUND @@ -86,7 +91,7 @@ class KeystoreManager( * require authentication, so identity-key writes never prompt. (The legacy * [KEYS_ALIAS] is never an encrypt target — new keys use the RSA aliases.) */ - fun encrypt(plaintext: ByteArray, alias: String = MASTER_ALIAS): EncryptedBlob { + open fun encrypt(plaintext: ByteArray, alias: String = MASTER_ALIAS): EncryptedBlob { if (isIdentityKeysAlias(alias)) { val cipher = Cipher.getInstance(RSA_TRANSFORMATION) cipher.init(Cipher.ENCRYPT_MODE, keysPublicKey(alias), oaepSpec()) @@ -108,7 +113,7 @@ class KeystoreManager( * detects them ([isLegacyKeysBlob]) and routes them through * [decryptLegacyKeysBlob]. */ - fun decrypt(blob: EncryptedBlob, alias: String = MASTER_ALIAS): ByteArray { + open fun decrypt(blob: EncryptedBlob, alias: String = MASTER_ALIAS): ByteArray { if (isIdentityKeysAlias(alias)) { val cipher = Cipher.getInstance(RSA_TRANSFORMATION) cipher.init(Cipher.DECRYPT_MODE, keysPrivateKey(alias), oaepSpec()) @@ -130,7 +135,7 @@ class KeystoreManager( * in `iv` (see [isLegacyKeysBlob]) and are handled by the migration * fallback instead. Never decrypts, so it never prompts for authentication. */ - fun isKeysBlobDecryptable(blob: EncryptedBlob): Boolean = + open fun isKeysBlobDecryptable(blob: EncryptedBlob): Boolean = blob.iv.isEmpty() && blob.ciphertext.size == RSA_KEY_SIZE / 8 /** @@ -140,18 +145,44 @@ class KeystoreManager( * never does. Structural check only — never decrypts, never prompts. * Decrypt these via [decryptLegacyKeysBlob]. */ - fun isLegacyKeysBlob(blob: EncryptedBlob): Boolean = blob.iv.isNotEmpty() + open fun isLegacyKeysBlob(blob: EncryptedBlob): Boolean = blob.iv.isNotEmpty() /** * Whether the legacy AES key at [KEYS_ALIAS] still exists, so a legacy - * identity-key blob is recoverable via [decryptLegacyKeysBlob] (and can be - * migrated to the RSA scheme). A Keystore presence check only — no decrypt, - * no prompt. False once an older build already deleted the key, in which - * case any surviving legacy blob is unrecoverable and needs a re-derive. + * AES-GCM identity-key blob is recoverable via [decryptLegacyKeysBlob] (and + * can be migrated to the RSA scheme). A Keystore presence check only — no + * decrypt, no prompt. False once an older build already deleted the key, in + * which case any surviving legacy AES blob is unrecoverable and needs a + * re-derive. Note [KEYS_ALIAS] is single-typed: it holds EITHER this AES key + * OR the former RSA keypair ([hasLegacyRsaKeysKey]) OR nothing — never both. */ - fun hasLegacyKeysKey(): Boolean = + open fun hasLegacyKeysKey(): Boolean = (androidKeyStore().getKey(KEYS_ALIAS, 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 + * intermediate scheme that wrapped identity keys as empty-IV RSA/OAEP blobs + * under [KEYS_ALIAS] before the AUTH_GATED/DEVICE_BOUND alias split moved new + * keys to dedicated aliases. Only this key can open those blobs, so + * [WalletStorage.retrievePrivateKey] uses it as the recovery fallback and + * migrates the value to the current policy alias. Keystore presence check + * only — never decrypts, never prompts. + */ + open fun hasLegacyRsaKeysKey(): Boolean = + (androidKeyStore().getKey(KEYS_ALIAS, null) as? PrivateKey) != null + + /** + * Whether the current identity-keys [alias] (one of the RSA policy aliases) + * already holds an RSA private key — a NON-generating presence check, unlike + * [decrypt]/[keysPrivateKey] which provision the keypair on first use. Lets + * [WalletStorage.isPrivateKeyDecryptable] report an empty-IV RSA blob's + * recoverability, and [WalletStorage.retrievePrivateKey] route the upgrade + * fast path, without ever creating a key. Never decrypts, never prompts. + */ + open fun hasIdentityKeysKey(alias: String): Boolean = + (androidKeyStore().getKey(alias, null) as? PrivateKey) != null + /** * Decrypt a legacy pre-RSA identity-key [blob] with the retained AES-GCM key * at [KEYS_ALIAS], or return `null` if that key is gone (the blob is then @@ -161,13 +192,33 @@ class KeystoreManager( * window is closed, exactly as the RSA auth-gated decrypt does; the caller * ([KeystoreSigner]) prompts and retries. */ - fun decryptLegacyKeysBlob(blob: EncryptedBlob): ByteArray? { + open fun decryptLegacyKeysBlob(blob: EncryptedBlob): ByteArray? { val legacyKey = androidKeyStore().getKey(KEYS_ALIAS, null) as? SecretKey ?: return null val cipher = Cipher.getInstance(AES_TRANSFORMATION) cipher.init(Cipher.DECRYPT_MODE, legacyKey, GCMParameterSpec(GCM_TAG_BITS, blob.iv)) return cipher.doFinal(blob.ciphertext) } + /** + * Decrypt an empty-IV RSA identity-key [blob] with the retained **former RSA + * keypair** at [KEYS_ALIAS] (the pre-alias-split scheme, + * dashpay/platform#4060), or return `null` if [KEYS_ALIAS] no longer holds an + * RSA private key. Deliberately fetches the existing key WITHOUT generating a + * fresh one. Like the current-scheme RSA decrypt this key was auth-gated, so + * it throws `UserNotAuthenticatedException` when the auth window is closed — + * the caller ([KeystoreSigner]) prompts and retries. A blob that this key + * cannot open (e.g. it actually belongs to the current policy alias) surfaces + * as a JCE `BadPaddingException`, which [WalletStorage.retrievePrivateKey] + * treats as "not this key". + */ + open fun decryptLegacyRsaKeysBlob(blob: EncryptedBlob): ByteArray? { + val legacyRsaPrivate = + androidKeyStore().getKey(KEYS_ALIAS, null) as? PrivateKey ?: return null + val cipher = Cipher.getInstance(RSA_TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, legacyRsaPrivate, oaepSpec()) + return cipher.doFinal(blob.ciphertext) + } + /** IV + ciphertext pair, serialized as `iv.size || iv || ciphertext`. */ data class EncryptedBlob(val iv: ByteArray, val ciphertext: ByteArray) { fun encode(): ByteArray = @@ -334,13 +385,22 @@ class KeystoreManager( const val MASTER_ALIAS = "org.dashfoundation.wallet.master" /** - * **Legacy** identity-keys alias from the pre-RSA scheme, where - * identity private keys were wrapped under an **auth-gated AES-256-GCM** - * key here. Retained (never deleted, never regenerated) purely so - * existing installs' AES-GCM blobs stay decryptable across the upgrade - * to the RSA scheme: [WalletStorage.retrievePrivateKey] falls back to - * this key for a legacy blob and migrates it to [KEYS_ALIAS_AUTH_GATED]. - * New identity keys are NEVER written here — see [keysAlias]. + * **Legacy** identity-keys alias. Across the SDK's history this single + * alias has held, in turn, two now-superseded wrapping keys, so on an + * upgraded install it may currently contain EITHER: + * - the original **auth-gated AES-256-GCM** key (the pre-RSA scheme — + * blobs carry a GCM nonce; recovered via [decryptLegacyKeysBlob], + * gated by [hasLegacyKeysKey]), OR + * - the **former RSA-2048 OAEP keypair** (the intermediate + * pre-alias-split scheme — empty-IV blobs; recovered via + * [decryptLegacyRsaKeysBlob], gated by [hasLegacyRsaKeysKey], + * dashpay/platform#4060). + * It is single-typed (one key at a time) and retained (never deleted, + * never regenerated) purely so existing installs' blobs stay decryptable + * across the upgrade to the aliased RSA scheme: + * [WalletStorage.retrievePrivateKey] falls back to whichever key is + * present and migrates the recovered value to [keysAlias]. New identity + * keys are NEVER written here — see [keysAlias]. */ const val KEYS_ALIAS = "org.dashfoundation.wallet.keys" 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 769efd8ce6..2264cf228f 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 @@ -1,12 +1,14 @@ package org.dashfoundation.dashsdk.security import android.content.Context +import android.security.keystore.UserNotAuthenticatedException import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.first +import java.security.GeneralSecurityException import java.util.Base64 private val Context.secretsStore: DataStore by preferencesDataStore( @@ -130,33 +132,74 @@ class WalletStorage( * under [KeySecurityPolicy.DEVICE_BOUND] it never auth-gates. * Callers must zero the returned array after use. * - * Upgrade path: a blob written by the pre-RSA scheme (AES-GCM under the - * legacy [KeystoreManager.KEYS_ALIAS], never deleted) is decrypted with - * that retained key and then transparently re-encrypted under the current - * RSA alias and rewritten, so it is recovered — not stranded — and future - * reads use the new scheme. A fresh install has no legacy blobs and always - * takes the RSA path. + * Upgrade path — two legacy on-disk schemes are recovered and migrated + * forward transparently (so old identity keys are never stranded), then + * future reads use the current aliased RSA scheme: + * 1. **Pre-RSA AES-GCM** blob (non-empty IV) under the legacy + * [KeystoreManager.KEYS_ALIAS] AES key — decrypted with that retained + * key ([KeystoreManager.decryptLegacyKeysBlob]). + * 2. **Pre-alias-split RSA** blob (empty IV) encrypted under the former RSA + * keypair still at [KeystoreManager.KEYS_ALIAS] — the current policy + * alias cannot open it, so we fall back to that keypair + * ([KeystoreManager.decryptLegacyRsaKeysBlob]) (dashpay/platform#4060). + * Both are re-encrypted under [KeystoreManager.keysAlias] and rewritten. A + * fresh install has no legacy blobs and always takes the current-alias path. */ suspend fun retrievePrivateKey(pubkeyHex: String): ByteArray? { val encoded = store.data.first()[privateKeyKey(pubkeyHex)] ?: return null val blob = decode(encoded) if (keystore.isLegacyKeysBlob(blob)) { - // Legacy AES-GCM blob: recover with the retained legacy key (may - // throw UserNotAuthenticatedException — the legacy key was - // auth-gated — which the signer handles exactly as the RSA path), - // or null if that key is already gone (unrecoverable). + // Scheme 1 — legacy AES-GCM blob: recover with the retained legacy + // AES key (may throw UserNotAuthenticatedException — the legacy key + // was auth-gated — which the signer handles exactly as the RSA + // path), or null if that key is already gone (unrecoverable). val plain = keystore.decryptLegacyKeysBlob(blob) ?: return null - // Opportunistically migrate to the RSA scheme. A rewrite failure - // must not lose the value we just recovered, so it stays best-effort - // and the read still returns the plaintext (migration retries next - // read). - runCatching { - val migrated = keystore.encrypt(plain, alias = keystore.keysAlias) - store.edit { it[privateKeyKey(pubkeyHex)] = encode(migrated) } - } + migrateToPolicyAlias(pubkeyHex, plain) return plain } - return keystore.decrypt(blob, alias = keystore.keysAlias) + // Empty-IV RSA blob: encrypted under EITHER the current policy alias + // (steady state) or the former RSA keypair still at KEYS_ALIAS (scheme 2 + // blobs written before the alias split, dashpay/platform#4060). + // + // Upgrade fast path: when the policy alias has no keypair yet it cannot + // have encrypted this blob, so if the former RSA key is present the blob + // is unambiguously a scheme-2 blob — recover with it directly instead of + // provisioning a throwaway policy keypair only to fail. (In steady state + // the policy alias HAS a key, so this short-circuits without the second + // Keystore probe.) + if (!keystore.hasIdentityKeysKey(keystore.keysAlias) && keystore.hasLegacyRsaKeysKey()) { + val recovered = keystore.decryptLegacyRsaKeysBlob(blob) ?: return null + migrateToPolicyAlias(pubkeyHex, recovered) + return recovered + } + // Otherwise try the current policy alias first; on a wrong-key crypto + // failure (a scheme-2 blob lingering while newer keys already live at + // the policy alias) fall back to the former RSA keypair and migrate. + // UserNotAuthenticatedException is NOT a wrong-key signal — it must + // propagate so KeystoreSigner can prompt and retry. + return try { + keystore.decrypt(blob, alias = keystore.keysAlias) + } catch (e: UserNotAuthenticatedException) { + throw e + } catch (e: GeneralSecurityException) { + val recovered = keystore.decryptLegacyRsaKeysBlob(blob) ?: throw e + migrateToPolicyAlias(pubkeyHex, recovered) + recovered + } + } + + /** + * Best-effort re-encrypt [plain] under the current policy alias + * ([KeystoreManager.keysAlias], a never-auth-gated public-key encrypt) and + * rewrite the stored blob, migrating a recovered legacy value forward. A + * rewrite failure must not lose the value the caller just recovered, so this + * stays best-effort (migration retries on the next read). + */ + private suspend fun migrateToPolicyAlias(pubkeyHex: String, plain: ByteArray) { + runCatching { + val migrated = keystore.encrypt(plain, alias = keystore.keysAlias) + store.edit { it[privateKeyKey(pubkeyHex)] = encode(migrated) } + } } suspend fun deletePrivateKey(pubkeyHex: String) { @@ -167,19 +210,29 @@ class WalletStorage( store.data.first().contains(privateKeyKey(pubkeyHex)) /** - * Whether the blob stored for [pubkeyHex] can be recovered: either it is a - * current-scheme RSA blob, or it is a legacy pre-RSA AES-GCM blob whose - * retained legacy key still exists (so [retrievePrivateKey] can decrypt and - * migrate it). Only a legacy blob whose key was already deleted by an older - * build is unrecoverable — key-health treats that as missing and offers a - * re-derive. Structural + Keystore-presence check only: never decrypts, - * never prompts. + * Whether the blob stored for [pubkeyHex] can actually be recovered — a + * structural + Keystore-presence check (never decrypts, never prompts) that + * reflects real decryptability, not blob shape alone: + * - **Legacy AES-GCM** blob (non-empty IV): recoverable iff the retained + * legacy AES key still exists ([KeystoreManager.hasLegacyKeysKey]). + * - **Empty-IV RSA** blob: recoverable iff SOME present RSA private key can + * open it — the current policy alias's key + * ([KeystoreManager.hasIdentityKeysKey]) or the retained former + * KEYS_ALIAS keypair used as the migration fallback + * ([KeystoreManager.hasLegacyRsaKeysKey], dashpay/platform#4060). A + * correctly-shaped RSA blob whose keys were all deleted by an older build + * is stranded, so shape is NOT sufficient. + * An unrecoverable blob is treated by key-health as missing and offers a + * re-derive. */ suspend fun isPrivateKeyDecryptable(pubkeyHex: String): Boolean { val encoded = store.data.first()[privateKeyKey(pubkeyHex)] ?: return false val blob = decode(encoded) - return keystore.isKeysBlobDecryptable(blob) || - (keystore.isLegacyKeysBlob(blob) && keystore.hasLegacyKeysKey()) + if (keystore.isLegacyKeysBlob(blob)) { + return keystore.hasLegacyKeysKey() + } + return keystore.isKeysBlobDecryptable(blob) && + (keystore.hasIdentityKeysKey(keystore.keysAlias) || keystore.hasLegacyRsaKeysKey()) } /** All entry names (masked listing for the Keystore Explorer screen). */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index 1a130e8bfa..cae70e0e1d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -421,24 +421,27 @@ class Dashpay internal constructor(private val walletHandle: Long) { ContactInfoPublishOutcome.fromRaw(raw) } - /** - * Open the managed-identity handle for [identityId], run [block], - * and destroy the handle before returning (the [contacts] / - * [acceptIncomingRequest] discipline). Returns null when the - * identity isn't managed by this wallet. - */ /** * Snapshot the managed identity for [identityId], or 0 when the wallet * does not manage it. The native side reports an unmanaged identity as * a platform-wallet NotFound error rather than a zero handle, so the * "not managed" outcome is translated here — every local-read caller * treats it as an absence (null / empty / false), never an exception. + * An invalid/stale wallet handle is a distinct native error + * (ErrorInvalidHandle) that is NOT translated, so it propagates instead + * of masquerading as an unmanaged identity (dashpay/platform#4060). */ private fun managedIdentityHandleOrZero(identityId: ByteArray): Long = translateManagedIdentityNotFoundToZero { TokensNative.getManagedIdentity(walletHandle, identityId) } + /** + * Open the managed-identity handle for [identityId], run [block], + * and destroy the handle before returning (the [contacts] / + * [acceptIncomingRequest] discipline). Returns null when the + * identity isn't managed by this wallet. + */ private inline fun withManagedIdentity( identityId: ByteArray, block: (Long) -> T, diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt new file mode 100644 index 0000000000..71f37084d8 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt @@ -0,0 +1,268 @@ +package org.dashfoundation.dashsdk.security + +import android.security.keystore.UserNotAuthenticatedException +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.assertNull +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 +import java.security.GeneralSecurityException +import javax.crypto.BadPaddingException + +/** + * Full upgrade-matrix coverage for [WalletStorage]'s identity-key recovery + * (dashpay/platform#4060). Exercises every stored-blob shape an upgraded + * install can hold — legacy AES-GCM, pre-alias-split RSA under + * [KeystoreManager.KEYS_ALIAS], and current policy-alias RSA — plus the + * stranded and auth-propagation cases. + * + * The real AndroidKeyStore crypto cannot run on the JVM (no Robolectric + * provider — see [KeySecurityPolicyTest]), so a [FakeKeystoreManager] + * substitutes deterministic in-memory "crypto" through the `open` seams on + * [KeystoreManager]. It models the load-bearing invariants the production code + * relies on: an empty-IV blob only decrypts under the alias that produced it, + * [KeystoreManager.KEYS_ALIAS] holds at most one former key (AES XOR RSA), and + * a public-key encrypt provisions the policy alias. This pins the ROUTING and + * migration behavior; the concrete keystore crypto is out of unit-test reach by + * construction. + */ +@RunWith(RobolectricTestRunner::class) +class WalletStorageUpgradeMatrixTest { + + private val pub = "02aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899" + private val secret = ByteArray(32) { (it + 1).toByte() } + + private lateinit var fake: FakeKeystoreManager + private lateinit var storage: WalletStorage + + @Before + fun setUp() = runBlocking { + fake = FakeKeystoreManager() + storage = WalletStorage(ApplicationProvider.getApplicationContext(), fake) + // Isolate from any state a prior test left in the shared DataStore file. + storage.deleteAll() + } + + // ── Blob-shape / routing matrix ────────────────────────────────────── + + /** New-alias (current-scheme) blob: decrypts under the policy alias, no migration. */ + @Test + fun currentPolicyAliasBlobDecryptsDirectly() = runBlocking { + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + storage.storePrivateKey(pub, secret) + + assertTrue(storage.isPrivateKeyDecryptable(pub)) + assertArrayEquals(secret, storage.retrievePrivateKey(pub)) + // No fallback path was taken. + assertEquals(0, fake.legacyRsaFallbackCalls) + } + + /** Legacy AES-GCM blob: recovered via the retained AES key and migrated forward. */ + @Test + fun legacyAesBlobIsRecoveredAndMigrated() = runBlocking { + fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.AES + fake.scheme = FakeKeystoreManager.Scheme.LEGACY_AES + storage.storePrivateKey(pub, secret) // writes a non-empty-IV AES blob + + // Health check sees the retained AES key. + assertTrue(storage.isPrivateKeyDecryptable(pub)) + + // Migration re-encrypts under the current alias. + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + assertArrayEquals(secret, storage.retrievePrivateKey(pub)) + + // The stored blob is now a current-alias blob: a second read no longer + // touches the legacy AES path. + fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.NONE + assertArrayEquals(secret, storage.retrievePrivateKey(pub)) + } + + /** Legacy AES key already deleted by an older build → stranded, reported undecryptable. */ + @Test + fun legacyAesBlobWithDeletedKeyIsStranded() = runBlocking { + fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.AES + fake.scheme = FakeKeystoreManager.Scheme.LEGACY_AES + storage.storePrivateKey(pub, secret) + + fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.NONE // key gone + assertFalse(storage.isPrivateKeyDecryptable(pub)) + // decryptLegacyKeysBlob returns null → retrieve yields null, not a wrong value. + assertNull(storage.retrievePrivateKey(pub)) + } + + /** + * Pre-alias-split RSA blob under KEYS_ALIAS with an empty policy alias: + * the upgrade fast path recovers it with the former RSA key and migrates. + */ + @Test + fun formerRsaBlobEmptyPolicyTakesFastPathAndMigrates() = runBlocking { + fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.RSA + fake.scheme = FakeKeystoreManager.Scheme.FORMER_RSA + storage.storePrivateKey(pub, secret) // former-RSA blob; policy alias not provisioned + + assertTrue(storage.isPrivateKeyDecryptable(pub)) // recoverable via former RSA key + + fake.scheme = FakeKeystoreManager.Scheme.CURRENT // migration writes a policy blob + assertArrayEquals(secret, storage.retrievePrivateKey(pub)) + assertTrue(fake.legacyRsaFallbackCalls > 0) + + // Migrated: the next read decrypts straight under the (now provisioned) + // policy alias without another former-RSA fallback. + val before = fake.legacyRsaFallbackCalls + assertArrayEquals(secret, storage.retrievePrivateKey(pub)) + assertEquals(0, fake.legacyRsaFallbackCalls - before) + } + + /** + * Mixed window: the policy alias already holds a key (so the fast path is + * skipped) while a former-RSA blob lingers. The policy decrypt fails with a + * wrong-key crypto error and the code falls back to the former RSA key. + */ + @Test + fun formerRsaBlobProvisionedPolicyTakesCatchFallback() = runBlocking { + fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.RSA + fake.scheme = FakeKeystoreManager.Scheme.FORMER_RSA + storage.storePrivateKey(pub, secret) + + // Simulate a sibling key already provisioned at the policy alias. + fake.policyKeyProvisioned = true + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + + assertArrayEquals(secret, storage.retrievePrivateKey(pub)) + assertTrue(fake.legacyRsaFallbackCalls > 0) // reached the catch-branch fallback + } + + /** Former-RSA blob whose KEYS_ALIAS key is gone → stranded (no wrong-value recovery). */ + @Test + fun formerRsaBlobWithDeletedKeyIsStranded() { + runBlocking { + fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.RSA + fake.scheme = FakeKeystoreManager.Scheme.FORMER_RSA + storage.storePrivateKey(pub, secret) + + fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.NONE // former RSA key gone + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + assertFalse(storage.isPrivateKeyDecryptable(pub)) + } + // Policy decrypt fails (wrong key) and there is no former key to fall + // back to → the crypto failure surfaces rather than a bogus plaintext. + assertThrows(GeneralSecurityException::class.java) { + runBlocking { storage.retrievePrivateKey(pub) } + } + } + + /** + * A closed auth window must NOT be mistaken for a wrong key: the + * UserNotAuthenticatedException propagates so KeystoreSigner can prompt, + * instead of being swallowed into the former-RSA fallback. + */ + @Test + fun authFailureOnPolicyDecryptPropagates() = runBlocking { + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + storage.storePrivateKey(pub, secret) // provisions the policy alias + + fake.throwAuthOnPolicyDecrypt = true + assertThrows(UserNotAuthenticatedException::class.java) { + runBlocking { storage.retrievePrivateKey(pub) } + } + assertEquals(0, fake.legacyRsaFallbackCalls) // never fell through to the fallback + } + +} + +/** + * Deterministic in-memory stand-in for [KeystoreManager] used only by + * [WalletStorageUpgradeMatrixTest]. RSA-shaped blobs are 256-byte, empty-IV + * ciphertexts tagged with the producing alias; legacy AES blobs carry a + * non-empty IV. Only the alias that produced an RSA blob can decrypt it — every + * other combination raises [BadPaddingException], mirroring the JCE contract the + * production routing depends on. The pure structural predicates + * ([isLegacyKeysBlob], [isKeysBlobDecryptable]) are inherited unchanged. + */ +private class FakeKeystoreManager : + KeystoreManager(KeySecurityPolicy.AUTH_GATED) { + + enum class Scheme { CURRENT, FORMER_RSA, LEGACY_AES } + + enum class KeysAliasKind { NONE, AES, RSA } + + var scheme: Scheme = Scheme.CURRENT + var keysAliasKind: KeysAliasKind = KeysAliasKind.NONE + var policyKeyProvisioned: Boolean = false + var throwAuthOnPolicyDecrypt: Boolean = false + var legacyRsaFallbackCalls: Int = 0 + + override val keysAlias: String get() = POLICY_ALIAS + + override fun encrypt(plaintext: ByteArray, alias: String): EncryptedBlob = when (scheme) { + Scheme.CURRENT -> { + policyKeyProvisioned = true // public-key encrypt provisions the alias + rsaBlob(TAG_POLICY, plaintext) + } + Scheme.FORMER_RSA -> rsaBlob(TAG_FORMER_RSA, plaintext) // does NOT provision policy + Scheme.LEGACY_AES -> aesBlob(plaintext) + } + + override fun decrypt(blob: EncryptedBlob, alias: String): ByteArray { + if (throwAuthOnPolicyDecrypt) throw UserNotAuthenticatedException() + require(alias == POLICY_ALIAS) { "test only decrypts under the policy alias" } + if (blob.ciphertext[0] == TAG_POLICY && policyKeyProvisioned) return plaintextOfRsa(blob) + throw BadPaddingException("wrong key for $alias") + } + + override fun decryptLegacyKeysBlob(blob: EncryptedBlob): ByteArray? = + if (keysAliasKind == KeysAliasKind.AES) plaintextOfAes(blob) else null + + override fun decryptLegacyRsaKeysBlob(blob: EncryptedBlob): ByteArray? { + legacyRsaFallbackCalls++ + if (keysAliasKind != KeysAliasKind.RSA) return null + if (blob.ciphertext[0] == TAG_FORMER_RSA) return plaintextOfRsa(blob) + throw BadPaddingException("former RSA key cannot open this blob") + } + + override fun hasLegacyKeysKey(): Boolean = keysAliasKind == KeysAliasKind.AES + + override fun hasLegacyRsaKeysKey(): Boolean = keysAliasKind == KeysAliasKind.RSA + + override fun hasIdentityKeysKey(alias: String): Boolean = + alias == POLICY_ALIAS && policyKeyProvisioned + + private fun rsaBlob(tag: Byte, plain: ByteArray): EncryptedBlob { + val ct = ByteArray(RSA_BLOB_BYTES) + ct[0] = tag + ct[1] = plain.size.toByte() + plain.copyInto(ct, 2) + return EncryptedBlob(iv = ByteArray(0), ciphertext = ct) + } + + private fun plaintextOfRsa(blob: EncryptedBlob): ByteArray { + val len = blob.ciphertext[1].toInt() and 0xFF + return blob.ciphertext.copyOfRange(2, 2 + len) + } + + private fun aesBlob(plain: ByteArray): EncryptedBlob { + val ct = ByteArray(1 + plain.size) + ct[0] = plain.size.toByte() + plain.copyInto(ct, 1) + return EncryptedBlob(iv = ByteArray(12) { 0xAA.toByte() }, ciphertext = ct) + } + + private fun plaintextOfAes(blob: EncryptedBlob): ByteArray { + val len = blob.ciphertext[0].toInt() and 0xFF + return blob.ciphertext.copyOfRange(1, 1 + len) + } + + private companion object { + const val POLICY_ALIAS = KeystoreManager.KEYS_ALIAS_AUTH_GATED + const val RSA_BLOB_BYTES = 2048 / 8 + const val TAG_POLICY: Byte = 0 + const val TAG_FORMER_RSA: Byte = 2 + } +} diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index 785ee47db5..f4fe1d903a 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -79,7 +79,23 @@ pub unsafe extern "C" fn platform_wallet_get_managed_identity( let info = wm.get_wallet_info(&wallet.wallet_id())?; info.identity_manager.managed_identity(&id).cloned() }); - let inner = unwrap_option_or_return!(option); + // Two distinct "None" outcomes must NOT collapse into one `NotFound` + // (dashpay/platform#4060): the OUTER `with_item` miss means `wallet_handle` + // is absent from `PLATFORM_WALLET_STORAGE` — a closed/stale wallet, i.e. a + // real wallet failure — whereas the INNER miss means the (valid) wallet + // simply does not manage `id`. The Kotlin `Dashpay` layer translates only + // the inner "not managed" miss into a zero handle + // (`translateManagedIdentityNotFoundToZero`); if the invalid-handle case + // also arrived as `NotFound`, a stale `Dashpay` instance would silently + // read as "unmanaged" instead of surfacing the wallet failure. Emit + // `ErrorInvalidHandle` for the outer miss so it stays untranslated, and + // keep the inner miss as `NotFound`. + let Some(inner) = option else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + format!("platform wallet handle {wallet_handle} not found"), + ); + }; let managed = unwrap_option_or_return!(inner); unsafe { *out_managed_identity_handle = MANAGED_IDENTITY_STORAGE.insert(managed) }; PlatformWalletFFIResult::ok() From 374d023fa7af6b10d244e4876e2525dc48ca59a3 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:54:17 -0400 Subject: [PATCH 6/7] fix(kotlin-sdk): don't let wrong-key RSA recovery throw uncaught from retrievePrivateKey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the latest review on dashpay/platform#4060. WalletStorage.retrievePrivateKey (BLOCKING, finding 41026612bc10): the empty-IV RSA upgrade fast path inferred blob origin from key *presence* (!hasIdentityKeysKey(policyAlias) && hasLegacyRsaKeysKey()) and then called decryptLegacyRsaKeysBlob with no crypto-failure handling. Presence is not proof of origin: after an AUTH_GATED->DEVICE_BOUND policy switch, a blob written under a sibling policy alias can enter this branch merely because an unrelated legacy RSA key still lingers at KEYS_ALIAS — decryptLegacyRsaKeysBlob then does a doFinal with the wrong private key and threw a BadPaddingException that escaped uncaught into KeystoreSigner (which catches only UserNotAuthenticatedException). Every candidate key is now TRIED and a wrong-key GeneralSecurityException is absorbed as "not this key" (per the KeystoreManager.decryptLegacyRsaKeysBlob contract) via the new tryFormerRsaRecovery helper, used by both the fast path and the steady-state catch fallback. A blob no present key can open is now unrecoverable -> null (key-health then offers a re-derive), mirroring the legacy-AES stranded path — never a bogus plaintext, never an uncaught crypto exception. UserNotAuthenticatedException still propagates so the signer can prompt and retry. Tests: WalletStorageUpgradeMatrixTest gains siblingAliasBlobUnderUnprovisioned- PolicyDoesNotThrow (former RSA key present, policy unprovisioned, sibling-alias blob -> null, not a thrown BadPaddingException — this failed before the fix); formerRsaBlobWithDeletedKeyIsStranded now asserts null (RSA-stranded made consistent with the AES-stranded null contract). Full :sdk suite green (131). dashpay.rs (suggestion d7c06333de19): add get_managed_identity_unknown_wallet_- is_invalid_handle asserting the outer with_item miss returns ErrorInvalidHandle (not NotFound), locking in the dual-error contract the invalid-handle fix relies on. The inner not-managed -> NotFound outcome needs a seeded-wallet fixture this unit-test module lacks; it stays covered by the Kotlin translation test. Co-Authored-By: Claude Fable 5 --- .../dashsdk/security/WalletStorage.kt | 65 ++++++++++++++----- .../WalletStorageUpgradeMatrixTest.kt | 64 +++++++++++++----- .../rs-platform-wallet-ffi/src/dashpay.rs | 26 ++++++++ 3 files changed, 119 insertions(+), 36 deletions(-) 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 2264cf228f..7d4d81fccd 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 @@ -157,37 +157,66 @@ class WalletStorage( migrateToPolicyAlias(pubkeyHex, plain) return plain } - // Empty-IV RSA blob: encrypted under EITHER the current policy alias - // (steady state) or the former RSA keypair still at KEYS_ALIAS (scheme 2 - // blobs written before the alias split, dashpay/platform#4060). - // - // Upgrade fast path: when the policy alias has no keypair yet it cannot - // have encrypted this blob, so if the former RSA key is present the blob - // is unambiguously a scheme-2 blob — recover with it directly instead of - // provisioning a throwaway policy keypair only to fail. (In steady state - // the policy alias HAS a key, so this short-circuits without the second - // Keystore probe.) - if (!keystore.hasIdentityKeysKey(keystore.keysAlias) && keystore.hasLegacyRsaKeysKey()) { - val recovered = keystore.decryptLegacyRsaKeysBlob(blob) ?: return null + // Empty-IV RSA blob: encrypted under the current policy alias (steady + // state), the former RSA keypair still at KEYS_ALIAS (scheme 2 blobs + // written before the alias split), or — after a policy switch (e.g. + // AUTH_GATED→DEVICE_BOUND) — a *sibling* policy alias we no longer target + // (dashpay/platform#4060). Key *presence* never proves which alias wrote + // THIS blob: an unrelated former RSA key can linger next to a + // sibling-alias blob, so every candidate key is TRIED and a wrong-key + // crypto failure is treated as "not this key" (per the + // [KeystoreManager.decryptLegacyRsaKeysBlob] contract) rather than letting + // a BadPaddingException escape uncaught into KeystoreSigner. A blob that no + // present key can open is unrecoverable → null (key-health then offers a + // re-derive), mirroring the legacy-AES stranded path above — never a bogus + // plaintext. UserNotAuthenticatedException is NOT a wrong-key signal and + // always propagates so KeystoreSigner can prompt and retry. + + // Upgrade fast path: an unprovisioned policy alias cannot have produced + // the blob, so recover with the former RSA keypair directly instead of + // provisioning a throwaway policy keypair only to fail. If that key does + // not open it either (former key absent, or the blob belongs to a sibling + // alias), the blob is unrecoverable here → null. + if (!keystore.hasIdentityKeysKey(keystore.keysAlias)) { + val recovered = tryFormerRsaRecovery(blob) ?: return null migrateToPolicyAlias(pubkeyHex, recovered) return recovered } - // Otherwise try the current policy alias first; on a wrong-key crypto - // failure (a scheme-2 blob lingering while newer keys already live at - // the policy alias) fall back to the former RSA keypair and migrate. - // UserNotAuthenticatedException is NOT a wrong-key signal — it must - // propagate so KeystoreSigner can prompt and retry. + // Steady state: try the current policy alias first; on a wrong-key crypto + // failure (a scheme-2 / sibling blob lingering while a key already lives at + // the policy alias) fall back to the former RSA keypair and migrate, else + // report the blob unrecoverable (null). return try { keystore.decrypt(blob, alias = keystore.keysAlias) } catch (e: UserNotAuthenticatedException) { throw e } catch (e: GeneralSecurityException) { - val recovered = keystore.decryptLegacyRsaKeysBlob(blob) ?: throw e + val recovered = tryFormerRsaRecovery(blob) ?: return null migrateToPolicyAlias(pubkeyHex, recovered) recovered } } + /** + * Attempt recovery of an empty-IV RSA blob with the retained former + * pre-alias-split RSA keypair at [KeystoreManager.KEYS_ALIAS], converting a + * wrong-key crypto failure to `null` ("not this key", + * dashpay/platform#4060). [KeystoreManager.decryptLegacyRsaKeysBlob] returns + * `null` when that key is absent and throws a JCE `BadPaddingException` when + * the key is present but did not write the blob — presence alone is not proof + * of origin, so that throw must be absorbed here rather than escaping + * uncaught. `UserNotAuthenticatedException` is a closed-auth-window signal, + * never a wrong key, so it propagates unchanged. + */ + private fun tryFormerRsaRecovery(blob: KeystoreManager.EncryptedBlob): ByteArray? = + try { + keystore.decryptLegacyRsaKeysBlob(blob) + } catch (e: UserNotAuthenticatedException) { + throw e + } catch (e: GeneralSecurityException) { + null + } + /** * Best-effort re-encrypt [plain] under the current policy alias * ([KeystoreManager.keysAlias], a never-auth-gated public-key encrypt) and diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt index 71f37084d8..c9f5affa39 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt @@ -13,7 +13,6 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -import java.security.GeneralSecurityException import javax.crypto.BadPaddingException /** @@ -139,23 +138,47 @@ class WalletStorageUpgradeMatrixTest { assertTrue(fake.legacyRsaFallbackCalls > 0) // reached the catch-branch fallback } - /** Former-RSA blob whose KEYS_ALIAS key is gone → stranded (no wrong-value recovery). */ + /** + * Former-RSA blob whose KEYS_ALIAS key is gone → stranded. No present key can + * open it, so recovery yields null (a re-derive signal, like the legacy-AES + * stranded path) rather than a bogus plaintext — and, critically, rather than + * an uncaught crypto exception into KeystoreSigner (dashpay/platform#4060). + */ @Test - fun formerRsaBlobWithDeletedKeyIsStranded() { - runBlocking { - fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.RSA - fake.scheme = FakeKeystoreManager.Scheme.FORMER_RSA - storage.storePrivateKey(pub, secret) - - fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.NONE // former RSA key gone - fake.scheme = FakeKeystoreManager.Scheme.CURRENT - assertFalse(storage.isPrivateKeyDecryptable(pub)) - } - // Policy decrypt fails (wrong key) and there is no former key to fall - // back to → the crypto failure surfaces rather than a bogus plaintext. - assertThrows(GeneralSecurityException::class.java) { - runBlocking { storage.retrievePrivateKey(pub) } - } + fun formerRsaBlobWithDeletedKeyIsStranded() = runBlocking { + fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.RSA + fake.scheme = FakeKeystoreManager.Scheme.FORMER_RSA + storage.storePrivateKey(pub, secret) + + fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.NONE // former RSA key gone + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + assertFalse(storage.isPrivateKeyDecryptable(pub)) + // Unrecoverable → null (not a wrong value, not a thrown BadPaddingException). + assertNull(storage.retrievePrivateKey(pub)) + } + + /** + * Regression (dashpay/platform#4060): a blob written under a *sibling* policy + * alias (e.g. a KEYS_ALIAS_AUTH_GATED blob read after an AUTH_GATED→DEVICE_BOUND + * switch) while an UNRELATED former RSA key still lingers at KEYS_ALIAS. The + * policy alias is unprovisioned, so the upgrade fast path is entered on former- + * RSA-key *presence* alone — but that key did not write this blob, so + * decryptLegacyRsaKeysBlob raises BadPaddingException. That wrong-key failure + * must be absorbed ("not this key") and reported as unrecoverable (null) so the + * host can re-derive, NOT escape uncaught into KeystoreSigner (which only + * catches UserNotAuthenticatedException). Before the fix this threw a raw + * BadPaddingException out of retrievePrivateKey. + */ + @Test + fun siblingAliasBlobUnderUnprovisionedPolicyDoesNotThrow() = runBlocking { + fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.RSA // unrelated former RSA key present + fake.scheme = FakeKeystoreManager.Scheme.SIBLING_POLICY + storage.storePrivateKey(pub, secret) // sibling-alias blob; policy alias NOT provisioned + + // The former-RSA fast path is attempted (presence-based) but cannot open + // the blob; the wrong-key failure is absorbed and surfaces as null. + assertNull(storage.retrievePrivateKey(pub)) + assertTrue(fake.legacyRsaFallbackCalls > 0) // recovery was tried, not skipped } /** @@ -189,7 +212,7 @@ class WalletStorageUpgradeMatrixTest { private class FakeKeystoreManager : KeystoreManager(KeySecurityPolicy.AUTH_GATED) { - enum class Scheme { CURRENT, FORMER_RSA, LEGACY_AES } + enum class Scheme { CURRENT, FORMER_RSA, LEGACY_AES, SIBLING_POLICY } enum class KeysAliasKind { NONE, AES, RSA } @@ -207,6 +230,10 @@ private class FakeKeystoreManager : rsaBlob(TAG_POLICY, plaintext) } Scheme.FORMER_RSA -> rsaBlob(TAG_FORMER_RSA, plaintext) // does NOT provision policy + // A blob produced by a sibling policy alias: neither the current policy + // alias key nor the former RSA key can open it, and it does NOT provision + // the policy alias (dashpay/platform#4060). + Scheme.SIBLING_POLICY -> rsaBlob(TAG_SIBLING, plaintext) Scheme.LEGACY_AES -> aesBlob(plaintext) } @@ -264,5 +291,6 @@ private class FakeKeystoreManager : const val RSA_BLOB_BYTES = 2048 / 8 const val TAG_POLICY: Byte = 0 const val TAG_FORMER_RSA: Byte = 2 + const val TAG_SIBLING: Byte = 3 } } diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index f4fe1d903a..3f007a2fbc 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -1030,4 +1030,30 @@ mod tests { assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); assert_eq!(count, 7, "out_count is untouched on a lookup miss"); } + + /// An unknown `wallet_handle` surfaces `ErrorInvalidHandle` — the OUTER + /// `with_item` miss — NOT `NotFound` (dashpay/platform#4060). This is the + /// load-bearing half of the dual-error contract: the Kotlin `Dashpay` layer + /// translates only `NotFound` (a valid wallet that does not manage the id) + /// into a zero handle, so a stale/closed wallet MUST arrive as + /// `ErrorInvalidHandle` to avoid masquerading as an unmanaged identity. The + /// 32-byte `identity_id` is read before the wallet lookup (`read_identifier`), + /// so a real buffer is supplied; `out_managed_identity_handle` must be left + /// untouched on the miss. + /// + /// The complementary inner outcome (a valid wallet lacking the managed + /// identity → `NotFound`) needs a fully seeded wallet in + /// `PLATFORM_WALLET_STORAGE`, which this unit-test module has no fixture for; + /// that path is covered at the translation layer by the Kotlin + /// `ManagedIdentityNotFoundTranslationTest`. + #[test] + fn get_managed_identity_unknown_wallet_is_invalid_handle() { + let id = [0u8; 32]; + let mut out: Handle = 0; + let r = unsafe { + platform_wallet_get_managed_identity(0xDEAD_BEEF, id.as_ptr(), &mut out) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert_eq!(out, 0, "out handle is untouched on an invalid-handle miss"); + } } From 1fcd60ef92ed3aec1a8bc07f3b4445751ca1c496 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:24:48 -0400 Subject: [PATCH 7/7] fix(kotlin-sdk): isPrivateKeyDecryptable probes real decryptability, not key presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses escalated blocker e17e265dc680 on dashpay/platform#4060 — the still-open half of the RSA-alias-inference finding (the retrievePrivateKey crash was fixed last round; the false-healthy key-health verdict was not). isPrivateKeyDecryptable returned isKeysBlobDecryptable(blob) && (hasIdentityKeysKey || hasLegacyRsaKeysKey) — pure structural + key-*presence* checks that never verified the specific blob was written by a present key. For the SIBLING_POLICY scenario (a blob written under a sibling policy alias while an unrelated former RSA key lingers at KEYS_ALIAS) it reported the stranded key "healthy", so WalletKeyHealthSheet never offered the re-derive/repair path the check exists to drive. It now PROBES the same candidate keys retrievePrivateKey uses (policy alias if provisioned, then the former KEYS_ALIAS RSA keypair; legacy AES for GCM blobs) and returns true only when a present key actually opens the blob. The probe never prompts: KeystoreManager.decrypt / decryptLegacyRsaKeysBlob / decryptLegacyKeysBlob are bare Cipher ops (BiometricPrompt is driven only by KeystoreSigner/BiometricGate, never here), so an auth-gated key with a closed window throws UserNotAuthenticatedException rather than showing UI — which counts as DECRYPTABLE (present + would recover after auth; a health check must not report it strandable). Only a wrong-key crypto failure (BadPadding/AEAD) or an absent key is "not decryptable". Recovered plaintext is scrubbed immediately. Tests: siblingAlias… now also asserts isPrivateKeyDecryptable == false (the fix; true before). Adds authGatedPolicyKeyReportsDecryptableWithoutPrompting and authGatedFormerRsaKeyReportsDecryptable (UserNotAuth -> decryptable), with a throwAuthOnLegacyRsaDecrypt toggle on the fake. Full :sdk suite green. Co-Authored-By: Claude Fable 5 --- .../dashsdk/security/WalletStorage.kt | 74 ++++++++++++++----- .../WalletStorageUpgradeMatrixTest.kt | 44 +++++++++++ 2 files changed, 100 insertions(+), 18 deletions(-) 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 7d4d81fccd..1ba2d0657f 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 @@ -239,31 +239,69 @@ class WalletStorage( store.data.first().contains(privateKeyKey(pubkeyHex)) /** - * Whether the blob stored for [pubkeyHex] can actually be recovered — a - * structural + Keystore-presence check (never decrypts, never prompts) that - * reflects real decryptability, not blob shape alone: - * - **Legacy AES-GCM** blob (non-empty IV): recoverable iff the retained - * legacy AES key still exists ([KeystoreManager.hasLegacyKeysKey]). - * - **Empty-IV RSA** blob: recoverable iff SOME present RSA private key can - * open it — the current policy alias's key - * ([KeystoreManager.hasIdentityKeysKey]) or the retained former - * KEYS_ALIAS keypair used as the migration fallback - * ([KeystoreManager.hasLegacyRsaKeysKey], dashpay/platform#4060). A - * correctly-shaped RSA blob whose keys were all deleted by an older build - * is stranded, so shape is NOT sufficient. - * An unrecoverable blob is treated by key-health as missing and offers a - * re-derive. + * Whether the blob stored for [pubkeyHex] can actually be recovered. This + * PROBES the same candidate keys [retrievePrivateKey] would use and returns + * true only when a present key actually opens the blob — NOT a bare + * key-presence check, which reported a stranded/sibling-alias blob "healthy" + * merely because an unrelated key of the right shape existed + * (dashpay/platform#4060, finding e17e265dc680), so `WalletKeyHealthSheet` + * never offered the re-derive/repair path this check exists to drive. + * + * The probe never prompts: [KeystoreManager.decrypt] / + * [KeystoreManager.decryptLegacyRsaKeysBlob] / [KeystoreManager.decryptLegacyKeysBlob] + * are bare Cipher operations — the biometric prompt is driven only by + * `KeystoreSigner`/`BiometricGate`, never here. An auth-gated key whose auth + * window is closed therefore throws `UserNotAuthenticatedException` (rather + * than showing UI), which counts as DECRYPTABLE: the key is present and the + * value would recover after the user authenticates, so a health check must not + * report it strandable. Only a wrong-key crypto failure (BadPadding / AEAD tag) + * or an absent key yields "not decryptable". Recovered plaintext is scrubbed + * immediately — a health check must not leave key bytes on the heap. + * + * - **Legacy AES-GCM** blob (non-empty IV): probe [KeystoreManager.decryptLegacyKeysBlob]. + * - **Empty-IV RSA** blob: probe the current policy alias (only if + * provisioned — an unprovisioned alias can't have written it), then the + * retained former KEYS_ALIAS RSA keypair. A structurally non-RSA blob is + * not decryptable. */ suspend fun isPrivateKeyDecryptable(pubkeyHex: String): Boolean { val encoded = store.data.first()[privateKeyKey(pubkeyHex)] ?: return false val blob = decode(encoded) - if (keystore.isLegacyKeysBlob(blob)) { - return keystore.hasLegacyKeysKey() + return when { + keystore.isLegacyKeysBlob(blob) -> + probeOpensBlob { keystore.decryptLegacyKeysBlob(blob) } + !keystore.isKeysBlobDecryptable(blob) -> false + else -> + (keystore.hasIdentityKeysKey(keystore.keysAlias) && + probeOpensBlob { keystore.decrypt(blob, keystore.keysAlias) }) || + (keystore.hasLegacyRsaKeysKey() && + probeOpensBlob { keystore.decryptLegacyRsaKeysBlob(blob) }) } - return keystore.isKeysBlobDecryptable(blob) && - (keystore.hasIdentityKeysKey(keystore.keysAlias) || keystore.hasLegacyRsaKeysKey()) } + /** + * True iff [decrypt] recovers [blob] with a PRESENT key (plaintext scrubbed + * immediately), or the key is auth-gated with a closed window + * (`UserNotAuthenticatedException` — present and would recover after auth, so + * decryptable). A wrong-key crypto failure or an absent key (`null`) is false. + * Prompt-free by construction — see [isPrivateKeyDecryptable]. Used only by the + * non-prompting key-health probe, never on a signing path. + */ + private fun probeOpensBlob(decrypt: () -> ByteArray?): Boolean = + try { + val plain = decrypt() + if (plain != null) { + plain.fill(0) + true + } else { + false + } + } catch (e: UserNotAuthenticatedException) { + true + } catch (e: GeneralSecurityException) { + false + } + /** All entry names (masked listing for the Keystore Explorer screen). */ suspend fun listEntryNames(): List = store.data.first().asMap().keys.map { it.name }.sorted() diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt index c9f5affa39..444f58ba4d 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt @@ -179,6 +179,46 @@ class WalletStorageUpgradeMatrixTest { // the blob; the wrong-key failure is absorbed and surfaces as null. assertNull(storage.retrievePrivateKey(pub)) assertTrue(fake.legacyRsaFallbackCalls > 0) // recovery was tried, not skipped + + // And key-health must NOT report the stranded blob decryptable + // (finding e17e265dc680): the former RSA key is present but does not open + // this sibling-alias blob, so probing it yields BadPadding -> false, which + // lets WalletKeyHealthSheet offer the re-derive/repair path. Before the + // fix this returned true on bare key presence. + assertFalse(storage.isPrivateKeyDecryptable(pub)) + } + + /** + * Key-health probes actual decryptability, not presence — but an auth-gated + * key with a closed window is DECRYPTABLE, not stranded (finding e17e265dc680). + * A provisioned policy-alias blob whose decrypt throws + * UserNotAuthenticatedException (window closed) must report decryptable: the + * key is present and the value recovers once the user authenticates, and the + * probe must not prompt. (Contrast siblingAlias above, where the key is present + * but genuinely wrong -> BadPadding -> not decryptable.) + */ + @Test + fun authGatedPolicyKeyReportsDecryptableWithoutPrompting() = runBlocking { + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + storage.storePrivateKey(pub, secret) // provisions the policy alias, TAG_POLICY blob + + fake.throwAuthOnPolicyDecrypt = true // closed auth window: decrypt throws UserNotAuth + assertTrue(storage.isPrivateKeyDecryptable(pub)) + } + + /** + * The same auth-gated semantics on the former-RSA recovery path: a present but + * auth-gated KEYS_ALIAS RSA key that would open the blob after auth reports + * decryptable when the window is closed (UserNotAuth), rather than stranded. + */ + @Test + fun authGatedFormerRsaKeyReportsDecryptable() = runBlocking { + fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.RSA + fake.scheme = FakeKeystoreManager.Scheme.FORMER_RSA + storage.storePrivateKey(pub, secret) // former-RSA blob; policy alias unprovisioned + + fake.throwAuthOnLegacyRsaDecrypt = true // closed window on the former RSA key + assertTrue(storage.isPrivateKeyDecryptable(pub)) } /** @@ -220,6 +260,7 @@ private class FakeKeystoreManager : var keysAliasKind: KeysAliasKind = KeysAliasKind.NONE var policyKeyProvisioned: Boolean = false var throwAuthOnPolicyDecrypt: Boolean = false + var throwAuthOnLegacyRsaDecrypt: Boolean = false var legacyRsaFallbackCalls: Int = 0 override val keysAlias: String get() = POLICY_ALIAS @@ -250,6 +291,9 @@ private class FakeKeystoreManager : override fun decryptLegacyRsaKeysBlob(blob: EncryptedBlob): ByteArray? { legacyRsaFallbackCalls++ if (keysAliasKind != KeysAliasKind.RSA) return null + // Auth-gated former RSA key with a closed window throws before the padding + // check (parity with AndroidKeyStore), independent of blob match. + if (throwAuthOnLegacyRsaDecrypt) throw UserNotAuthenticatedException() if (blob.ciphertext[0] == TAG_FORMER_RSA) return plaintextOfRsa(blob) throw BadPaddingException("former RSA key cannot open this blob") }