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 de41a05412..5ad9ee14b7 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 @@ -176,6 +176,55 @@ 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" + } + } + + /** + * `PlatformWalletFFIResultCode::NotFound` (native code 98, + * [PLATFORM_WALLET_NOT_FOUND_CODE]) — the code the FFI's blanket + * `Option → result` conversion emits for every "requested + * not found" miss (an unknown wallet id, an identity the wallet + * does not manage, …). Typed inside the wallet-error family — + * parity with Swift's `PlatformWalletError.notFound`, which also + * keeps 98 in the wallet family — so callers can match a + * wallet-level absence without sniffing [Generic] codes, while + * staying distinct from the rs-sdk-ffi top-level + * [DashSdkError.NotFound] that codes 7/8 map to. + * + * Dashpay's managed-identity local reads never see this type: + * `translateManagedIdentityNotFoundToZero` intercepts the RAW + * code (offset + 98) on the [org.dashfoundation.dashsdk.ffi.DashSDKException] + * before [fromNative] runs and turns the miss into an absence. + */ + class NotFound(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -218,12 +267,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). Mapped to the typed [PlatformWallet.NotFound]. + */ + 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, @@ -232,11 +293,25 @@ 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) + } 7, // ErrorIdentityNotFound 8, // ErrorContactNotFound - 98, // NotFound (Option returned as an error) -> NotFound(message, cause) + // 98 (PlatformWalletFFIResultCode::NotFound, the blanket Option → + // result miss) stays inside the wallet-error family as the typed + // PlatformWallet.NotFound — exact Swift parity + // (PlatformWalletError.notFound) — rather than collapsing into the + // top-level NotFound that rs-sdk-ffi codes 7/8 map to. Dashpay's + // managed-identity local reads are unaffected: they intercept the + // RAW code via translateManagedIdentityNotFoundToZero (#4051) + // before this mapping ever runs. + PLATFORM_WALLET_NOT_FOUND_CODE -> + PlatformWallet.NotFound(message, cause) 16 -> PlatformWallet.ShieldedBroadcastFailed(message, cause) // ErrorShieldedBroadcastFailed 18 -> PlatformWallet.ShieldedSpendUnconfirmed(message, cause) // ErrorShieldedSpendUnconfirmed 19 -> PlatformWallet.ShieldedNoRecordedAnchor(message, cause) // ErrorShieldedNoRecordedAnchor @@ -245,8 +320,16 @@ sealed class DashSdkError( 23 -> PlatformWallet.AssetLockNotTracked(message, cause) // ErrorAssetLockNotTracked 24 -> PlatformWallet.AssetLockAlreadyConsumed(message, cause) // ErrorAssetLockAlreadyConsumed 25 -> PlatformWallet.AssetLockFundingMismatch(message, cause) // ErrorAssetLockFundingMismatch - 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/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 0fa8ae1927..536e5a1abc 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 @@ -5,7 +5,11 @@ import androidx.room.withTransaction import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExecutorCoroutineDispatcher 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.flow.update import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -162,6 +166,25 @@ class PlatformWalletPersistenceHandler( */ private class ChangesetBuffer { val ops: MutableList Unit> = mutableListOf() + + /** + * [pendingIdentityKeys] map deltas staged by + * [onPersistIdentityKeyUpsert] during this round. The matching + * `PublicKeyEntity` row is only BUFFERED until [onChangesetEnd], so + * the pending-state change it describes is not true until that row + * commits: publishing a record early would flag a key whose + * watch-only row may be discarded by rollback, and publishing a + * clear early would drop the repair signal for an old watch-only + * row whose successful re-derive then rolls back (alias cleanup + * deletes the newly stored scalar). Applied in order, in ONE atomic + * [MutableStateFlow.update], only after the Room transaction + * commits; discarded with the buffer on rollback/abort so an + * aborted round leaves the pre-round map untouched + * (dashpay/platform#4060, finding de3cf44a71fc). + */ + val pendingKeyDeltas: + MutableList<(Map) -> Map> = + mutableListOf() } /** Open rounds keyed by walletId hex (a round is per-walletId). */ @@ -285,6 +308,52 @@ class PlatformWalletPersistenceHandler( suspend fun withCallbackExclusion(block: suspend () -> T): T = callbackExclusion.withLock { block() } + /** + * 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. + * + * Transactional with the round it belongs to: while a store round is + * open, record/clear mutations are staged in the round's + * [ChangesetBuffer] and published only after the Room transaction + * commits — a rolled-back or aborted round leaves this map exactly as + * it was before the round (see [ChangesetBuffer.pendingKeyDeltas]). + * Standalone (non-bracketed) upserts and [markIdentityKeyRepaired] + * publish immediately. + */ + 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 @@ -309,7 +378,8 @@ class PlatformWalletPersistenceHandler( // A pending leftover here means the previous round never reached // its end callback (abandoned mid-round) — its rows never // committed, so its aliases are orphans; scrub them like a - // rolled-back round. Then retry any earlier failed cleanup. + // rolled-back round (its staged pending-key deltas vanish with the + // replaced buffer). Then retry any earlier failed cleanup. scrubPendingAliases(key) retryOrphanedAliases(key) buffers[key] = ChangesetBuffer() @@ -324,7 +394,9 @@ class PlatformWalletPersistenceHandler( // `backgroundContext.rollback()` — and delete the aliases the // deriver already wrote for this round: their rows will never // commit, so leaving them would strand undiscoverable - // identity-key ciphertext in the DataStore forever. + // identity-key ciphertext in the DataStore forever. The round's + // staged pending-key deltas are discarded with the buffer, so + // the pre-round [pendingIdentityKeys] map survives untouched. scrubPendingAliases(key) return@guarded 0 } @@ -336,11 +408,14 @@ class PlatformWalletPersistenceHandler( } } } - // Rows committed — the aliases are discoverable the normal way. + // Rows committed — the aliases are discoverable the normal way, + // and the round's pending-key state changes are now true. pendingRoundAliases.remove(key) + publishPendingKeyDeltas(buffer) } catch (t: Throwable) { // Commit failed: the staged rows never landed, so the round's - // aliases are orphans exactly like the !success branch. + // aliases are orphans exactly like the !success branch — and its + // pending-key deltas are equally void (discarded with the buffer). scrubPendingAliases(key) throw t } @@ -1023,17 +1098,61 @@ class PlatformWalletPersistenceHandler( runCatching { deriver.hasStored(pubkeyHex) }.getOrDefault(true) val derivedKeychainId: String? = if (derivationIndicesIsSome && deriver != null && !readOnly && walletStillPersisted) { - 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. + // Staged with the round (when one is open): if this + // round rolls back, alias cleanup deletes the newly + // stored scalar, so the old watch-only row must keep + // its repair signal (finding de3cf44a71fc). + stagePendingKeyDelta(roundKey, clearPendingKeyDelta(pubkeyHex)) + } 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(), + ) + // Staged with the round (when one is open): the row is + // being persisted watch-only INSIDE the round's buffer, + // so if the round aborts that row never commits and the + // pending entry would be a phantom (finding de3cf44a71fc). + stagePendingKeyDelta( + roundKey, + recordPendingKeyDelta( + PendingIdentityKey( + walletIdHex = keyOwnerWalletId.toHex(), + identityIdBase58 = identityId.toBase58String(), + keyId = keyId, + publicKeyHex = publicKeyData.toHex(), + identityIndex = identityIndex, + keyIndex = keyIndex, + reason = reason, + failedAtMs = System.currentTimeMillis(), + ), + ), + ) + } + id } else { null } @@ -2418,6 +2537,78 @@ class PlatformWalletPersistenceHandler( } } + // ── Pending identity-key bookkeeping (#4053) ────────────────────── + + // Every publish to the map goes through `MutableStateFlow.update` + // (atomic compare-and-set) rather than a plain read-modify-write on + // `.value`: the persistence callback publishes on the Rust caller + // thread while `markIdentityKeyRepaired` can clear 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. + // + // The mutations themselves are expressed as pure map deltas so the + // upsert callback can STAGE them with the round's [ChangesetBuffer] + // instead of publishing mid-round (finding de3cf44a71fc): the pending + // state a delta describes only becomes true when the round's Room + // transaction commits, and an aborted round must leave no trace. + + private fun recordPendingKeyDelta( + entry: PendingIdentityKey, + ): (Map) -> Map = + { it + (entry.publicKeyHex to entry) } + + private fun clearPendingKeyDelta( + publicKeyHex: String, + ): (Map) -> Map = + { if (publicKeyHex in it) it - publicKeyHex else it } + + /** + * Stage [delta] with the wallet's open round (published atomically by + * [publishPendingKeyDeltas] after the round's transaction commits, + * discarded on rollback/abort), or publish immediately when no round is + * open — the standalone-callback path, whose Room write also commits + * immediately. Caller must hold [callbackExclusion] (every persist + * callback does), which also guards [buffers]. + */ + private fun stagePendingKeyDelta( + walletIdHex: String, + delta: (Map) -> Map, + ) { + val buffer = buffers[walletIdHex] + if (buffer != null) { + buffer.pendingKeyDeltas.add(delta) + } else { + _pendingIdentityKeys.update(delta) + } + } + + /** Publish a committed round's staged deltas in ONE atomic map update. */ + private fun publishPendingKeyDeltas(buffer: ChangesetBuffer) { + if (buffer.pendingKeyDeltas.isEmpty()) return + _pendingIdentityKeys.update { map -> + buffer.pendingKeyDeltas.fold(map) { acc, delta -> delta(acc) } + } + } + + /** + * 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. Publishes immediately (never staged with a round): the repair's + * scalar store already happened out-of-band, not inside any changeset. + */ + internal fun markIdentityKeyRepaired(publicKeyHex: String) { + _pendingIdentityKeys.update(clearPendingKeyDelta(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..b4c3c7edd9 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt @@ -0,0 +1,59 @@ +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 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** + * `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..c17e4f21dc 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 @@ -4,10 +4,13 @@ import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.security.keystore.StrongBoxUnavailableException +import android.util.Log +import java.security.GeneralSecurityException import java.security.KeyPair import java.security.KeyPairGenerator import java.security.KeyStore import java.security.PrivateKey +import java.security.ProviderException import java.security.PublicKey import java.security.spec.MGF1ParameterSpec import javax.crypto.Cipher @@ -29,9 +32,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 @@ -40,21 +44,70 @@ 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]). + * - [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 + * [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 { +// `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, + /** + * Whether the device currently has a secure lock screen configured + * (`KeyguardManager.isDeviceSecure`). Supplied by [WalletStorage] (which + * holds a `Context`); defaults to `true` for the no-`Context` / + * unit-test construction path. Consulted at key GENERATION only, to decide + * whether the lock-screen-bound Keystore parameters are enforceable — see + * [generateWithLockScreenDegradation] and [lockBoundKeyParamsSupported] + * (dashpay/platform#4060, no-secure-lock-screen key-gen failure). + */ + private val deviceSecureProbe: () -> Boolean = { true }, +) { + + /** + * The identity-keys alias this manager targets, per + * [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. + */ + open val keysAlias: String + get() = when (keySecurityPolicy) { + 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). [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 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. (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 (alias == KEYS_ALIAS) { + 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(), oaepSpec()) + cipher.init(Cipher.ENCRYPT_MODE, keysPublicKey(alias), oaepSpec()) return EncryptedBlob(iv = ByteArray(0), ciphertext = cipher.doFinal(plaintext)) } val cipher = Cipher.getInstance(AES_TRANSFORMATION) @@ -64,15 +117,19 @@ 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. + * `BiometricGate` and retries. The [KEYS_ALIAS_DEVICE_BOUND] decrypt is + * 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 (alias == KEYS_ALIAS) { + 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(), oaepSpec()) + cipher.init(Cipher.DECRYPT_MODE, keysPrivateKey(alias), oaepSpec()) return cipher.doFinal(blob.ciphertext) } val cipher = Cipher.getInstance(AES_TRANSFORMATION) @@ -85,16 +142,146 @@ 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 = + open 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]. + */ + open fun isLegacyKeysBlob(blob: EncryptedBlob): Boolean = blob.iv.isNotEmpty() + + /** + * Whether the legacy AES key at [KEYS_ALIAS] still exists, so a legacy + * 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. + */ + 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 + * 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. + */ + 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) + } + + /** + * Whether [blob] opens under the non-auth-gated [KEYS_ALIAS_DEVICE_BOUND] + * sibling — probed PROMPT-FREE and WITHOUT generating a key (guarded on + * [hasIdentityKeysKey] presence; recovered plaintext is scrubbed + * immediately). "Prompt-free" means it never shows a biometric prompt: the + * DEVICE_BOUND alias carries no `setUserAuthenticationRequired` gate, so a + * positive result never blocks on user authentication. It is NOT + * unconditional, though — a lock-bound DEVICE_BOUND key still has + * `setUnlockedDeviceRequired`, so if this probe runs while the device is + * CURRENTLY LOCKED the decrypt throws `UserNotAuthenticatedException` (a + * [GeneralSecurityException] subclass), which the catch below treats as + * "cannot disprove" and returns false. That is the conservative direction: + * the caller ([WalletStorage.isPrivateKeyDecryptable]) then reports the blob + * decryptable rather than falsely offering repair, and the disproof simply + * defers to the next unlock (the same residual as the locked FORMER-RSA case + * documented below). In practice the key-health sheet runs in-app on an + * unlocked device, where the probe is live. + * + * An RSA-OAEP ciphertext opens under exactly one keypair, so a positive + * result PROVES the blob belongs to the DEVICE_BOUND sibling rather than to + * the current (auth-gated) policy alias. The key-health check + * ([WalletStorage.isPrivateKeyDecryptable]) uses this to DISPROVE a locked + * auth-gated policy alias's ownership of a sibling-written blob: without it, + * that alias's `UserNotAuthenticatedException` (thrown at `cipher.init` before + * the ciphertext is ever examined) is indistinguishable from a locked but + * legitimate owner and would be mis-reported as "decryptable" + * (dashpay/platform#4060, finding b80a15c93339). Because + * [WalletStorage.retrievePrivateKey] under the current policy never falls back + * to the sibling alias, such a blob is genuinely unrecoverable and must drive + * the re-derive/repair path. + * + * Returns false when the current policy already IS + * [KeySecurityPolicy.DEVICE_BOUND] (the sibling would be the policy alias + * itself, already probed on the normal path) or when no DEVICE_BOUND key is + * present. Residual: the symmetric case — a locked auth-gated FORMER RSA key + * at [KEYS_ALIAS] whose ownership can't be disproved without a prompt — is + * still reported decryptable until the first real unlock surfaces the + * BadPadding (documented in [WalletStorage.isPrivateKeyDecryptable]). + */ + open fun opensUnderNonGatedDeviceBoundSibling(blob: EncryptedBlob): Boolean { + if (keySecurityPolicy == KeySecurityPolicy.DEVICE_BOUND) return false + if (!hasIdentityKeysKey(KEYS_ALIAS_DEVICE_BOUND)) return false + return try { + decrypt(blob, KEYS_ALIAS_DEVICE_BOUND).fill(0) + true + } catch (e: GeneralSecurityException) { + false + } + } + /** IV + ciphertext pair, serialized as `iv.size || iv || ciphertext`. */ data class EncryptedBlob(val iv: ByteArray, val ciphertext: ByteArray) { fun encode(): ByteArray = @@ -128,7 +315,7 @@ class KeystoreManager { } private fun generateAesKey(alias: String): SecretKey { - fun spec(strongBox: Boolean): KeyGenParameterSpec { + fun spec(strongBox: Boolean, lockBound: Boolean): KeyGenParameterSpec { val builder = KeyGenParameterSpec.Builder( alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, @@ -136,7 +323,12 @@ class KeystoreManager { .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(256) - .setUnlockedDeviceRequired(true) + // `setUnlockedDeviceRequired(true)` binds the key to a screen-lock + // (an "unlocked device" is only meaningful when a secure lock screen + // exists); KeyMint rejects generate_key for it on a lockless device. + // Dropped when no secure lock screen is configured (see + // [generateWithLockScreenDegradation]). + if (lockBound) builder.setUnlockedDeviceRequired(true) if (strongBox) builder.setIsStrongBoxBacked(true) return builder.build() } @@ -145,27 +337,30 @@ class KeystoreManager { KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE, ) - return try { - generator.init(spec(strongBox = true)) - generator.generateKey() - } catch (_: StrongBoxUnavailableException) { - generator.init(spec(strongBox = false)) + return generateWithLockScreenDegradation(alias) { strongBox, lockBound -> + generator.init(spec(strongBox, lockBound)) generator.generateKey() } } - // ── 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_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). - 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_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]) @@ -177,22 +372,26 @@ 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_AUTH_GATED 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. 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. - runCatching { keyStore.deleteEntry(KEYS_ALIAS) } + // 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 { + fun spec(strongBox: Boolean, lockBound: Boolean): KeyGenParameterSpec { val builder = KeyGenParameterSpec.Builder( - KEYS_ALIAS, + alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, ) .setKeySize(RSA_KEY_SIZE) @@ -202,21 +401,34 @@ class KeystoreManager { // [oaepSpec] — both encrypt and decrypt pin MGF1 = SHA-1. .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) + // Both lock-screen-bound parameters — `setUnlockedDeviceRequired` + // and (auth-gated only) `setUserAuthenticationRequired` — require a + // secure lock screen to exist; KeyMint rejects generate_key for them + // otherwise. On a lockless device both are dropped: the AUTH_GATED + // alias then degrades to a non-gated key (there is no lock-screen + // authenticator to gate on anyway). See + // [generateWithLockScreenDegradation]. + if (lockBound) { + builder.setUnlockedDeviceRequired(true) + 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() @@ -226,15 +438,73 @@ class KeystoreManager { KeyProperties.KEY_ALGORITHM_RSA, ANDROID_KEYSTORE, ) - try { - generator.initialize(spec(strongBox = true)) - generator.generateKeyPair() - } catch (_: StrongBoxUnavailableException) { - generator.initialize(spec(strongBox = false)) + generateWithLockScreenDegradation(alias) { strongBox, lockBound -> + generator.initialize(spec(strongBox, lockBound)) generator.generateKeyPair() } } + /** + * Run [generate] — which builds+initializes the spec for `(strongBox, + * lockBound)` and produces the key — degrading gracefully when the device + * has no secure lock screen. + * + * THE APP MUST WORK WITHOUT A SCREEN LOCK (product decision, + * dashpay/platform#4060). The lock-screen-bound Keystore parameters + * (`setUnlockedDeviceRequired`, and for the auth-gated alias + * `setUserAuthenticationRequired`) require a secure lock screen; without one + * KeyMint fails `generate_key` (observed on-device: `ProviderException` + * "Keystore key generation failed" wrapping `KeyStoreException` system error, + * KeyMint 10309). Strategy: + * 1. If [deviceSecureProbe] reports NO secure lock screen, build the key + * WITHOUT the lock-bound params up front and log the downgrade. + * 2. Otherwise attempt with the lock-bound params; if generation still + * fails with the no-secure-lock-screen signature (a race — the lock was + * removed after the probe — or an OEM that rejects it despite a probe + * saying secure), retry once WITHOUT them and log the downgrade. + * Each attempt keeps the existing StrongBox→TEE fallback. + * + * Existing keys are never regenerated (callers check presence first), so this + * only affects FIRST-USE creation on a lockless device; a key already + * provisioned with the lock-bound params is untouched. + */ + private fun generateWithLockScreenDegradation( + alias: String, + generate: (strongBox: Boolean, lockBound: Boolean) -> T, + ): T { + fun withStrongBoxFallback(lockBound: Boolean): T = + try { + generate(true, lockBound) + } catch (_: StrongBoxUnavailableException) { + generate(false, lockBound) + } + + val lockBoundSupported = lockBoundKeyParamsSupported(deviceSecureProbe()) + if (!lockBoundSupported) { + Log.w( + TAG, + "No secure lock screen (KeyguardManager.isDeviceSecure=false); generating " + + "'$alias' WITHOUT lock-screen binding so the wallet works without a " + + "screen lock (dashpay/platform#4060).", + ) + return withStrongBoxFallback(lockBound = false) + } + return try { + withStrongBoxFallback(lockBound = true) + } catch (e: ProviderException) { + if (!isNoSecureLockScreenKeyGenFailure(e)) throw e + Log.w( + TAG, + "Key generation for '$alias' was rejected for requiring a secure lock " + + "screen even though the device reported secure (lock removed mid-flight, " + + "or an OEM quirk); retrying WITHOUT lock-screen binding " + + "(dashpay/platform#4060).", + e, + ) + withStrongBoxFallback(lockBound = false) + } + } + // Pin MGF1 = SHA-1 to match AndroidKeyStore. The public-key encrypt runs // on the default JCE provider (the certificate's public key is a plain // RSAPublicKey), which would otherwise default MGF1 to SHA-256; the @@ -246,13 +516,111 @@ class KeystoreManager { companion object { const val MASTER_ALIAS = "org.dashfoundation.wallet.master" + + /** + * **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" - /** Auth window for the identity-keys alias, in seconds. */ + /** + * 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 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 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_AUTH_GATED || alias == KEYS_ALIAS_DEVICE_BOUND + + /** + * Whether the lock-screen-bound key-generation parameters + * (`setUnlockedDeviceRequired`, and for the auth-gated alias + * `setUserAuthenticationRequired`) may be applied. They require a secure + * lock screen — KeyMint rejects `generate_key` for them otherwise — so + * they are only usable when [deviceSecure] + * (`KeyguardManager.isDeviceSecure`) is true. Pure so the + * parameter-selection logic is unit-testable without an Android runtime + * (dashpay/platform#4060). + */ + internal fun lockBoundKeyParamsSupported(deviceSecure: Boolean): Boolean = deviceSecure + + /** + * Whether [t]'s cause chain is the KeyMint "generate_key needs a secure + * lock screen" rejection: a key-generation `ProviderException` (message + * "Keystore key generation failed") and/or an `android.security` + * `KeyStoreException` system error raised from `generate_key` (observed + * on-device as internal Keystore code 4 / KeyMint 10309 after the lock + * screen was removed). Used only as the retry-decision for + * [generateWithLockScreenDegradation]'s safety net. Pure and + * JVM-testable — matches by exception type name and message so it needs + * no Android classes (dashpay/platform#4060). + */ + internal fun isNoSecureLockScreenKeyGenFailure(t: Throwable): Boolean { + var cur: Throwable? = t + var sawKeyGenFailure = false + while (cur != null) { + val name = cur::class.java.name + val msg = cur.message.orEmpty() + if (name.endsWith("ProviderException") && + msg.contains("key generation failed", ignoreCase = true) + ) { + sawKeyGenFailure = true + } + if (name.endsWith("KeyStoreException")) { + // A Keystore-side generation system error reached via a + // key-gen ProviderException, or one that names generate_key / + // a lock-screen requirement directly. + if (sawKeyGenFailure || + msg.contains("generate_key", ignoreCase = true) || + msg.contains("lock screen", ignoreCase = true) + ) { + return true + } + } + cur = cur.cause + } + return false + } + + private const val TAG = "KeystoreManager" + // 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/KeystoreSigner.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt index 130a77eba8..23e9f9c7a6 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 } @@ -208,7 +214,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/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt index 480a475055..6e41c9fd63 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,6 +1,8 @@ package org.dashfoundation.dashsdk.security +import android.app.KeyguardManager 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 @@ -10,6 +12,7 @@ import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import java.security.GeneralSecurityException import java.util.Base64 private val Context.secretsStore: DataStore by preferencesDataStore( @@ -25,15 +28,36 @@ 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(), + private val keystore: KeystoreManager = + KeystoreManager(deviceSecureProbe = deviceSecureProbe(context)), ) { + /** + * 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, deviceSecureProbe(context))) + private val store = context.secretsStore + /** The identity-key security policy this storage was constructed with. */ + val keySecurityPolicy: KeySecurityPolicy get() = keystore.keySecurityPolicy + /** * Serializes every `privkey.*` alias mutation. A single DataStore * `edit` is already atomic, but compound sequences (wallet deletion's @@ -137,14 +161,15 @@ 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]. */ /** * @param ownerWalletId when given, the alias is also recorded in the @@ -162,7 +187,7 @@ class WalletStorage( ownerWalletId: ByteArray? = null, ) { privateKeyMutex.withLock { - val blob = keystore.encrypt(privateKey, alias = KeystoreManager.KEYS_ALIAS) + val blob = keystore.encrypt(privateKey, alias = keystore.keysAlias) store.edit { it[privateKeyKey(pubkeyHex)] = encode(blob) if (ownerWalletId != null) { @@ -183,14 +208,131 @@ class WalletStorage( store.data.first()[ownerIndexKey(walletId.toHex())] ?: emptySet() /** - * 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. + * + * 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 - return keystore.decrypt(decode(encoded), alias = KeystoreManager.KEYS_ALIAS) + val blob = decode(encoded) + if (keystore.isLegacyKeysBlob(blob)) { + // 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 + migrateToPolicyAlias(pubkeyHex, plain, encoded) + return plain + } + // 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, encoded) + return recovered + } + // 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 = tryFormerRsaRecovery(blob) ?: return null + migrateToPolicyAlias(pubkeyHex, recovered, encoded) + 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 + * 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). + * + * The rewrite is CONDITIONAL on the entry still holding [sourceEncoded] — + * the exact encoded blob the caller read and recovered. [retrievePrivateKey] + * runs without [privateKeyMutex], so between its read and this rewrite a + * wallet deletion can win [withPrivateKeyExclusion], sweep the alias plus + * its owner-index entry, and cascade the Room rows; an unconditional edit + * would then RESURRECT `privkey.` as undiscoverable ciphertext + * with no owner-index or database reference, violating removeWallet's + * no-surviving-ciphertext guarantee (dashpay/platform#4060, finding + * 1049be675782). DataStore serializes edits, so the still-present check and + * the write commit atomically against the deletion's edit: if the deletion + * (or any concurrent overwrite — e.g. a [storePrivateKey] racing in a newer + * value) got there first, the migration is skipped; the caller still + * returns the plaintext it legitimately recovered. + */ + private suspend fun migrateToPolicyAlias( + pubkeyHex: String, + plain: ByteArray, + sourceEncoded: String, + ) { + runCatching { + val migrated = keystore.encrypt(plain, alias = keystore.keysAlias) + store.edit { + val key = privateKeyKey(pubkeyHex) + if (it[key] == sourceEncoded) it[key] = encode(migrated) + } + } } suspend fun deletePrivateKey(pubkeyHex: String) { @@ -235,18 +377,90 @@ class WalletStorage( store.data.first().contains(privateKeyKey(pubkeyHex)) /** - * Whether the blob stored for [pubkeyHex] is decryptable under the - * current [KeystoreManager.KEYS_ALIAS] 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 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: first let a prompt-free DEVICE_BOUND sibling + * DISPROVE ownership (see below), then 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. + * + * **AUTH_GATED residual (dashpay/platform#4060, finding b80a15c93339).** A + * locked auth-gated alias throws `UserNotAuthenticatedException` at + * `cipher.init` — before the ciphertext is examined — so a bare catch cannot + * tell a locked *legitimate owner* from a locked *wrong* alias, and would + * mis-report a sibling-written blob as decryptable. The prompt-free + * DEVICE_BOUND sibling ([KeystoreManager.opensUnderNonGatedDeviceBoundSibling]) + * resolves the common case: if that non-gated sibling opens the blob, the + * current (auth-gated) policy alias does NOT own it, and since + * [retrievePrivateKey] never falls back to the sibling the blob is genuinely + * strandable → `false` (drives the re-derive/repair path). The irreducible + * residual is the symmetric one — a locked auth-gated FORMER RSA key at + * KEYS_ALIAS whose ownership can't be disproved prompt-free: it is still + * reported decryptable until the first real unlock surfaces the BadPadding, + * at which point [retrievePrivateKey]'s fallback→null drives the same repair. */ 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 when { + keystore.isLegacyKeysBlob(blob) -> + probeOpensBlob { keystore.decryptLegacyKeysBlob(blob) } + !keystore.isKeysBlobDecryptable(blob) -> false + // A prompt-free sibling proves the blob belongs to the non-gated + // DEVICE_BOUND alias, not the current (auth-gated, possibly locked) + // policy alias — and retrieve never tries the sibling — so it is + // unrecoverable here (finding b80a15c93339). + keystore.opensUnderNonGatedDeviceBoundSibling(blob) -> false + else -> + (keystore.hasIdentityKeysKey(keystore.keysAlias) && + probeOpensBlob { keystore.decrypt(blob, keystore.keysAlias) }) || + (keystore.hasLegacyRsaKeysKey() && + probeOpensBlob { keystore.decryptLegacyRsaKeysBlob(blob) }) + } } + /** + * 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() @@ -282,5 +496,22 @@ class WalletStorage( const val PRIVKEY_OWNERS_PREFIX = "privkeyowners." fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + /** + * A prompt-free probe of whether the device currently has a secure lock + * screen (`KeyguardManager.isDeviceSecure`), captured against the + * application context so it re-reads live state at each key generation + * (a lock can be added/removed at any time). Handed to [KeystoreManager] + * so it can drop the lock-screen-bound key-gen parameters when no lock is + * configured — the wallet must work without a screen lock + * (dashpay/platform#4060). + */ + fun deviceSecureProbe(context: Context): () -> Boolean { + val appContext = context.applicationContext + return { + (appContext.getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager) + ?.isDeviceSecure == true + } + } } } 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 26f7025cc0..634dc0ba26 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 @@ -5,7 +5,9 @@ import org.dashfoundation.dashsdk.wallet.op 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 @@ -215,7 +217,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()) } @@ -253,7 +255,7 @@ class Dashpay internal constructor(private val walletHandle: Long, coreSignerHandle: Long, ): Boolean = gate.op { mapNativeErrors { - val identityHandle = TokensNative.getManagedIdentity(walletHandle, ourIdentityId) + val identityHandle = managedIdentityHandleOrZero(ourIdentityId) if (identityHandle == 0L) return@mapNativeErrors false try { val requestHandle = @@ -440,6 +442,21 @@ class Dashpay internal constructor(private val walletHandle: Long, ContactInfoPublishOutcome.fromRaw(raw) } + /** + * 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] / @@ -450,7 +467,7 @@ class Dashpay internal constructor(private val walletHandle: Long, 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) @@ -547,3 +564,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.NotFound("…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/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 11c64d3dff..33a336d1fe 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 @@ -346,6 +346,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 @@ -365,6 +376,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. */ suspend fun repairIdentityKey( walletId: ByteArray, @@ -377,12 +394,16 @@ class PlatformWalletManager( // deriveAndStore is a synchronous JNI call keyed on the manager's // resolver handle — the gate keeps teardown from freeing it // mid-derive (callers run on their own Compose scopes). - identityKeyDeriver.deriveAndStore( + val storageIdentifier = identityKeyDeriver.deriveAndStore( walletId = walletId, publicKeyData = publicKeyData, identityIndex = identityIndex, keyIndex = keyIndex, ) + if (storageIdentifier != null) { + persistenceHandler.markIdentityKeyRepaired(publicKeyData.toHex()) + } + storageIdentifier } /** @@ -1531,7 +1552,8 @@ class PlatformWalletManager( * its next tick; a stopped loop observes it on next start. Equal/forward * heights are harmless no-ops for scan purposes. If the process dies * before the loop consumes and persists progress, the user must reissue - * the request. Unknown wallets surface as typed [DashSdkError.NotFound]. + * the request. Unknown wallets surface as typed + * [DashSdkError.PlatformWallet.NotFound] (native code 98). */ suspend fun rescanSpvFilters(walletId: ByteArray, fromHeight: Int) = withContext(Dispatchers.IO) { 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 f8e397cade..713b3bceb9 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 @@ -58,11 +58,22 @@ class DashSdkErrorTest { // Distinct from the rs-sdk-ffi CryptoError that shares raw code 6. assertFalse(walletOp is DashSdkError.CryptoError) - listOf(7, 8, 98).forEach { code -> + listOf(7, 8).forEach { code -> val notFound = DashSdkError.fromNative(DashSDKException(offset + code, "missing")) assertTrue("platform-wallet code $code must be typed NotFound", notFound is DashSdkError.NotFound) assertEquals("missing", notFound.message) } + // 98 (PlatformWalletFFIResultCode::NotFound, the blanket Option → result + // miss) stays in the PlatformWallet subtree as the typed + // PlatformWallet.NotFound — parity with Swift's PlatformWalletError + // .notFound (also in the wallet-error family) — distinct from the typed + // top-level NotFound that 7/8 map to. Local reads still recognize it at + // the raw code via translateManagedIdentityNotFoundToZero (#4051) before + // this mapping runs. + val optionMiss = DashSdkError.fromNative(DashSDKException(offset + 98, "missing")) + assertTrue(optionMiss is DashSdkError.PlatformWallet.NotFound) + assertFalse(optionMiss is DashSdkError.NotFound) + assertEquals("missing", optionMiss.message) val noAnchor = DashSdkError.fromNative(DashSDKException(offset + 19, "mid-block tree")) assertTrue(noAnchor is DashSdkError.PlatformWallet.ShieldedNoRecordedAnchor) @@ -116,6 +127,60 @@ 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 platformWalletNotFoundCodeMapsToTypedWalletNotFound() { + // PlatformWalletFFIResultCode::NotFound (98) — the code the Option → + // result conversion emits for "requested not found". Maps to + // the typed PlatformWallet.NotFound (Swift parity: + // PlatformWalletError.notFound); Dashpay's managed-identity reads + // translate the raw code 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.NotFound) + } + @Test fun mapNativeErrorsConvertsAtTheBoundary() { try { @@ -138,7 +203,14 @@ class DashSdkErrorTest { } }.exceptionOrNull() - assertTrue(error is DashSdkError.NotFound) - assertEquals("wallet not found", error?.message) + // Code 98 surfaces (through the public mapNativeErrors boundary) as the + // typed PlatformWallet.NotFound — the wallet-family NotFound, exactly + // how Swift surfaces PlatformWalletError.notFound, and NOT the + // top-level NotFound reserved for rs-sdk-ffi codes 7/8 — so #4051's + // raw-code translation stays the single place that turns an + // unmanaged-identity miss into an absence. + assertTrue(error is DashSdkError.PlatformWallet.NotFound) + assertFalse(error is DashSdkError.NotFound) + assertEquals("wallet not found", (error as DashSdkError).message) } } 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 f9f9a76ff1..38553f4aad 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 @@ -1003,6 +1003,222 @@ class PlatformWalletPersistenceHandlerTest { assertEquals("privkey.deadbeef", 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") + + override fun deleteStored(pubkeyHexes: Collection) = Unit + + override fun hasStored(pubkeyHex: String): Boolean = false + } + + 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" + + override fun deleteStored(pubkeyHexes: Collection) = Unit + + override fun hasStored(pubkeyHex: String): Boolean = false + } + 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) + } + + @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()) + } + + /** + * Regression (dashpay/platform#4060, finding de3cf44a71fc): the pending + * record is staged with the round, not published mid-round — the + * watch-only row it describes is only buffered until [onChangesetEnd], + * so an aborted round (which discards that row) must leave no phantom + * pending entry behind. + */ + @Test + fun abortedRoundLeavesNoPhantomPendingKeyState() = runTest { + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + + val identityId = ByteArray(32) { 19 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 14 } + + 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, + ) + // Mid-round the record is only STAGED: the watch-only row it + // describes has not committed yet. + assertTrue(handler.pendingIdentityKeys.value.isEmpty()) + + handler.onChangesetEnd(walletId, success = false) + + // The aborted round discarded the watch-only row — its staged + // pending entry must vanish with it, not survive as a phantom. + assertTrue(handler.pendingIdentityKeys.value.isEmpty()) + assertNull(db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)) + + // The same failure in a round that COMMITS still publishes. + upsertIdentityKey(pubkey, identityId) + assertNotNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + } + + /** + * Regression (dashpay/platform#4060, finding de3cf44a71fc), the converse + * flow: an earlier watch-only key is pending; a retry round derives + * successfully (staging the clear) but then ABORTS. Rollback's alias + * cleanup deletes the newly stored scalar, so the old watch-only row — + * still the persisted truth — must keep its repair signal instead of + * losing it to a mid-round clear. + */ + @Test + fun abortedRetryRoundPreservesThePendingRepairSignal() = runTest { + var boom = true + val flaky = object : PrivateKeyDeriver { + val deletedAliases = mutableListOf() + + override fun deriveAndStore( + walletId: ByteArray, + publicKeyData: ByteArray, + identityIndex: Int, + keyIndex: Int, + ): String = + if (boom) throw IllegalStateException("transient") else "privkey.cafebabe" + + override fun deleteStored(pubkeyHexes: Collection) { + deletedAliases.addAll(pubkeyHexes) + } + + override fun hasStored(pubkeyHex: String): Boolean = false + } + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, flaky) + + val identityId = ByteArray(32) { 20 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 15 } + + // A committed failing round records the watch-only key as pending. + upsertIdentityKey(pubkey, identityId) + assertNotNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + + // The retry round derives + stores successfully (the clear is + // staged)… and then the round rolls back. + boom = false + 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 = false) + + // Rollback scrubbed the round's newly stored scalar; the old + // watch-only row is still the persisted truth, so the repair signal + // must survive the aborted round's staged clear. + assertEquals(listOf(pubkey.toHex()), flaky.deletedAliases) + assertNotNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + } + // ── 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..0b47aec739 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyTest.kt @@ -0,0 +1,106 @@ +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) + // 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 + 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. 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 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( + 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) + } +} diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeystoreKeyGenPolicyTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeystoreKeyGenPolicyTest.kt new file mode 100644 index 0000000000..a97ded40df --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeystoreKeyGenPolicyTest.kt @@ -0,0 +1,88 @@ +package org.dashfoundation.dashsdk.security + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.security.GeneralSecurityException +import java.security.ProviderException + +/** + * Pins the no-secure-lock-screen key-generation degradation + * (dashpay/platform#4060). The lock-screen-bound Keystore parameters + * (`setUnlockedDeviceRequired`, and for the auth-gated alias + * `setUserAuthenticationRequired`) require a secure lock screen; KeyMint rejects + * `generate_key` for them otherwise (observed on-device: ProviderException + * "Keystore key generation failed" / internal Keystore code 4 / KeyMint 10309), + * which used to hard-crash wallet creation on a device with no screen lock. The + * parameter-selection and failure-classification logic is factored into pure + * functions so it is unit-testable without an AndroidKeyStore runtime (which has + * no Robolectric provider — see [KeySecurityPolicyTest]). + */ +class KeystoreKeyGenPolicyTest { + + @Test + fun lockBoundParamsRequireASecureLockScreen() { + // The whole point: apply the lock-bound params only when a secure lock + // screen exists, so a lockless device generates a usable (degraded) key + // instead of failing generation. + assertTrue(KeystoreManager.lockBoundKeyParamsSupported(deviceSecure = true)) + assertFalse(KeystoreManager.lockBoundKeyParamsSupported(deviceSecure = false)) + } + + /** + * Stand-in for `android.security.KeyStoreException`, which cannot be + * constructed on the plain JVM. The classifier matches Keystore exceptions + * by type-name suffix, so any type whose simple name ends in + * `KeyStoreException` exercises the same path. + */ + private class SimulatedKeyStoreException(message: String) : + GeneralSecurityException(message) + + @Test + fun classifiesWrappedKeystoreGenerationFailure() { + // The exact on-device shape: a key-gen ProviderException wrapping a + // Keystore system error from generate_key. + val failure = ProviderException( + "Keystore key generation failed", + SimulatedKeyStoreException( + "System error (internal Keystore code: 4 message: In generate_key. 10309)", + ), + ) + assertTrue(KeystoreManager.isNoSecureLockScreenKeyGenFailure(failure)) + } + + @Test + fun classifiesDirectGenerateKeyKeystoreError() { + val failure = SimulatedKeyStoreException("Keymint error In generate_key") + assertTrue(KeystoreManager.isNoSecureLockScreenKeyGenFailure(failure)) + } + + @Test + fun classifiesExplicitLockScreenMessage() { + val failure = SimulatedKeyStoreException("Requires a secure lock screen to be set up") + assertTrue(KeystoreManager.isNoSecureLockScreenKeyGenFailure(failure)) + } + + @Test + fun doesNotClassifyUnrelatedFailures() { + // A generic runtime error is not the lock-screen signature. + assertFalse( + KeystoreManager.isNoSecureLockScreenKeyGenFailure( + IllegalStateException("some unrelated error"), + ), + ) + // A ProviderException with no keystore cause and no gen-failed message. + assertFalse( + KeystoreManager.isNoSecureLockScreenKeyGenFailure( + ProviderException("unrelated provider issue"), + ), + ) + // A Keystore error unrelated to generation / lock screen, not wrapped by + // a key-gen ProviderException, must not trigger the degraded retry. + assertFalse( + KeystoreManager.isNoSecureLockScreenKeyGenFailure( + SimulatedKeyStoreException("Signature verification failed"), + ), + ) + } +} 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..9cc18bdf9b --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt @@ -0,0 +1,443 @@ +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 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 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)) + // 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 + + // 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)) + } + + /** + * Regression (dashpay/platform#4060, finding b80a15c93339): the + * "provisioned + locked + WRONG alias" case. The current AUTH_GATED policy + * alias is provisioned from an earlier period and its auth window is closed, + * but the blob was actually written by the DEVICE_BOUND sibling. The locked + * policy alias throws UserNotAuthenticatedException at cipher.init — before + * the ciphertext is examined — so a bare catch would mis-report it + * "decryptable". The prompt-free DEVICE_BOUND sibling opens the blob, proving + * the policy alias does not own it; since retrievePrivateKey under this policy + * never falls back to the sibling, the blob is genuinely strandable and + * key-health must report it undecryptable so the repair path fires. + */ + @Test + fun provisionedLockedWrongAliasBlobDisprovedByPromptFreeSibling() = runBlocking { + fake.scheme = FakeKeystoreManager.Scheme.SIBLING_POLICY + storage.storePrivateKey(pub, secret) // sibling-written blob (TAG_SIBLING) + + fake.policyKeyProvisioned = true // AUTH_GATED alias provisioned earlier... + fake.throwAuthOnPolicyDecrypt = true // ...and its auth window is closed + fake.deviceBoundSiblingPresent = true // DEVICE_BOUND actually wrote it, key present + + assertFalse(storage.isPrivateKeyDecryptable(pub)) + } + + /** + * The disproof must NOT fire when the sibling can't open the blob: a locked + * auth-gated policy alias that legitimately owns its blob still reports + * decryptable even with an unrelated DEVICE_BOUND key present (which raises + * BadPadding on this policy-written blob, so it proves nothing). Guards + * against the b80a15c93339 fix regressing the legitimate locked-owner case. + */ + @Test + fun lockedPolicyOwnerNotDisprovedWhenSiblingCannotOpen() = runBlocking { + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + storage.storePrivateKey(pub, secret) // policy-alias-owned blob (TAG_POLICY) + + fake.throwAuthOnPolicyDecrypt = true // locked window + fake.deviceBoundSiblingPresent = true // sibling present but cannot open a TAG_POLICY blob + + assertTrue(storage.isPrivateKeyDecryptable(pub)) + } + + /** + * Regression (dashpay/platform#4060, finding 1049be675782): the legacy + * migration must not resurrect a private key a concurrent wallet deletion + * just removed. [WalletStorage.retrievePrivateKey] reads and recovers the + * former-RSA blob WITHOUT holding the private-key mutex; a removeWallet + * sweep can win `withPrivateKeyExclusion` between that read and + * `migrateToPolicyAlias`'s rewrite, delete the alias plus its owner-index + * entry, and cascade the Room rows. The rewrite must then be SKIPPED — an + * unconditional edit recreated `privkey.` as undiscoverable + * ciphertext (no owner index, no database row) behind a "successful" wipe. + */ + @Test + fun migrationDoesNotResurrectAKeyDeletedMidRecovery() = runBlocking { + fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.RSA + fake.scheme = FakeKeystoreManager.Scheme.FORMER_RSA + val owner = ByteArray(32) { 4 } + storage.storePrivateKey(pub, secret, ownerWalletId = owner) + + // The migration's policy-alias re-encrypt is the window between the + // caller's read and the conditional rewrite: model the concurrent + // deletion sweep winning it. + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + fake.onNextPolicyEncrypt = { + runBlocking { + storage.withPrivateKeyExclusion { + deletePrivateKeys(listOf(pub)) + deleteOwnerIndex(owner) + } + } + } + + // The caller still gets the value it legitimately recovered… + assertArrayEquals(secret, storage.retrievePrivateKey(pub)) + // …but the swept entry and owner index must NOT be re-created. + assertFalse(storage.hasPrivateKey(pub)) + assertTrue(storage.ownedPrivateKeyAliases(owner).isEmpty()) + assertNull(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, SIBLING_POLICY } + + 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 throwAuthOnLegacyRsaDecrypt: Boolean = false + var legacyRsaFallbackCalls: Int = 0 + + /** + * One-shot hook fired at the next policy-alias encrypt — the exact + * window between a legacy recovery's read/decrypt and + * `migrateToPolicyAlias`'s rewrite, where a concurrent wallet deletion + * can interleave (finding 1049be675782). + */ + var onNextPolicyEncrypt: (() -> Unit)? = null + + /** + * A present, non-auth-gated DEVICE_BOUND sibling key. Modelling the JCE + * invariant, it opens ONLY the sibling-written blob (TAG_SIBLING) — the one + * alias that produced it — prompt-free. + */ + var deviceBoundSiblingPresent: Boolean = false + + override val keysAlias: String get() = POLICY_ALIAS + + override fun opensUnderNonGatedDeviceBoundSibling(blob: EncryptedBlob): Boolean = + deviceBoundSiblingPresent && blob.ciphertext[0] == TAG_SIBLING + + override fun encrypt(plaintext: ByteArray, alias: String): EncryptedBlob = when (scheme) { + Scheme.CURRENT -> { + onNextPolicyEncrypt?.let { hook -> + onNextPolicyEncrypt = null + hook() + } + policyKeyProvisioned = true // public-key encrypt provisions the alias + 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) + } + + 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 + // 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") + } + + 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 + const val TAG_SIBLING: Byte = 3 + } +} 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..eca481ef3d --- /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.NotFound("…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) + } + } +} diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index 85553aa1bb..5c40361e4e 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -50,6 +50,46 @@ use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; // Managed identity lookup // --------------------------------------------------------------------------- +/// Outcome of the layered [`platform_wallet_get_managed_identity`] lookup, +/// split out so the dual-error decision (dashpay/platform#4060, findings +/// `03ff842bd7d7` / `d7c06333de19`) is unit-testable without a fully-seeded +/// `PlatformWallet` fixture. +enum ManagedIdentityOutcome { + /// A present, live wallet manages the identity. + Found(T), + /// The `wallet_handle` no longer resolves to a live, managed wallet: + /// either absent from `PLATFORM_WALLET_STORAGE` (the outer lookup miss) OR + /// present as a handle but already removed from the shared `WalletManager` + /// map (a `get_wallet_info` miss — a stale handle racing `removeWallet`, + /// which clears the manager entry *before* destroying the handle). Both are + /// real wallet failures and must surface as `ErrorInvalidHandle`, never be + /// swallowed as "identity not managed". + InvalidHandle, + /// A valid, live wallet that simply does not manage the identity — the only + /// outcome Kotlin's `translateManagedIdentityNotFoundToZero` turns into a + /// zero handle. + NotManaged, +} + +/// Classify the nested lookup `Option` layers into the FFI result contract: +/// - outer = `wallet_handle` presence in `PLATFORM_WALLET_STORAGE`, +/// - middle = `get_wallet_info` presence in the shared `WalletManager` map, +/// - inner = `managed_identity` presence on that wallet. +/// +/// The two distinct `None`-producing failures (handle absent, and wallet +/// removed from the manager map) must NOT collapse into `NotManaged` — see +/// [`ManagedIdentityOutcome`]. Pure and generic so both error arms are directly +/// unit-testable. +fn classify_managed_identity_outcome( + outcome: Option>>, +) -> ManagedIdentityOutcome { + match outcome { + None | Some(None) => ManagedIdentityOutcome::InvalidHandle, + Some(Some(None)) => ManagedIdentityOutcome::NotManaged, + Some(Some(Some(managed))) => ManagedIdentityOutcome::Found(managed), + } +} + /// Look up the live [`ManagedIdentity`](platform_wallet::ManagedIdentity) /// for `identity_id` under `wallet_handle` and return a fresh handle /// into the shared `MANAGED_IDENTITY_STORAGE`. @@ -74,13 +114,42 @@ pub unsafe extern "C" fn platform_wallet_get_managed_identity( check_ptr!(out_managed_identity_handle); let id = unwrap_result_or_return!(unsafe { read_identifier(identity_id) }); - let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + // THREE distinct outcomes must stay distinct — they must NOT collapse into + // one `NotFound` (dashpay/platform#4060, finding 03ff842bd7d7). Using `?` + // inside the closure previously folded the `get_wallet_info` miss into the + // same closure-`None` as the `managed_identity` miss, so a wallet removed + // from the manager map (but not yet handle-destroyed) reported as "identity + // not managed". `.map()` keeps the `get_wallet_info` presence in its own + // layer instead: + // outer `None` -> handle absent from PLATFORM_WALLET_STORAGE + // `Some(None)` -> handle live, wallet REMOVED from the manager map + // (a stale handle racing `removeWallet`) + // `Some(Some(None))` -> valid live wallet, identity not managed + // `Some(Some(Some(mi)))` -> found + // Kotlin's `translateManagedIdentityNotFoundToZero` (Dashpay.kt) turns ONLY + // `NotFound` into a zero handle, so the two handle-invalid cases must surface + // as `ErrorInvalidHandle` and never masquerade as an empty (zero-balance) + // unmanaged identity. See [`classify_managed_identity_outcome`]. + let outcome = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { let wm = wallet.wallet_manager().blocking_read(); - let info = wm.get_wallet_info(&wallet.wallet_id())?; - info.identity_manager.managed_identity(&id).cloned() + wm.get_wallet_info(&wallet.wallet_id()) + .map(|info| info.identity_manager.managed_identity(&id).cloned()) }); - let inner = unwrap_option_or_return!(option); - let managed = unwrap_option_or_return!(inner); + let managed = match classify_managed_identity_outcome(outcome) { + ManagedIdentityOutcome::Found(managed) => managed, + ManagedIdentityOutcome::InvalidHandle => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + format!("platform wallet handle {wallet_handle} is not backed by a live wallet"), + ); + } + ManagedIdentityOutcome::NotManaged => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + format!("wallet {wallet_handle} does not manage the requested identity"), + ); + } + }; unsafe { *out_managed_identity_handle = MANAGED_IDENTITY_STORAGE.insert(managed) }; PlatformWalletFFIResult::ok() } @@ -1245,4 +1314,74 @@ 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"); + } + + /// The dual-error decision (dashpay/platform#4060, findings 03ff842bd7d7 & + /// d7c06333de19), exercised at the `classify_managed_identity_outcome` seam + /// so BOTH error codes are covered without a fully-seeded `PlatformWallet` + /// fixture (which this unit-test module cannot build). This is exactly the + /// layer where the two `None` outcomes previously collapsed: + /// + /// - Outer `None` (handle absent from `PLATFORM_WALLET_STORAGE`) AND + /// `Some(None)` (handle live but the wallet was REMOVED from the shared + /// `WalletManager` map — the removed-but-not-destroyed race the finding + /// describes) both classify as `InvalidHandle` → `ErrorInvalidHandle`, so + /// Kotlin's `translateManagedIdentityNotFoundToZero` never swallows a real + /// wallet failure as a zero-balance unmanaged identity. + /// - `Some(Some(None))` (valid live wallet that does not manage the id) + /// classifies as `NotManaged` → `NotFound`, the only outcome Kotlin + /// translates to a zero handle. + /// - `Some(Some(Some(_)))` classifies as `Found`. + /// + /// The full FFI path for the outer-`None` arm is additionally covered by + /// `get_managed_identity_unknown_wallet_is_invalid_handle` above. + #[test] + fn classify_managed_identity_outcome_distinguishes_removed_wallet_from_unmanaged() { + // Handle absent from storage → invalid handle. + assert!(matches!( + classify_managed_identity_outcome::(None), + ManagedIdentityOutcome::InvalidHandle + )); + // Removed-but-not-destroyed: handle live, wallet gone from the manager + // map (get_wallet_info miss). Must NOT be "not managed". + assert!(matches!( + classify_managed_identity_outcome::(Some(None)), + ManagedIdentityOutcome::InvalidHandle + )); + // Valid live wallet that simply does not manage the identity. + assert!(matches!( + classify_managed_identity_outcome::(Some(Some(None))), + ManagedIdentityOutcome::NotManaged + )); + // Found. + assert!(matches!( + classify_managed_identity_outcome(Some(Some(Some(7u32)))), + ManagedIdentityOutcome::Found(7) + )); + } }