From 74db4992f2e563db4d79d837dda9a9a212ba7bef Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:04:17 -0400 Subject: [PATCH 01/26] fix(kotlin-sdk): unmanaged-identity local reads return absence, not NotFound Split out of dashpay/platform#4183 (port of #4060) per review: the unmanaged-identity read fixes are conflict-free on v4.1-dev and should not be blocked on the Keystore rework. The FFI's blanket Option -> result conversion reports an identity the wallet does not manage as PlatformWalletFFIResultCode::NotFound (98), so the Kotlin callers' zero-handle checks were dead and every local read over an unmanaged identity threw instead of returning absence. - Dashpay: route getManagedIdentity through translateManagedIdentityNotFoundToZero so contacts()/syncState()/ payments()/sendContactRequest() treat "not managed" as null/empty/ false; ErrorInvalidHandle still propagates. - Sweep the remaining dead `== 0L` sites: ManagedPlatformWallet .inMemoryIdentityStates (one unmanaged/just-removed id no longer throws through the whole listing) and IdentityRegistration .contestedDpnsNames (the intended "identity is not managed by this wallet" NotFound now actually surfaces). - platform-wallet-ffi: platform_wallet_get_managed_identity keeps the three outcomes distinct (classify_managed_identity_outcome) so a stale/removed wallet surfaces ErrorInvalidHandle and never masquerades as an unmanaged identity; unit tests pin both arms. - DashSdkError: name the code (PLATFORM_WALLET_NOT_FOUND_CODE = 98); the 98 -> DashSdkError.NotFound mapping is unchanged. Co-Authored-By: Claude Fable 5 --- .../dashsdk/errors/DashSdkError.kt | 13 +- .../dashsdk/identity/IdentityRegistration.kt | 9 +- .../dashfoundation/dashsdk/tokens/Dashpay.kt | 48 +++++- .../dashsdk/wallet/ManagedPlatformWallet.kt | 10 +- .../ManagedIdentityNotFoundTranslationTest.kt | 53 +++++++ .../rs-platform-wallet-ffi/src/dashpay.rs | 149 +++++++++++++++++- 6 files changed, 271 insertions(+), 11 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/tokens/ManagedIdentityNotFoundTranslationTest.kt diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index de41a05412..be95b26c85 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 @@ -197,6 +197,17 @@ sealed class DashSdkError( */ const val PLATFORM_WALLET_CODE_OFFSET = 1000 + /** + * `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 [NotFound]; Dashpay's managed-identity + * local reads intercept the RAW code (offset + 98) via + * `translateManagedIdentityNotFoundToZero` before [fromNative] runs + * and turn the miss into an absence (zero handle → null / empty). + */ + const val PLATFORM_WALLET_NOT_FOUND_CODE = 98 + /** Map a native error code + message into the public hierarchy. */ fun fromNative(e: DashSDKException): DashSdkError { val message = e.message ?: "Unknown SDK error" @@ -235,7 +246,7 @@ sealed class DashSdkError( 6 -> PlatformWallet.WalletOperation(message, cause) // ErrorWalletOperation 7, // ErrorIdentityNotFound 8, // ErrorContactNotFound - 98, // NotFound (Option returned as an error) + PLATFORM_WALLET_NOT_FOUND_CODE, // NotFound (Option returned as an error) -> NotFound(message, cause) 16 -> PlatformWallet.ShieldedBroadcastFailed(message, cause) // ErrorShieldedBroadcastFailed 18 -> PlatformWallet.ShieldedSpendUnconfirmed(message, cause) // ErrorShieldedSpendUnconfirmed diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt index 0aef05ce72..2f92c1497a 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt @@ -12,6 +12,7 @@ import org.dashfoundation.dashsdk.ffi.ContestedDpnsNamesNativeResult import org.dashfoundation.dashsdk.ffi.IdentityNative import org.dashfoundation.dashsdk.ffi.IdentityRegistrationNativeResult import org.dashfoundation.dashsdk.ffi.TokensNative +import org.dashfoundation.dashsdk.tokens.translateManagedIdentityNotFoundToZero import org.dashfoundation.dashsdk.wallet.TrackedAssetLock internal fun interface ResumeIdentityNativeCall { @@ -90,8 +91,14 @@ class IdentityRegistration internal constructor( val refreshedCount = mapNativeErrors { syncContestedDpnsNative.call(walletHandle, identityId) } + // The native side reports an unmanaged identity as a platform-wallet + // NotFound error, not a zero handle; translate the raw code back to + // the zero-handle signal so the intended, caller-facing message below + // is what actually surfaces (dashpay/platform#4060). val identityHandle = mapNativeErrors { - managedIdentityLookupNative.call(walletHandle, identityId) + translateManagedIdentityNotFoundToZero { + managedIdentityLookupNative.call(walletHandle, identityId) + } } if (identityHandle == 0L) { throw DashSdkError.NotFound("identity is not managed by this wallet") 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..ec8761fc89 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 + * a typed `DashSdkError.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/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index ba05a9ff7f..27c846cd3b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt @@ -6,6 +6,7 @@ import org.dashfoundation.dashsdk.errors.mapNativeErrors import org.dashfoundation.dashsdk.ffi.NativeCleaner import org.dashfoundation.dashsdk.ffi.TokensNative import org.dashfoundation.dashsdk.ffi.WalletManagerNative +import org.dashfoundation.dashsdk.tokens.translateManagedIdentityNotFoundToZero import java.util.concurrent.atomic.AtomicLong /** @@ -546,7 +547,14 @@ class ManagedPlatformWallet internal constructor( val watched = inMemoryWatchedIdentityIds().map { it to true } (managed + watched).mapNotNull { (id, isWatched) -> mapNativeErrors { - val identityHandle = TokensNative.getManagedIdentity(handle, id) + // The native side reports an unmanaged / just-removed identity + // as a platform-wallet NotFound error, not a zero handle, so + // the raw code is translated back to the zero-handle "skip" + // signal here — otherwise one removed id would throw through + // the whole listing (dashpay/platform#4060). + val identityHandle = translateManagedIdentityNotFoundToZero { + TokensNative.getManagedIdentity(handle, id) + } if (identityHandle == 0L) return@mapNativeErrors null try { val index = WalletManagerNative.managedIdentityGetIdentityIndex(identityHandle) 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..dc860ce9c5 --- /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 a typed + * `DashSdkError.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) + )); + } } From 4ecbdcb3fc082359d98c97056eb60bb1da405ec8 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:04:50 -0400 Subject: [PATCH 02/26] feat(kotlin-sdk): typed SigningKeyUnavailable for missing signing keys Split out of dashpay/platform#4183 (port of #4060) per review: the typed signing error is conflict-free on v4.1-dev and should not be blocked on the Keystore rework. The KeystoreSigner "missing key" completion error travels as free text through Rust and used to come back as an opaque PlatformWallet.Generic (or WalletOperation) failure. It is now built from a shared MESSAGE_MARKER constant and recognized on the Kotlin boundary as the typed DashSdkError.PlatformWallet.SigningKeyUnavailable, so hosts can route users to key repair instead of showing a generic error. The marker is only consulted on the catch-all codes (6 / else), so the dedicated retry-semantics types are never overridden. Known limitation (kept as-is from #4183 by request): the discriminator is message-text-based (message.contains on the marker); a structured error code across the FFI boundary is follow-up work in the parent PR line. Co-Authored-By: Claude Fable 5 --- .../dashsdk/errors/DashSdkError.kt | 52 +++++++++++++++++-- .../dashsdk/security/KeystoreSigner.kt | 8 ++- .../dashsdk/errors/DashSdkErrorTest.kt | 37 +++++++++++++ 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index be95b26c85..3637fd57a4 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,35 @@ sealed class DashSdkError( cause, ) + /** + * A state transition could not be signed because the signer has no + * usable private key for the requested public key — the stored blob + * is missing (never derived, wiped, or written under a different + * Keystore alias/policy) rather than the operation itself failing. + * + * Signing failures originate as free text in + * `KeystoreSigner.completeSign` (the "[MESSAGE_MARKER] " + * string), travel through Rust, and come back under the catch-all + * platform-wallet codes; [fromPlatformWalletNative] recognizes the + * marker on the Kotlin boundary and surfaces this typed error so + * hosts can route users to key repair (e.g. + * `PlatformWalletManager.repairIdentityKey`) instead of treating it + * as an opaque [Generic] failure. Not retryable as-is — the key must + * be (re-)derived first. + */ + class SigningKeyUnavailable(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) { + companion object { + /** + * Stable prefix of the `KeystoreSigner` "missing key" + * completion error. `KeystoreSigner` builds its message from + * this constant, so the emitter and the matcher cannot + * drift. + */ + const val MESSAGE_MARKER = "no private key stored for" + } + } + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -234,7 +263,11 @@ sealed class DashSdkError( * [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, @@ -243,7 +276,12 @@ 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 PLATFORM_WALLET_NOT_FOUND_CODE, // NotFound (Option returned as an error) @@ -256,8 +294,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/security/KeystoreSigner.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt index 7258a77247..7de0a78623 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 } 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..c097a04454 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 @@ -116,6 +116,43 @@ 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 mapNativeErrorsConvertsAtTheBoundary() { try { From f122d11a6e817e48c7dc38e1769cbe57114edcac Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:02:52 -0400 Subject: [PATCH 03/26] feat(kotlin-sdk): split identity-key Keystore aliases by security policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce KeySecurityPolicy (AUTH_GATED default / DEVICE_BOUND opt-in) and move new identity-key wrapping off the single KEYS_ALIAS onto two dedicated RSA aliases, one per policy — Keystore auth parameters are fixed at key generation, so the policies can never share an alias. The legacy KEYS_ALIAS becomes read-only: it retains whichever superseded key (auth-gated AES-GCM, or the pre-split RSA pair) an upgraded install still holds, reachable only through the decryptLegacy* migration fallbacks. The #4172 hardening survives the split alias-parameterized: write-time fingerprint capture (encryptForIdentityKeysAlias — one lookup for blob + fingerprint + producing alias), the non-generating fingerprint read, and the KeyPermanentlyInvalidatedException recovery with its generation-checked, per-alias deletion (deleteIdentityKeysAliasIfCurrentGeneration, which refuses the legacy alias by contract). WalletStorage grows the policy-taking constructor and routes identity-key writes/reads through the policy alias. Co-Authored-By: Claude Fable 5 --- .../dashsdk/security/KeySecurityPolicy.kt | 59 +++ .../dashsdk/security/KeystoreManager.kt | 444 ++++++++++++++---- .../dashsdk/security/WalletStorage.kt | 58 ++- .../dashsdk/security/KeySecurityPolicyTest.kt | 106 +++++ 4 files changed, 565 insertions(+), 102 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyTest.kt diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/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 cb3b017cec..53de6e6d85 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 @@ -5,6 +5,7 @@ import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyPermanentlyInvalidatedException import android.security.keystore.KeyProperties import android.security.keystore.StrongBoxUnavailableException +import java.security.GeneralSecurityException import java.security.KeyPair import java.security.KeyPairGenerator import java.security.KeyStore @@ -31,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 @@ -42,21 +44,67 @@ 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** alias that + * previously wrapped identity keys (first under an auth-gated AES-256-GCM + * key, later under a single RSA keypair — see the [KEYS_ALIAS] KDoc). + * Retained read-only so existing installs' blobs stay recoverable: + * [WalletStorage.retrievePrivateKey] decrypts a legacy blob with whichever + * legacy key is present and migrates the value to the policy alias. 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, +) { /** - * Encrypt [plaintext] under [alias]. - * - * [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. + * 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 [decrypt] / [encryptForIdentityKeysAlias] 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). 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; use + * [encryptForIdentityKeysAlias] instead when the write-time key + * fingerprint must be captured. The legacy [KEYS_ALIAS] is read-only by + * contract and never an encrypt target. */ - fun encrypt(plaintext: ByteArray, alias: String = MASTER_ALIAS): EncryptedBlob { - if (alias == KEYS_ALIAS) { - return encryptForKeysAlias(plaintext).blob + open fun encrypt(plaintext: ByteArray, alias: String = MASTER_ALIAS): EncryptedBlob { + if (isIdentityKeysAlias(alias)) { + return encryptForIdentityKeysAlias(alias, plaintext).blob + } + require(alias != KEYS_ALIAS) { + "the legacy identity-keys alias is read-only (migration fallback only)" } val cipher = Cipher.getInstance(AES_TRANSFORMATION) cipher.init(Cipher.ENCRYPT_MODE, secretKey(alias)) @@ -64,7 +112,8 @@ class KeystoreManager { } /** - * Encrypt identity-key material and bind its metadata to the captured + * Encrypt identity-key material under the RSA identity-keys [alias] and + * bind its metadata (key fingerprint + producing alias) to the captured * public key. Looking the alias up again after encryption could observe * a concurrent rotation and label old-key ciphertext as though the * replacement key encrypted it. @@ -72,8 +121,14 @@ class KeystoreManager { * Kept separate from [EncryptedBlob] so adding write-time metadata does * not change that public value type's Java constructor or JVM ABI. */ - internal fun encryptForKeysAlias(plaintext: ByteArray): KeysAliasEncryptedBlob { - val publicKey = keysPublicKey() + internal open fun encryptForIdentityKeysAlias( + alias: String, + plaintext: ByteArray, + ): KeysAliasEncryptedBlob { + require(isIdentityKeysAlias(alias)) { + "not an RSA identity-keys alias: $alias" + } + val publicKey = keysPublicKey(alias) val cipher = Cipher.getInstance(RSA_TRANSFORMATION) cipher.init(Cipher.ENCRYPT_MODE, publicKey, oaepSpec()) return KeysAliasEncryptedBlob( @@ -82,20 +137,25 @@ class KeystoreManager { ciphertext = cipher.doFinal(plaintext), ), keyFingerprint = fingerprint(publicKey), + alias = alias, ) } /** - * Decrypt a blob produced by [encrypt] under the same [alias]. The - * [KEYS_ALIAS] RSA private-key decrypt throws + * Decrypt a blob produced under the same [alias]. The + * [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 blobs + * are NOT handled here — [WalletStorage.retrievePrivateKey] detects them + * and routes them through [decryptLegacyKeysBlob] / + * [decryptLegacyRsaKeysBlob]. */ - 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) - val generation = keysPrivateKeyGeneration() + val generation = keysPrivateKeyGeneration(alias) try { cipher.init(Cipher.DECRYPT_MODE, generation.privateKey, oaepSpec()) } catch (e: KeyPermanentlyInvalidatedException) { @@ -103,11 +163,14 @@ class KeystoreManager { // AndroidKeyStore, so ensureKeysKeyPair would otherwise keep // returning it forever. Deletion is the re-derive boundary: // the original exception still escapes, while a later use can - // create a replacement after secure lock is re-enabled. A - // stale decryptor must not delete a replacement generated by - // another thread while it waited for the alias lock. + // create a replacement (an auth-gated key is invalidated by + // biometric/credential re-enrollment; some OEMs invalidate a + // device-bound key on secure-lock removal too — the recovery + // is harmless there). A stale decryptor must not delete a + // replacement generated by another thread while it waited for + // the alias lock. try { - deleteKeysAliasIfCurrentGeneration(generation.fingerprint) + deleteIdentityKeysAliasIfCurrentGeneration(alias, generation.fingerprint) } catch (deleteError: Exception) { // Preserve the typed signal that suppresses the biometric // retry; deletion failure remains available for diagnosis. @@ -117,6 +180,10 @@ class KeystoreManager { } return cipher.doFinal(blob.ciphertext) } + require(alias != KEYS_ALIAS) { + "the legacy identity-keys alias is decrypted only via decryptLegacyKeysBlob / " + + "decryptLegacyRsaKeysBlob" + } val cipher = Cipher.getInstance(AES_TRANSFORMATION) cipher.init( Cipher.DECRYPT_MODE, @@ -127,42 +194,170 @@ 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 /** - * SHA-256 of the current [KEYS_ALIAS] public key, hex-encoded. Callers + * 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() + + /** + * SHA-256 of the current RSA public key at [alias], hex-encoded. Callers * persist this alongside each RSA-encrypted blob and compare it back on * read: [isKeysBlobDecryptable]'s shape check alone cannot tell a blob * encrypted under the current keypair from one encrypted under a prior * keypair of the same size (e.g. after Keystore data is lost and - * [ensureKeysKeyPair] regenerates KEYS_ALIAS, or a DataStore-only backup + * [ensureKeysKeyPair] regenerates the alias, or a DataStore-only backup * restore reintroduces an old blob onto a device with a fresh key) — a * fingerprint mismatch means the blob needs to be re-derived, not read. + * GENERATING: provisions the keypair on first use; never call this on a + * read/probe path — use [keysAliasFingerprintOrNull] there. */ - fun keysAliasFingerprint(): String = - fingerprint(keysPublicKey()) + open fun keysAliasFingerprint(alias: String = keysAlias): String = + fingerprint(keysPublicKey(alias)) /** - * Like [keysAliasFingerprint] but read-only: returns `null` when - * [KEYS_ALIAS] is absent instead of generating a keypair. Capability - * probes (`canSignWith`) run this on a Rust callback thread and must not - * block or mutate Keystore state — an absent alias means any stored blob - * is already unrecoverable, so the caller treats it as not current. + * Like [keysAliasFingerprint] but read-only: returns `null` when [alias] + * is absent instead of generating a keypair. Capability probes + * (`canSignWith`) run this on a Rust callback thread and must not block + * or mutate Keystore state — an absent alias means any blob stored under + * it is already unrecoverable, so the caller treats it as not current. */ - fun keysAliasFingerprintOrNull(): String? = - androidKeyStore().getCertificate(KEYS_ALIAS)?.publicKey?.let { fingerprint(it) } + open fun keysAliasFingerprintOrNull(alias: String = keysAlias): String? = + androidKeyStore().getCertificate(alias)?.publicKey?.let { fingerprint(it) } - private fun fingerprint(publicKey: PublicKey): String = - MessageDigest.getInstance("SHA-256").digest(publicKey.encoded) - .joinToString("") { "%02x".format(it) } + /** + * 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 RSA identity-keys [alias] already holds an RSA private + * key — a NON-generating presence check, unlike [decrypt] which + * provisions 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 a 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.probeIdentityKeyRecoverability]) then reports + * the blob recoverable 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 probe + * ([WalletStorage.probeIdentityKeyRecoverability]) 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 an un-tagged 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 recoverable until the first real unlock surfaces the + * BadPadding (documented in [WalletStorage.probeIdentityKeyRecoverability]). + */ + 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( @@ -189,9 +384,17 @@ class KeystoreManager { } } + /** + * Write-time metadata of one identity-key encrypt: the blob plus the + * fingerprint of the exact public key that produced it and the alias it + * lives under — all captured in ONE alias lookup, so a concurrent + * rotation can never mislabel old-key ciphertext with a new key's + * fingerprint. + */ internal data class KeysAliasEncryptedBlob( val blob: EncryptedBlob, val keyFingerprint: String, + val alias: String, ) private data class KeysAliasPrivateKeyGeneration( @@ -236,15 +439,18 @@ class KeystoreManager { } } - // ── KEYS_ALIAS: RSA-2048 OAEP keypair (identity private keys) ── - // Public key encrypts (never auth-gated → unprompted writes); private key - // decrypts under user auth within AUTH_VALIDITY_SECONDS (signing prompts). + // ── Identity-keys aliases: RSA-2048 OAEP keypair per alias ── + // Public key encrypts (never auth-gated → unprompted writes); the + // KEYS_ALIAS_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 keysPrivateKeyGeneration(): KeysAliasPrivateKeyGeneration { - val keyPair = ensureKeysKeyPair() + private fun keysPrivateKeyGeneration(alias: String): KeysAliasPrivateKeyGeneration { + val keyPair = ensureKeysKeyPair(alias) return KeysAliasPrivateKeyGeneration( privateKey = keyPair.private, fingerprint = fingerprint(keyPair.public), @@ -252,25 +458,37 @@ class KeystoreManager { } /** - * Drop an invalidated generation only while it is still the current one. - * A stale decryptor can reach cleanup after another thread has already - * replaced the alias and encrypted data under it; deleting by alias alone - * would orphan that newly written ciphertext. + * Drop an invalidated generation of the RSA identity-keys [alias] only + * while it is still the current one. A stale decryptor can reach cleanup + * after another thread has already replaced the alias and encrypted data + * under it; deleting by alias alone would orphan that newly written + * ciphertext. Operates on the RSA policy aliases ONLY — the legacy + * [KEYS_ALIAS] is read-only by contract and never deleted here. */ - internal fun deleteKeysAliasIfCurrentGeneration(expectedFingerprint: String): Boolean = - synchronized(KEYS_ALIAS_LOCK) { + internal open fun deleteIdentityKeysAliasIfCurrentGeneration( + alias: String, + expectedFingerprint: String, + ): Boolean { + require(isIdentityKeysAlias(alias)) { + "refusing to delete non-policy alias: $alias" + } + return synchronized(KEYS_ALIAS_LOCK) { val keyStore = androidKeyStore() - val currentPublicKey = keyStore.getCertificate(KEYS_ALIAS)?.publicKey + val currentPublicKey = keyStore.getCertificate(alias)?.publicKey ?: return@synchronized false if (fingerprint(currentPublicKey) != expectedFingerprint) { return@synchronized false } - keyStore.deleteEntry(KEYS_ALIAS) + keyStore.deleteEntry(alias) true } + } /** - * 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]) @@ -282,22 +500,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 retained legacy key that still wraps + // existing installs' identity-key blobs is never deleted here. + runCatching { keyStore.deleteEntry(alias) } fun spec(strongBox: Boolean): KeyGenParameterSpec { val builder = KeyGenParameterSpec.Builder( - KEYS_ALIAS, + alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, ) .setKeySize(RSA_KEY_SIZE) @@ -308,20 +530,24 @@ class KeystoreManager { .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA1) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) .setUnlockedDeviceRequired(true) - .setUserAuthenticationRequired(true) - // setUserAuthenticationParameters is API 30+ (Android 11); on the - // minSdk-29 (Android 10) floor fall back to the deprecated pre-30 - // time-bound API. Pre-30 the key accepts any enrolled authenticator - // for the window; the STRONG|DEVICE_CREDENTIAL restriction (and the - // AuthPrompt that requests it) still applies on 30+. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - builder.setUserAuthenticationParameters( - AUTH_VALIDITY_SECONDS, - KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL, - ) - } else { - @Suppress("DEPRECATION") - builder.setUserAuthenticationValidityDurationSeconds(AUTH_VALIDITY_SECONDS) + if (authGated) { + builder.setUserAuthenticationRequired(true) + // setUserAuthenticationParameters is API 30+ (Android 11); on + // the minSdk-29 (Android 10) floor fall back to the deprecated + // pre-30 time-bound API. Pre-30 the key accepts any enrolled + // authenticator for the window; the STRONG|DEVICE_CREDENTIAL + // restriction (and the AuthPrompt that requests it) still + // applies on 30+. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setUserAuthenticationParameters( + AUTH_VALIDITY_SECONDS, + KeyProperties.AUTH_BIOMETRIC_STRONG or + KeyProperties.AUTH_DEVICE_CREDENTIAL, + ) + } else { + @Suppress("DEPRECATION") + builder.setUserAuthenticationValidityDurationSeconds(AUTH_VALIDITY_SECONDS) + } } if (strongBox) builder.setIsStrongBoxBacked(true) return builder.build() @@ -340,6 +566,10 @@ class KeystoreManager { } } + private fun fingerprint(publicKey: PublicKey): String = + MessageDigest.getInstance("SHA-256").digest(publicKey.encoded) + .joinToString("") { "%02x".format(it) } + // 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 @@ -351,13 +581,59 @@ 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 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] 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 + // Guards first-use creation/migration of the process-global - // KEYS_ALIAS entry across concurrent callers (and across the + // identity-keys entries across concurrent callers (and across the // per-WalletStorage KeystoreManager instances). private val KEYS_ALIAS_LOCK = Any() diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt index f6e9fd96ed..521961a920 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 @@ -25,21 +25,41 @@ 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]) * * Consuming apps should exclude this DataStore from Android's default app-data * backup — Keystore keys are device-bound and never restored, so a backed-up * blob can never be decrypted on the new device. See * `res/xml/dash_sdk_backup_rules.xml` and `res/xml/dash_sdk_data_extraction_rules.xml` * for ready-made exclusion rules and the manifest snippet to reference them. + * + * The identity-key security policy is fixed by the [keystore] this storage + * wraps; use the policy-taking constructor to opt into + * [KeySecurityPolicy.DEVICE_BOUND] (see [KeySecurityPolicy] for the + * semantics and the stability requirement). The default is the historical + * [KeySecurityPolicy.AUTH_GATED] behavior, unchanged. */ class WalletStorage( context: Context, private val keystore: KeystoreManager = KeystoreManager(), ) { + /** + * Construct with an explicit identity-key [keySecurityPolicy] — + * convenience for host apps that don't otherwise need to touch + * [KeystoreManager]. `WalletStorage(context)` keeps the + * [KeySecurityPolicy.AUTH_GATED] default. + */ + constructor(context: Context, keySecurityPolicy: KeySecurityPolicy) : + this(context, KeystoreManager(keySecurityPolicy)) + private val store = context.secretsStore + /** The identity-key security policy this storage was constructed with. */ + val keySecurityPolicy: KeySecurityPolicy get() = keystore.keySecurityPolicy + /** * Serializes every `privkey.*` alias mutation. A single DataStore * `edit` is already atomic, but compound sequences (wallet deletion's @@ -208,14 +228,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 @@ -364,7 +385,7 @@ class WalletStorage( privateKey: ByteArray, ownerWalletId: ByteArray?, ) { - val encrypted = keystore.encryptForKeysAlias(privateKey) + val encrypted = keystore.encryptForIdentityKeysAlias(keystore.keysAlias, privateKey) val blob = encrypted.blob val fingerprint = encrypted.keyFingerprint store.edit { @@ -379,7 +400,7 @@ class WalletStorage( /** * Whether the stored blob for [pubkeyHex] is both structurally an RSA - * blob and was encrypted under the [KeystoreManager.KEYS_ALIAS] keypair + * blob and was encrypted under the [KeystoreManager.keysAlias] keypair * currently in the Keystore — see [KeystoreManager.keysAliasFingerprint]. * A missing fingerprint (written before this check existed) is treated * as unusable rather than trusted, since a stale RSA-shaped blob is @@ -396,7 +417,7 @@ class WalletStorage( ): Boolean { if (!keystore.isKeysBlobDecryptable(decode(encoded))) return false val fingerprint = prefs[privateKeyFingerprintKey(pubkeyHex)] ?: return false - val current = keystore.keysAliasFingerprintOrNull() ?: return false + val current = keystore.keysAliasFingerprintOrNull(keystore.keysAlias) ?: return false return fingerprint == current } @@ -411,22 +432,23 @@ class WalletStorage( /** * Decrypt the private key for [pubkeyHex]. A stable blob whose stored - * key fingerprint no longer matches the current [KeystoreManager.KEYS_ALIAS] + * key fingerprint no longer matches the current [KeystoreManager.keysAlias] * returns `null`, allowing the caller to re-derive it instead of trying * OAEP with an unrelated replacement key. Rotation can still occur * between this check and decrypt; that race deliberately remains a * fail-closed crypto exception rather than returning stale plaintext. * - * Throws + * Under [KeySecurityPolicy.AUTH_GATED] this throws * `UserNotAuthenticatedException` when the auth window expired — the - * caller (KeystoreSigner) routes through [BiometricGate] and retries. + * caller (KeystoreSigner) routes through [BiometricGate] and retries; + * under [KeySecurityPolicy.DEVICE_BOUND] it never auth-gates. * Callers must zero the returned array after use. */ suspend fun retrievePrivateKey(pubkeyHex: String): ByteArray? { val prefs = store.data.first() val encoded = prefs[privateKeyKey(pubkeyHex)] ?: return null if (!isCurrentKeysBlob(pubkeyHex, encoded, prefs)) return null - return keystore.decrypt(decode(encoded), alias = KeystoreManager.KEYS_ALIAS) + return keystore.decrypt(decode(encoded), alias = keystore.keysAlias) } suspend fun deletePrivateKey(pubkeyHex: String) { @@ -478,7 +500,7 @@ class WalletStorage( /** * Whether the blob stored for [pubkeyHex] is decryptable under the - * current [KeystoreManager.KEYS_ALIAS] RSA scheme. Blobs written by the + * current [KeystoreManager.keysAlias] RSA scheme. Blobs written by the * pre-RSA AES-GCM scheme survive in the DataStore but lost their key * when the RSA pair replaced it, so signing with them can only fail — * key-health treats them as missing and offers a re-derive. Read-only: it 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) + } +} From 0c6306c9323f9b2c8d062d9da6529f585185238b Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:09:59 -0400 Subject: [PATCH 04/26] feat(kotlin-sdk): degrade lockless AUTH_GATED writes to the device-bound alias, honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet must work without a screen lock (dashpay/platform#4060), but the AUTH_GATED authentication gate cannot exist without one — KeyMint rejects generate_key. Instead of silently generating a gate-less key under the auth-gated alias (a policy lie), identity-key writes on a lockless device are redirected to KEYS_ALIAS_DEVICE_BOUND, whose gate-less parameters are inherent: - encryptForIdentityKeys resolves the effective write alias (probe first, KeyMint safety-net rejection second) and returns the producing alias with the blob; WalletStorage records it as a per-blob privkeyalias. tag written atomically with the blob + fingerprint, and reads decrypt under the RECORDED alias — keys written while lockless stay readable after a lock screen is enrolled and new writes move to the gated alias. A missing tag means the policy alias (backward compatible with earlier data). - effectiveKeySecurityPolicy() (KeystoreManager + WalletStorage) surfaces the degradation; PlatformWalletManager logs it once at construction. - Strict mode: KeystoreManager(requireAuthGated = true) throws the new KeySecurityPolicyUnavailableException instead of degrading. - MASTER_ALIAS (AES) and the DEVICE_BOUND alias keep the in-place setUnlockedDeviceRequired degradation (no auth gate to lie about); the auth-gated alias is never routed through it. - isNoSecureLockScreenKeyGenFailure is tightened: a nested KeyStoreException classifies only inside the key-gen ProviderException's cause chain AND with a lock-screen message or the observed rejection's numeric code (internal Keystore 4 / KeyMint 10309, read reflectively) — a bare nested Keystore error with an unrelated message no longer triggers the retry. Co-Authored-By: Claude Fable 5 --- .../dashsdk/security/KeySecurityPolicy.kt | 16 + .../KeySecurityPolicyUnavailableException.kt | 19 + .../dashsdk/security/KeystoreManager.kt | 330 +++++++++++++++++- .../dashsdk/security/WalletStorage.kt | 87 ++++- .../dashsdk/wallet/PlatformWalletManager.kt | 18 + .../dashsdk/security/KeySecurityPolicyTest.kt | 62 ++++ .../security/KeystoreKeyGenPolicyTest.kt | 156 +++++++++ 7 files changed, 667 insertions(+), 21 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyUnavailableException.kt create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeystoreKeyGenPolicyTest.kt diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt index b4c3c7edd9..691d01371e 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt @@ -43,6 +43,22 @@ package org.dashfoundation.dashsdk.security * `PlatformWalletManager.repairIdentityKey`, which re-encrypts under the * current policy's alias). Mnemonics ([KeystoreManager.MASTER_ALIAS]) are * unaffected — this policy governs identity keys only. + * + * ## Lockless-device degradation (dashpay/platform#4060) + * + * [AUTH_GATED]'s authentication gate requires a secure lock screen to exist + * — Android KeyMint rejects generating the gated key otherwise. The wallet + * must still work without a screen lock (product decision), so on a + * lockless device the SDK does NOT silently generate a gate-less key under + * the auth-gated alias; new identity keys are written under the + * [DEVICE_BOUND] alias instead, and the degradation is surfaced honestly + * via [KeystoreManager.effectiveKeySecurityPolicy] / + * [WalletStorage.effectiveKeySecurityPolicy]. Each blob records the alias + * that produced it, so keys written during a lockless period remain + * readable after a lock screen is later enrolled (new writes then move to + * the gated alias). Hosts that must never degrade can construct + * `KeystoreManager(requireAuthGated = true)`, which throws + * [KeySecurityPolicyUnavailableException] instead of degrading. */ enum class KeySecurityPolicy { /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyUnavailableException.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyUnavailableException.kt new file mode 100644 index 0000000000..68a5f961f9 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyUnavailableException.kt @@ -0,0 +1,19 @@ +package org.dashfoundation.dashsdk.security + +/** + * Thrown (Kotlin-side, before any FFI call) by identity-key writes when the + * requested [KeySecurityPolicy.AUTH_GATED] cannot be provisioned with its + * authentication gate — the device has no secure lock screen — and the host + * opted into strict mode (`KeystoreManager(requireAuthGated = true)`), + * refusing the [KeySecurityPolicy.DEVICE_BOUND] fallback. + * + * The default (non-strict) behavior instead degrades honestly: new keys are + * written under [KeystoreManager.KEYS_ALIAS_DEVICE_BOUND] and + * [KeystoreManager.effectiveKeySecurityPolicy] reports + * [KeySecurityPolicy.DEVICE_BOUND] — the wallet must work without a screen + * lock (product decision, dashpay/platform#4060). + */ +class KeySecurityPolicyUnavailableException( + message: String, + cause: Throwable? = null, +) : IllegalStateException(message, cause) 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 53de6e6d85..9f47087290 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 @@ -5,12 +5,14 @@ import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyPermanentlyInvalidatedException 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.MessageDigest import java.security.PrivateKey +import java.security.ProviderException import java.security.PublicKey import java.security.spec.MGF1ParameterSpec import javax.crypto.Cipher @@ -74,6 +76,27 @@ import javax.crypto.spec.PSource // 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 + * [effectiveKeySecurityPolicy] and [generateWithLockScreenDegradation] + * (dashpay/platform#4060, no-secure-lock-screen key-gen failure). + */ + private val deviceSecureProbe: () -> Boolean = { true }, + /** + * Strict mode for hosts that must never degrade below + * [KeySecurityPolicy.AUTH_GATED]: when true and the auth-gated alias + * cannot be provisioned with its authentication gate (no secure lock + * screen), identity-key writes throw + * [KeySecurityPolicyUnavailableException] instead of selecting the + * [KEYS_ALIAS_DEVICE_BOUND] fallback. Default false — the wallet must + * work without a screen lock (product decision, dashpay/platform#4060), + * with the degradation surfaced via [effectiveKeySecurityPolicy]. + */ + private val requireAuthGated: Boolean = false, ) { /** @@ -89,6 +112,102 @@ open class KeystoreManager( KeySecurityPolicy.DEVICE_BOUND -> KEYS_ALIAS_DEVICE_BOUND } + /** + * The [KeySecurityPolicy] identity keys are EFFECTIVELY protected with + * right now — [KeySecurityPolicy.DEVICE_BOUND] while a requested + * [KeySecurityPolicy.AUTH_GATED] cannot be (or was not) provisioned with + * its authentication gate, i.e. on a device with no secure lock screen + * (dashpay/platform#4060). The manager never silently generates a + * gate-less key under the auth-gated alias: on a lockless device new + * identity keys are written under [KEYS_ALIAS_DEVICE_BOUND] instead (see + * [encryptForIdentityKeys]), and this surface reports that honestly so + * hosts can display / log the actual protection level. + * + * Non-mutating: presence checks and the lock-screen probe only — never + * generates a key. Once the auth-gated alias has been provisioned (with + * its gate — the only way it is ever created), the effective policy is + * [KeySecurityPolicy.AUTH_GATED] regardless of later lock-screen churn: + * the gate is baked into the existing key. + */ + open fun effectiveKeySecurityPolicy(): KeySecurityPolicy = when { + keySecurityPolicy == KeySecurityPolicy.DEVICE_BOUND -> KeySecurityPolicy.DEVICE_BOUND + hasIdentityKeysKey(KEYS_ALIAS_AUTH_GATED) -> KeySecurityPolicy.AUTH_GATED + !deviceSecureProbe() -> KeySecurityPolicy.DEVICE_BOUND + else -> KeySecurityPolicy.AUTH_GATED + } + + /** + * Encrypt identity-key material under the EFFECTIVE write alias — the + * policy alias ([keysAlias]) normally, or [KEYS_ALIAS_DEVICE_BOUND] when + * the requested [KeySecurityPolicy.AUTH_GATED] key cannot be provisioned + * with its authentication gate (no secure lock screen — the KeyMint + * `generate_key` rejection, dashpay/platform#4060). The returned + * [KeysAliasEncryptedBlob.alias] records which alias actually produced + * the blob, so [WalletStorage] persists it and later reads decrypt under + * the recorded alias — an AUTH_GATED-degraded blob stays readable after + * a lock screen is enrolled and new writes move to the gated alias. + * + * With `requireAuthGated = true` the degradation throws + * [KeySecurityPolicyUnavailableException] instead. + */ + internal open fun encryptForIdentityKeys(plaintext: ByteArray): KeysAliasEncryptedBlob = + encryptForIdentityKeysAlias(resolveIdentityKeysWriteAlias(), plaintext) + + /** + * Resolve the alias a new identity-key write must target, provisioning + * the auth-gated keypair when needed. The auth-gated alias is NEVER + * generated without its gate — on a lockless device (probe, or the + * KeyMint safety-net rejection during generation) the write is + * redirected to [KEYS_ALIAS_DEVICE_BOUND], whose gate-less parameters + * are inherent rather than a silent downgrade. The redirect is decided + * at most once per call and only when the requested spec actually + * carried the lock-bound parameters (the auth-gated alias always does). + */ + private fun resolveIdentityKeysWriteAlias(): String { + if (keySecurityPolicy == KeySecurityPolicy.DEVICE_BOUND) return KEYS_ALIAS_DEVICE_BOUND + if (hasIdentityKeysKey(KEYS_ALIAS_AUTH_GATED)) return KEYS_ALIAS_AUTH_GATED + if (!deviceSecureProbe()) { + return degradeAuthGatedWrite( + "no secure lock screen (KeyguardManager.isDeviceSecure=false)", + cause = null, + ) + } + return try { + // Provision the gated keypair up front so a mid-generation + // KeyMint rejection (lock removed after the probe, or an OEM + // quirk) is observed HERE and redirected, instead of surfacing + // as an unclassified write failure. + ensureKeysKeyPair(KEYS_ALIAS_AUTH_GATED) + KEYS_ALIAS_AUTH_GATED + } catch (e: ProviderException) { + if (!isNoSecureLockScreenKeyGenFailure(e)) throw e + degradeAuthGatedWrite( + "generation was rejected for requiring a secure lock screen even though " + + "the device reported secure (lock removed mid-flight, or an OEM quirk)", + cause = e, + ) + } + } + + private fun degradeAuthGatedWrite(reason: String, cause: Exception?): String { + if (requireAuthGated) { + throw KeySecurityPolicyUnavailableException( + "KeySecurityPolicy.AUTH_GATED is unavailable: $reason " + + "(requireAuthGated=true refuses the DEVICE_BOUND fallback)", + cause, + ) + } + Log.w( + TAG, + "AUTH_GATED identity-key alias cannot carry its authentication gate — $reason; " + + "writing under '$KEYS_ALIAS_DEVICE_BOUND' instead so the wallet works without " + + "a screen lock (dashpay/platform#4060). effectiveKeySecurityPolicy() reports " + + "the degradation.", + cause, + ) + return KEYS_ALIAS_DEVICE_BOUND + } + /** * Encrypt [plaintext] under [alias]; returns (iv, ciphertext). * [MASTER_ALIAS] uses AES-256-GCM (iv is the GCM nonce). The RSA @@ -413,7 +532,7 @@ open 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, @@ -421,7 +540,12 @@ open 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() } @@ -430,15 +554,77 @@ open 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() } } + /** + * Run [generate] — which builds+initializes the spec for `(strongBox, + * lockBound)` and produces the key — degrading gracefully when the device + * has no secure lock screen. Used for [MASTER_ALIAS] (AES) and + * [KEYS_ALIAS_DEVICE_BOUND] (RSA) only: dropping `setUnlockedDeviceRequired` + * on a lockless device is inherent (there is no lock to bind to) and does + * not change the key's authentication semantics. The AUTH_GATED alias is + * NEVER routed through here — dropping its `setUserAuthenticationRequired` + * gate would silently lie about the policy, so lockless AUTH_GATED writes + * are redirected to the DEVICE_BOUND alias instead (see + * [resolveIdentityKeysWriteAlias], dashpay/platform#4060). + * + * THE APP MUST WORK WITHOUT A SCREEN LOCK (product decision, + * dashpay/platform#4060). 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. The + * retry fires only when the failed spec actually carried lock-bound + * params. + * 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) + } + } + // ── 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 @@ -517,7 +703,7 @@ open class KeystoreManager( // 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( alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, @@ -529,7 +715,11 @@ open 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) + // 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. + if (lockBound) builder.setUnlockedDeviceRequired(true) if (authGated) { builder.setUserAuthenticationRequired(true) // setUserAuthenticationParameters is API 30+ (Android 11); on @@ -557,12 +747,27 @@ open class KeystoreManager( KeyProperties.KEY_ALGORITHM_RSA, ANDROID_KEYSTORE, ) - try { - generator.initialize(spec(strongBox = true)) - generator.generateKeyPair() - } catch (_: StrongBoxUnavailableException) { - generator.initialize(spec(strongBox = false)) - generator.generateKeyPair() + if (authGated) { + // The authentication gate is NEVER dropped: a lockless device must + // not receive a silently degraded key under the auth-gated alias. + // A KeyMint no-secure-lock-screen rejection propagates to + // [resolveIdentityKeysWriteAlias], which redirects the write to + // the DEVICE_BOUND alias instead (dashpay/platform#4060). + try { + generator.initialize(spec(strongBox = true, lockBound = true)) + generator.generateKeyPair() + } catch (_: StrongBoxUnavailableException) { + generator.initialize(spec(strongBox = false, lockBound = true)) + generator.generateKeyPair() + } + } else { + // DEVICE_BOUND: no auth gate exists to lie about — dropping the + // (inherently lock-dependent) setUnlockedDeviceRequired bit on a + // lockless device is the documented degradation. + generateWithLockScreenDegradation(alias) { strongBox, lockBound -> + generator.initialize(spec(strongBox, lockBound)) + generator.generateKeyPair() + } } } @@ -632,6 +837,101 @@ open class KeystoreManager( 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: an `android.security` `KeyStoreException` + * that either NAMES the failure (message references `generate_key` or a + * lock-screen requirement) or sits in the cause chain OF a + * key-generation `ProviderException` (message "Keystore key generation + * failed") AND carries the observed KeyMint rejection's numeric code + * (internal Keystore code 4 / KeyMint 10309, observed on-device after + * the lock screen was removed). + * + * Deliberately narrow: a bare nested `KeyStoreException` with an + * unrelated message (e.g. a signature failure that happens to be + * wrapped by a key-gen ProviderException) does NOT classify — the + * consideration window opens only at the matched ProviderException and + * still requires a message or numeric-code match, so unrelated + * Keystore failures never trigger the degraded retry. Used only as the + * retry/redirect decision for [generateWithLockScreenDegradation] and + * [resolveIdentityKeysWriteAlias]. Pure and JVM-testable — matches by + * exception type name, message, and (reflectively read) + * `getNumericErrorCode()`, so it needs no Android classes + * (dashpay/platform#4060). + */ + internal fun isNoSecureLockScreenKeyGenFailure(t: Throwable): Boolean { + var cur: Throwable? = t + while (cur != null) { + val name = cur::class.java.name + val msg = cur.message.orEmpty() + if (name.endsWith("KeyStoreException") && keyStoreMessageNamesLockScreen(msg)) { + return true + } + if (name.endsWith("ProviderException") && + msg.contains("key generation failed", ignoreCase = true) + ) { + // Inspect ONLY this ProviderException's cause chain: a + // KeyStoreException here is a generation-time Keystore + // error, but still must carry the lock-screen signature + // (message or numeric code) to classify. + var nested: Throwable? = cur.cause + while (nested != null) { + if (nested::class.java.name.endsWith("KeyStoreException")) { + val nestedMsg = nested.message.orEmpty() + if (keyStoreMessageNamesLockScreen(nestedMsg) || + keyStoreNumericCodeIsLockScreenRejection(nested) + ) { + return true + } + } + nested = nested.cause + } + return false + } + cur = cur.cause + } + return false + } + + private fun keyStoreMessageNamesLockScreen(msg: String): Boolean = + msg.contains("generate_key", ignoreCase = true) || + msg.contains("lock screen", ignoreCase = true) + + /** + * Reflectively read `getNumericErrorCode()` (API 33+ on the real + * `android.security.KeyStoreException`; any test double may declare + * the same method) and compare against the observed KeyMint + * no-secure-lock-screen rejection codes. Reflection keeps the helper + * pure-JVM; absence of the method simply means "no numeric evidence". + */ + private fun keyStoreNumericCodeIsLockScreenRejection(t: Throwable): Boolean = try { + val code = t.javaClass.getMethod("getNumericErrorCode").invoke(t) as? Int + code != null && code in LOCK_SCREEN_KEYGEN_REJECTION_CODES + } catch (_: Exception) { + false + } + + /** + * Numeric codes of the KeyMint "generate_key needs a secure lock + * screen" rejection: internal Keystore code 4 and KeyMint 10309 + * (both observed on-device, dashpay/platform#4060). + */ + private val LOCK_SCREEN_KEYGEN_REJECTION_CODES = setOf(4, 10309) + + private const val TAG = "KeystoreManager" + // Guards first-use creation/migration of the process-global // identity-keys entries across concurrent callers (and across the // per-WalletStorage KeystoreManager instances). 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 521961a920..051683805a 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,5 +1,6 @@ package org.dashfoundation.dashsdk.security +import android.app.KeyguardManager import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences @@ -44,7 +45,8 @@ private val Context.secretsStore: DataStore by preferencesDataStore */ class WalletStorage( context: Context, - private val keystore: KeystoreManager = KeystoreManager(), + private val keystore: KeystoreManager = + KeystoreManager(deviceSecureProbe = deviceSecureProbe(context)), ) { /** * Construct with an explicit identity-key [keySecurityPolicy] — @@ -53,13 +55,24 @@ class WalletStorage( * [KeySecurityPolicy.AUTH_GATED] default. */ constructor(context: Context, keySecurityPolicy: KeySecurityPolicy) : - this(context, KeystoreManager(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 + /** + * The [KeySecurityPolicy] identity keys are EFFECTIVELY protected with + * right now — [KeySecurityPolicy.DEVICE_BOUND] while a requested + * [KeySecurityPolicy.AUTH_GATED] key cannot be (or was not) provisioned + * with its authentication gate, i.e. on a device with no secure lock + * screen. See [KeystoreManager.effectiveKeySecurityPolicy] + * (dashpay/platform#4060). + */ + fun effectiveKeySecurityPolicy(): KeySecurityPolicy = + keystore.effectiveKeySecurityPolicy() + /** * Serializes every `privkey.*` alias mutation. A single DataStore * `edit` is already atomic, but compound sequences (wallet deletion's @@ -379,18 +392,27 @@ class WalletStorage( return true } - /** Encrypt-and-write [privateKey] for [pubkeyHex]; lock must already be held. */ + /** + * Encrypt-and-write [privateKey] for [pubkeyHex]; lock must already be + * held. The encrypt resolves the EFFECTIVE write alias (the policy alias, + * or its lockless DEVICE_BOUND degradation — see + * [KeystoreManager.encryptForIdentityKeys]); blob, write-time key + * fingerprint, and the producing-alias tag land in ONE atomic edit so + * reads can route the decrypt to the exact alias that wrote the blob. + */ private suspend fun storePrivateKeyEntryLocked( pubkeyHex: String, privateKey: ByteArray, ownerWalletId: ByteArray?, ) { - val encrypted = keystore.encryptForIdentityKeysAlias(keystore.keysAlias, privateKey) + val encrypted = keystore.encryptForIdentityKeys(privateKey) val blob = encrypted.blob val fingerprint = encrypted.keyFingerprint + val alias = encrypted.alias store.edit { it[privateKeyKey(pubkeyHex)] = encode(blob) it[privateKeyFingerprintKey(pubkeyHex)] = fingerprint + it[privateKeyAliasKey(pubkeyHex)] = alias if (ownerWalletId != null) { val indexKey = ownerIndexKey(ownerWalletId.toHex()) it[indexKey] = (it[indexKey] ?: emptySet()) + pubkeyHex.lowercase() @@ -398,6 +420,23 @@ class WalletStorage( } } + /** + * The RSA identity-keys alias recorded as having written [pubkeyHex]'s + * blob (`privkeyalias.`), falling back to the current policy + * alias when the tag is missing (blobs written before the tag existed — + * backward compatible) or names something that is not a policy alias + * (never trusted: only the two RSA policy aliases are ever accepted as + * decrypt targets). + */ + private fun recordedKeysAliasFor(pubkeyHex: String, prefs: Preferences): String { + val tagged = prefs[privateKeyAliasKey(pubkeyHex)] + return if (tagged != null && KeystoreManager.isIdentityKeysAlias(tagged)) { + tagged + } else { + keystore.keysAlias + } + } + /** * Whether the stored blob for [pubkeyHex] is both structurally an RSA * blob and was encrypted under the [KeystoreManager.keysAlias] keypair @@ -417,7 +456,8 @@ class WalletStorage( ): Boolean { if (!keystore.isKeysBlobDecryptable(decode(encoded))) return false val fingerprint = prefs[privateKeyFingerprintKey(pubkeyHex)] ?: return false - val current = keystore.keysAliasFingerprintOrNull(keystore.keysAlias) ?: return false + val alias = recordedKeysAliasFor(pubkeyHex, prefs) + val current = keystore.keysAliasFingerprintOrNull(alias) ?: return false return fingerprint == current } @@ -448,7 +488,10 @@ class WalletStorage( val prefs = store.data.first() val encoded = prefs[privateKeyKey(pubkeyHex)] ?: return null if (!isCurrentKeysBlob(pubkeyHex, encoded, prefs)) return null - return keystore.decrypt(decode(encoded), alias = keystore.keysAlias) + // Decrypt under the RECORDED alias: a blob written during a lockless + // AUTH_GATED→DEVICE_BOUND degradation stays readable after a lock + // screen is enrolled and new writes move to the gated alias. + return keystore.decrypt(decode(encoded), alias = recordedKeysAliasFor(pubkeyHex, prefs)) } suspend fun deletePrivateKey(pubkeyHex: String) { @@ -456,6 +499,7 @@ class WalletStorage( store.edit { it.remove(privateKeyKey(pubkeyHex)) it.remove(privateKeyFingerprintKey(pubkeyHex)) + it.remove(privateKeyAliasKey(pubkeyHex)) } } } @@ -478,6 +522,7 @@ class WalletStorage( for (pubkeyHex in normalized) { prefs.remove(privateKeyKey(pubkeyHex)) prefs.remove(privateKeyFingerprintKey(pubkeyHex)) + prefs.remove(privateKeyAliasKey(pubkeyHex)) } // Keep every owner index accurate in the same atomic commit: // a deleted alias must leave all wallets' index sets, or a @@ -535,6 +580,9 @@ class WalletStorage( private fun privateKeyFingerprintKey(pubkeyHex: String) = stringPreferencesKey(PRIVKEY_FINGERPRINT_PREFIX + pubkeyHex.lowercase()) + private fun privateKeyAliasKey(pubkeyHex: String) = + stringPreferencesKey(PRIVKEY_ALIAS_PREFIX + pubkeyHex.lowercase()) + private fun ownerIndexKey(walletIdHex: String) = stringSetPreferencesKey(PRIVKEY_OWNERS_PREFIX + walletIdHex.lowercase()) @@ -551,9 +599,36 @@ class WalletStorage( /** Per-alias [KeystoreManager.keysAliasFingerprint] snapshot, taken at write time. */ const val PRIVKEY_FINGERPRINT_PREFIX = "privkeyfp." + /** + * Per-blob record of the RSA identity-keys alias that produced the + * ciphertext (`privkeyalias.`), written atomically with the + * blob. Routes reads to the exact producing alias after a lockless + * AUTH_GATED→DEVICE_BOUND write degradation (dashpay/platform#4060); + * a missing tag means "the current policy alias" (blobs written + * before the tag existed). + */ + const val PRIVKEY_ALIAS_PREFIX = "privkeyalias." + /** Durable wallet → alias-hex-set owner index (string-set entries). */ 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 degrade 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/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 48eca431f6..84745fc9e4 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 @@ -180,6 +180,24 @@ class PlatformWalletManager( require(network == sdk.network) { "PlatformWalletManager is network-locked: network=$network but sdk.network=${sdk.network}" } + // One-time honesty log for the lockless-device identity-key policy + // degradation (dashpay/platform#4060): if the requested AUTH_GATED + // policy is effectively DEVICE_BOUND (no secure lock screen), say so + // once, loudly, at manager construction. Best-effort — the probe + // touches KeyguardManager/AndroidKeyStore, which may be absent in + // JVM test fixtures. + runCatching { + val requested = walletStorage.keySecurityPolicy + val effective = walletStorage.effectiveKeySecurityPolicy() + if (effective != requested) { + android.util.Log.w( + "PlatformWalletManager", + "identity-key security policy degraded: requested=$requested " + + "effective=$effective (no secure lock screen; new keys use the " + + "device-bound alias — dashpay/platform#4060)", + ) + } + } } // ── Reactive plumbing ───────────────────────────────────────────── diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyTest.kt index 0b47aec739..e36092414e 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyTest.kt @@ -103,4 +103,66 @@ class KeySecurityPolicyTest { val storage = WalletStorage(ApplicationProvider.getApplicationContext()) assertEquals(KeySecurityPolicy.AUTH_GATED, storage.keySecurityPolicy) } + + // ── Effective-policy resolution (lockless degradation, #4060) ─────── + // AndroidKeyStore has no Robolectric provider, so the alias-presence + // check is stubbed through the open seam; the RESOLUTION logic — + // requested policy × lock-screen probe × provisioned gated alias — is + // what these pin. + + private fun manager( + policy: KeySecurityPolicy, + deviceSecure: Boolean, + authGatedAliasProvisioned: Boolean, + ): KeystoreManager = object : KeystoreManager(policy, { deviceSecure }) { + override fun hasIdentityKeysKey(alias: String): Boolean = + authGatedAliasProvisioned && alias == KEYS_ALIAS_AUTH_GATED + } + + @Test + fun effectivePolicyDegradesToDeviceBoundOnALocklessDevice() { + // Requested AUTH_GATED, no lock screen, gated alias never provisioned: + // the manager must not lie — new keys go under the DEVICE_BOUND alias + // and the effective policy says so. + val m = manager( + KeySecurityPolicy.AUTH_GATED, + deviceSecure = false, + authGatedAliasProvisioned = false, + ) + assertEquals(KeySecurityPolicy.DEVICE_BOUND, m.effectiveKeySecurityPolicy()) + } + + @Test + fun effectivePolicyIsAuthGatedOnceTheGatedAliasExists() { + // A provisioned gated alias carries its gate in the key itself — + // later lock-screen churn cannot remove it, so the effective policy + // stays AUTH_GATED even while the probe reports lockless. + val m = manager( + KeySecurityPolicy.AUTH_GATED, + deviceSecure = false, + authGatedAliasProvisioned = true, + ) + assertEquals(KeySecurityPolicy.AUTH_GATED, m.effectiveKeySecurityPolicy()) + } + + @Test + fun effectivePolicyMatchesRequestedOnASecureDevice() { + val m = manager( + KeySecurityPolicy.AUTH_GATED, + deviceSecure = true, + authGatedAliasProvisioned = false, + ) + assertEquals(KeySecurityPolicy.AUTH_GATED, m.effectiveKeySecurityPolicy()) + } + + @Test + fun deviceBoundPolicyIsNeverReportedDegraded() { + // DEVICE_BOUND is the requested floor — there is nothing to degrade. + val m = manager( + KeySecurityPolicy.DEVICE_BOUND, + deviceSecure = false, + authGatedAliasProvisioned = false, + ) + assertEquals(KeySecurityPolicy.DEVICE_BOUND, m.effectiveKeySecurityPolicy()) + } } 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..4c1dc503b8 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeystoreKeyGenPolicyTest.kt @@ -0,0 +1,156 @@ +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 open class SimulatedKeyStoreException(message: String) : + GeneralSecurityException(message) + + /** + * Numeric-code-carrying stand-in: the classifier reads + * `getNumericErrorCode()` reflectively (the real method is + * `android.security.KeyStoreException`'s, API 33+), so declaring the + * same method here exercises the numeric path on the JVM. + */ + private class SimulatedNumericKeyStoreException( + message: String, + private val numericErrorCode: Int, + ) : SimulatedKeyStoreException(message) { + @Suppress("unused") // reflectively invoked by the classifier + fun getNumericErrorCode(): Int = numericErrorCode + } + + @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 classifiesNumericRejectionCodeUnderAKeyGenProviderException() { + // Some OEM builds report the rejection with an opaque message; the + // structured numeric code (internal Keystore 4 / KeyMint 10309) is + // then the only evidence. It counts ONLY inside a key-gen + // ProviderException's cause chain. + listOf(4, 10309).forEach { code -> + val failure = ProviderException( + "Keystore key generation failed", + SimulatedNumericKeyStoreException("System error", code), + ) + assertTrue( + "numeric code $code must classify", + KeystoreManager.isNoSecureLockScreenKeyGenFailure(failure), + ) + } + } + + @Test + fun doesNotClassifyUnrelatedNumericCodes() { + val failure = ProviderException( + "Keystore key generation failed", + SimulatedNumericKeyStoreException("System error", 7), + ) + assertFalse(KeystoreManager.isNoSecureLockScreenKeyGenFailure(failure)) + } + + @Test + fun doesNotClassifyANestedKeystoreErrorWithAnUnrelatedMessage() { + // Regression guard for the over-broad classifier: previously, ANY + // nested KeyStoreException classified once a generic "key generation + // failed" ProviderException had been seen anywhere earlier in the + // chain walk. A generation failure whose underlying Keystore error is + // unrelated to the lock screen (no generate_key / lock-screen message, + // no rejection code) must NOT trigger the degraded retry. + val failure = ProviderException( + "Keystore key generation failed", + SimulatedKeyStoreException("Signature verification failed"), + ) + assertFalse(KeystoreManager.isNoSecureLockScreenKeyGenFailure(failure)) + } + + @Test + fun doesNotClassifyAKeystoreErrorOutsideTheKeyGenSubtree() { + // The lock-screen-message match stands alone, but the bare-system- + // error match requires the key-gen ProviderException ABOVE it in the + // cause chain — an unrelated wrapper does not open the window. + val failure = ProviderException( + "unrelated provider issue", + SimulatedNumericKeyStoreException("System error", 4), + ) + assertFalse(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"), + ), + ) + } +} From 3cbd42845859b448f31e396518a37e7ccd64f83f Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:18:05 -0400 Subject: [PATCH 05/26] feat(kotlin-sdk): layered identity-key retrieve ladder with cheap/probing capability split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retrievePrivateKey becomes an explicit three-rung ladder that composes the #4172 fingerprint gate with the legacy-blob recovery paths: 1. Legacy AES-GCM blobs shape-dispatch FIRST — the write-time fingerprint gate never applies to them (they predate it or carry a superseded key's fingerprint); the retained legacy KEYS_ALIAS AES key recovers the value, which is migrated to the policy alias. 2. Empty-IV RSA blobs whose stored fingerprint matches the RECORDED alias's current key (non-generating read, alias-tag routed) decrypt on the fast path. KeyPermanentlyInvalidatedException rethrows after KeystoreManager's generation-checked cleanup — after the deletion the fingerprint can no longer match, so reads and repair re-DERIVE rather than trusting a stale certificate (closes the invalidation brick loop). Other crypto failures fall through (defense in depth). 3. A mismatching or MISSING fingerprint is a routing signal into the former-KEYS_ALIAS-RSA recovery ladder, not an immediate null — pre-split blobs carry the former key's fingerprint and must reach decryptLegacyRsaKeysBlob. The policy alias is never touched here, and a read never provisions a keypair. Capability is split per finding 3: isPrivateKeyDecryptable stays the CHEAP check (presence + fingerprint only — the Rust canSignWith callback thread must never decrypt, prompt, or generate), extended with presence rungs for the legacy schemes; the real-decrypt health check moves to the new probeIdentityKeyRecoverability (DEVICE_BOUND-sibling disproof included, skipped for blobs the sibling legitimately owns by tag), which the example app's WalletKeyHealthSheet now calls. UserNotAuthenticatedException propagates from every rung — a closed auth window is never a wrong key. WalletStorageUpgradeMatrixTest pins every ladder row through the fake Keystore seam, including the new invalidation-cleanup, alias-tag-routing, fast-path-failure, and never-decrypts-on-the-cheap-path rows. Co-Authored-By: Claude Fable 5 --- .../example/ui/wallet/WalletKeyHealthSheet.kt | 11 +- .../dashsdk/security/WalletStorage.kt | 313 ++++++++- .../WalletStorageUpgradeMatrixTest.kt | 642 ++++++++++++++++++ 3 files changed, 937 insertions(+), 29 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/WalletKeyHealthSheet.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/WalletKeyHealthSheet.kt index 2111c9dc05..c265a5ea8f 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/WalletKeyHealthSheet.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/WalletKeyHealthSheet.kt @@ -91,11 +91,14 @@ fun WalletKeyHealthSheet( securityLevel = row.securityLevel, pubkeyHex = pubkeyHex, publicKeyData = row.publicKeyData, - // Decryptability, not mere existence — a blob from the - // pre-RSA scheme is present but unusable and needs the - // same repair as a missing one. + // Real recoverability, not mere presence — the + // PROBING check actually opens the blob with the + // candidate keys, so a stranded/sibling-alias blob is + // reported unhealthy and gets the same repair as a + // missing one (the cheap isPrivateKeyDecryptable is + // reserved for the signer's capability callback). hasPrivateKey = runCatching { - container.walletStorage.isPrivateKeyDecryptable(pubkeyHex) + container.walletStorage.probeIdentityKeyRecoverability(pubkeyHex) }.getOrDefault(false), ) } 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 051683805a..bfbb2a88ea 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 @@ -2,6 +2,8 @@ package org.dashfoundation.dashsdk.security import android.app.KeyguardManager import android.content.Context +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.UserNotAuthenticatedException import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit @@ -11,6 +13,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( @@ -471,27 +474,169 @@ class WalletStorage( store.data.first()[ownerIndexKey(walletId.toHex())] ?: emptySet() /** - * Decrypt the private key for [pubkeyHex]. A stable blob whose stored - * key fingerprint no longer matches the current [KeystoreManager.keysAlias] - * returns `null`, allowing the caller to re-derive it instead of trying - * OAEP with an unrelated replacement key. Rotation can still occur - * between this check and decrypt; that race deliberately remains a - * fail-closed crypto exception rather than returning stale plaintext. - * - * Under [KeySecurityPolicy.AUTH_GATED] this throws - * `UserNotAuthenticatedException` when the auth window expired — the - * caller (KeystoreSigner) routes through [BiometricGate] and retries; - * under [KeySecurityPolicy.DEVICE_BOUND] it never auth-gates. + * Decrypt the private key for [pubkeyHex] — the LAYERED retrieve ladder + * (dashpay/platform#4060). Under [KeySecurityPolicy.AUTH_GATED] this + * throws `UserNotAuthenticatedException` when the auth window expired — + * the caller (KeystoreSigner) routes through [BiometricGate] and + * retries; under [KeySecurityPolicy.DEVICE_BOUND] it never auth-gates. * Callers must zero the returned array after use. + * + * Ladder, in order: + * 1. **Legacy AES-GCM** blob (non-empty IV) — shape-dispatch FIRST: the + * write-time fingerprint gate is NEVER applied to legacy blobs (they + * predate it, or carry a superseded key's fingerprint); the retained + * [KeystoreManager.KEYS_ALIAS] AES key recovers the value + * ([KeystoreManager.decryptLegacyKeysBlob]) and the entry is migrated + * to the policy alias. An absent legacy key → `null` (unrecoverable, + * the key-health/repair path takes over). + * 2. **Empty-IV RSA, fingerprint fast path** — when the stored + * fingerprint matches the RECORDED alias's current key (a + * non-generating read; a blob written during a lockless + * AUTH_GATED→DEVICE_BOUND degradation carries its producing alias in + * the `privkeyalias.` tag), decrypt under that alias. A + * `KeyPermanentlyInvalidatedException` there has already run the + * generation-checked alias cleanup inside [KeystoreManager.decrypt] + * and RETHROWS — after the deletion the stored fingerprint can no + * longer match, so subsequent reads and the repair path re-DERIVE + * instead of trusting a stale certificate (the brick-loop fix). Any + * other unexpected crypto failure falls to the recovery ladder + * (defense in depth). + * 3. **Recovery ladder** — a mismatching or missing fingerprint (or an + * absent/unprovisioned recorded alias) is a ROUTING signal, not an + * immediate `null`: pre-alias-split blobs carry the FORMER + * [KeystoreManager.KEYS_ALIAS] RSA key's fingerprint and must reach + * [KeystoreManager.decryptLegacyRsaKeysBlob]. The policy alias is + * never touched here (no OAEP attempt with an unrelated key, no + * keypair generation on a read). A wrong-key crypto failure is "not + * this key"; nothing opening the blob → `null` (re-derive signal). + * + * Recovered legacy values are re-encrypted under the current policy + * alias and rewritten (see [migrateToPolicyAlias]), so subsequent reads + * take the fast path. `UserNotAuthenticatedException` propagates from + * every rung — a closed auth window is never a wrong-key signal. */ suspend fun retrievePrivateKey(pubkeyHex: String): ByteArray? { val prefs = store.data.first() val encoded = prefs[privateKeyKey(pubkeyHex)] ?: return null - if (!isCurrentKeysBlob(pubkeyHex, encoded, prefs)) return null - // Decrypt under the RECORDED alias: a blob written during a lockless - // AUTH_GATED→DEVICE_BOUND degradation stays readable after a lock - // screen is enrolled and new writes move to the gated alias. - return keystore.decrypt(decode(encoded), alias = recordedKeysAliasFor(pubkeyHex, prefs)) + val blob = decode(encoded) + if (keystore.isLegacyKeysBlob(blob)) { + // Rung 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 + } + if (!keystore.isKeysBlobDecryptable(blob)) return null + val recordedAlias = recordedKeysAliasFor(pubkeyHex, prefs) + val storedFingerprint = prefs[privateKeyFingerprintKey(pubkeyHex)] + val currentFingerprint = keystore.keysAliasFingerprintOrNull(recordedAlias) + if (storedFingerprint != null && currentFingerprint != null && + storedFingerprint == currentFingerprint && + keystore.hasIdentityKeysKey(recordedAlias) + ) { + // Rung 2 — the blob is CURRENT under the recorded alias. + return try { + keystore.decrypt(blob, alias = recordedAlias) + } catch (e: UserNotAuthenticatedException) { + throw e // closed auth window — prompt and retry, never recovery + } catch (e: KeyPermanentlyInvalidatedException) { + // KeystoreManager.decrypt already ran the generation-checked + // alias deletion; the typed signal must escape so the signer + // suppresses the biometric retry and the next write/repair + // regenerates the alias. + throw e + } catch (e: GeneralSecurityException) { + // Rotation race / provider quirk: fall through to the + // recovery ladder rather than failing the read outright. + recoverEmptyIvRsaBlob(pubkeyHex, blob, encoded) + } + } + // Rung 3 — fingerprint mismatch/missing, or the recorded alias is + // absent (e.g. just deleted by invalidation cleanup): do NOT touch + // the policy alias; try the retained former KEYS_ALIAS RSA keypair. + return recoverEmptyIvRsaBlob(pubkeyHex, blob, encoded) + } + + /** + * Rung-3 recovery of an empty-IV RSA blob: the retained former + * pre-alias-split RSA keypair at [KeystoreManager.KEYS_ALIAS] either + * opens it (→ migrate forward + return) or the blob is unrecoverable + * here (→ null, the key-health/repair path takes over). + */ + private suspend fun recoverEmptyIvRsaBlob( + pubkeyHex: String, + blob: KeystoreManager.EncryptedBlob, + sourceEncoded: String, + ): ByteArray? { + val recovered = tryFormerRsaRecovery(blob) ?: return null + migrateToPolicyAlias(pubkeyHex, recovered, sourceEncoded) + return 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 effective policy + * alias (a never-auth-gated public-key encrypt) and rewrite the stored + * blob + fingerprint + alias tag, migrating a recovered legacy value + * forward so subsequent reads take the fingerprint fast path. 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.encryptForIdentityKeys(plain) + store.edit { + val key = privateKeyKey(pubkeyHex) + if (it[key] == sourceEncoded) { + it[key] = encode(migrated.blob) + // Keep the write-time fingerprint + alias tag coherent + // with the re-encrypted blob, so [isCurrentKeysBlob] (the + // storeIfAbsent usability check) and the read fast path + // recognize the migrated entry instead of re-deriving it. + it[privateKeyFingerprintKey(pubkeyHex)] = migrated.keyFingerprint + it[privateKeyAliasKey(pubkeyHex)] = migrated.alias + } + } + } } suspend fun deletePrivateKey(pubkeyHex: String) { @@ -544,21 +689,139 @@ class WalletStorage( store.data.first().contains(privateKeyKey(pubkeyHex)) /** - * Whether the blob stored for [pubkeyHex] is decryptable under the - * current [KeystoreManager.keysAlias] RSA scheme. Blobs written by the - * pre-RSA AES-GCM scheme survive in the DataStore but lost their key - * when the RSA pair replaced it, so signing with them can only fail — - * key-health treats them as missing and offers a re-derive. Read-only: it - * never decrypts, prompts, or generates a keypair (an absent alias just - * returns `false`), so it is safe to call from the synchronous signer - * capability probe without blocking or mutating Keystore state. + * CHEAP signing-capability check: whether the blob stored for + * [pubkeyHex] is plausibly recoverable by [retrievePrivateKey]. It never + * decrypts, prompts, or generates a keypair (an absent alias just + * returns `false`) — [KeystoreSigner.canSignWith] runs this under + * `runBlocking` on a Rust callback thread, which must not block on + * Keystore crypto or mutate Keystore state. + * + * Structure mirrors the retrieve ladder with PRESENCE checks in place of + * decrypts: a legacy AES blob is signable iff the retained legacy AES + * key still exists; a current-fingerprint RSA blob (under its recorded + * alias) is signable; any other RSA-shaped blob is signable iff the + * former [KeystoreManager.KEYS_ALIAS] RSA key survives (the recovery + * ladder can then still open it — optimistic, disproved only by the + * real-decrypt [probeIdentityKeyRecoverability], which is deliberately + * NOT reachable from here). */ suspend fun isPrivateKeyDecryptable(pubkeyHex: String): Boolean { val prefs = store.data.first() val encoded = prefs[privateKeyKey(pubkeyHex)] ?: return false - return isCurrentKeysBlob(pubkeyHex, encoded, prefs) + val blob = decode(encoded) + return when { + keystore.isLegacyKeysBlob(blob) -> keystore.hasLegacyKeysKey() + !keystore.isKeysBlobDecryptable(blob) -> false + isCurrentKeysBlob(pubkeyHex, encoded, prefs) -> true + // Former-RSA blobs are recoverable via the retrieve ladder as + // long as the retained legacy RSA key survives — presence only. + else -> keystore.hasLegacyRsaKeysKey() + } } + /** + * PROBING key-health check: 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 + * really 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. Deliberately SEPARATE from the cheap + * [isPrivateKeyDecryptable]: real decrypts are far too heavy for the + * signer's synchronous `canSignWith` callback thread — callers are the + * key-health UI and the repair verification, never a signing path. + * + * 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 RECOVERABLE: 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 recoverable". 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 — skipped when the blob's recorded + * alias IS the DEVICE_BOUND alias, where the sibling legitimately owns + * it), then probe the recorded 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 + * recoverable. + * + * **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 recoverable. The prompt-free + * DEVICE_BOUND sibling ([KeystoreManager.opensUnderNonGatedDeviceBoundSibling]) + * resolves the common case: if that non-gated sibling opens an + * un-tagged blob, the current (auth-gated) policy alias does NOT own it, + * and since [retrievePrivateKey] never falls back to an un-tagged 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 recoverable until the first + * real unlock surfaces the BadPadding, at which point + * [retrievePrivateKey]'s fallback→null drives the same repair. + */ + suspend fun probeIdentityKeyRecoverability(pubkeyHex: String): Boolean { + val prefs = store.data.first() + val encoded = prefs[privateKeyKey(pubkeyHex)] ?: return false + val blob = decode(encoded) + if (keystore.isLegacyKeysBlob(blob)) { + return probeOpensBlob { keystore.decryptLegacyKeysBlob(blob) } + } + if (!keystore.isKeysBlobDecryptable(blob)) return false + val recordedAlias = recordedKeysAliasFor(pubkeyHex, prefs) + // A prompt-free sibling proves an un-tagged blob belongs to the + // non-gated DEVICE_BOUND alias, not the (possibly locked) auth-gated + // alias the retrieve ladder would target — and retrieve never tries + // the sibling for such a blob — so it is unrecoverable here (finding + // b80a15c93339). When the blob is TAGGED as DEVICE_BOUND the sibling + // is its recorded owner and the normal probe below covers it. + if (recordedAlias != KeystoreManager.KEYS_ALIAS_DEVICE_BOUND && + keystore.opensUnderNonGatedDeviceBoundSibling(blob) + ) { + return false + } + return ( + keystore.hasIdentityKeysKey(recordedAlias) && + probeOpensBlob { keystore.decrypt(blob, recordedAlias) } + ) || + ( + keystore.hasLegacyRsaKeysKey() && + probeOpensBlob { keystore.decryptLegacyRsaKeysBlob(blob) } + ) + } + + /** + * True iff [decrypt] recovers the 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 recoverable). A wrong-key crypto failure or an absent key (`null`) is + * false. Prompt-free by construction — see [probeIdentityKeyRecoverability]. + * Used only by the non-prompting key-health probe, never on a signing path. + */ + private fun probeOpensBlob(decrypt: () -> ByteArray?): Boolean = + try { + val plain = decrypt() + if (plain != null) { + plain.fill(0) + true + } else { + false + } + } catch (e: UserNotAuthenticatedException) { + true + } catch (e: GeneralSecurityException) { + false + } + /** All entry names (masked listing for the Keystore Explorer screen). */ suspend fun listEntryNames(): List = store.data.first().asMap().keys.map { it.name }.sorted() diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt new file mode 100644 index 0000000000..a464d66a10 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt @@ -0,0 +1,642 @@ +package org.dashfoundation.dashsdk.security + +import android.security.keystore.KeyPermanentlyInvalidatedException +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 layered identity-key + * retrieve ladder (dashpay/platform#4060). Exercises every stored-blob shape + * an upgraded install can hold — legacy AES-GCM, pre-alias-split RSA under + * [KeystoreManager.KEYS_ALIAS], current policy-alias RSA, and + * DEVICE_BOUND-tagged degradation blobs — plus the stranded, invalidated-key, + * and auth-propagation cases, and the cheap-vs-probing capability split. + * + * 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), a + * write-time encrypt captures blob + fingerprint + producing alias in one + * lookup, and an invalidated policy key runs the generation-checked cleanup + * then rethrows. 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: fingerprint fast path, no recovery rung. */ + @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 + + // The cheap capability check sees the retained AES key (presence only). + assertTrue(storage.isPrivateKeyDecryptable(pub)) + // So does the probing health check (real decrypt). + assertTrue(storage.probeIdentityKeyRecoverability(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)) + assertFalse(storage.probeIdentityKeyRecoverability(pub)) + // decryptLegacyKeysBlob returns null → retrieve yields null, not a wrong value. + assertNull(storage.retrievePrivateKey(pub)) + } + + /** + * Pre-alias-split RSA blob (the former key's fingerprint never matches the + * policy alias): the fingerprint gate ROUTES to the former-RSA recovery + * ladder — not an immediate null — and the value is migrated forward. + */ + @Test + fun formerRsaBlobRoutesThroughRecoveryLadderAndMigrates() = 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 + assertTrue(storage.probeIdentityKeyRecoverability(pub)) + + 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 while a former-RSA + * blob lingers. The fingerprint mismatch routes STRAIGHT to the recovery + * ladder — the policy alias's key is never even tried against a blob it + * cannot own (no OAEP attempt with an unrelated key on a read path). + */ + @Test + fun formerRsaBlobWithProvisionedPolicySkipsThePolicyKeyEntirely() = 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 recovery ladder + assertEquals(0, fake.policyDecryptCalls) // wrong key never attempted + } + + /** + * 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)) + assertFalse(storage.probeIdentityKeyRecoverability(pub)) + // Unrecoverable → null (not a wrong value, not a thrown BadPaddingException). + assertNull(storage.retrievePrivateKey(pub)) + } + + /** + * Regression (dashpay/platform#4060): an un-tagged blob written by the + * DEVICE_BOUND sibling while an UNRELATED former RSA key still lingers at + * KEYS_ALIAS. The recovery ladder tries the former key on presence — 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). + */ + @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-written blob, tag says policy alias + + // The former-RSA recovery 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 the key-health PROBE must NOT report the stranded blob + // recoverable (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.probeIdentityKeyRecoverability(pub)) + } + + /** + * Key-health probes actual decryptability, not presence — but an auth-gated + * key with a closed window is RECOVERABLE, not stranded (finding e17e265dc680). + * A provisioned policy-alias blob whose decrypt throws + * UserNotAuthenticatedException (window closed) must report recoverable: 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 recoverable.) + */ + @Test + fun authGatedPolicyKeyReportsRecoverableWithoutPrompting() = runBlocking { + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + storage.storePrivateKey(pub, secret) // provisions the policy alias + + fake.throwAuthOnPolicyDecrypt = true // closed auth window + assertTrue(storage.probeIdentityKeyRecoverability(pub)) + // The cheap check agrees via the fingerprint match (no decrypt at all). + 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 + * recoverable when the window is closed (UserNotAuth), rather than stranded. + */ + @Test + fun authGatedFormerRsaKeyReportsRecoverable() = 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.probeIdentityKeyRecoverability(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 (un-tagged) 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 "recoverable". The prompt-free DEVICE_BOUND sibling opens + * the blob, proving the policy alias does not own it; since + * retrievePrivateKey never falls back to the sibling for an un-tagged blob, + * it is genuinely strandable and key-health must report it unrecoverable so + * the repair path fires. + */ + @Test + fun provisionedLockedWrongAliasBlobDisprovedByPromptFreeSibling() = runBlocking { + fake.scheme = FakeKeystoreManager.Scheme.SIBLING_POLICY + storage.storePrivateKey(pub, secret) // sibling-written blob, tag says policy alias + + fake.policyKeyProvisioned = true // AUTH_GATED alias provisioned earlier... + fake.throwAuthOnPolicyDecrypt = true // ...and its auth window is closed + fake.deviceBoundKeyPresent = true // DEVICE_BOUND actually wrote it, key present + + assertFalse(storage.probeIdentityKeyRecoverability(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 + * recoverable 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.deviceBoundKeyPresent = true // sibling present but cannot open a TAG_POLICY blob + + assertTrue(storage.probeIdentityKeyRecoverability(pub)) + } + + /** + * Alias-tag routing after a lockless AUTH_GATED→DEVICE_BOUND write + * degradation (dashpay/platform#4060 finding 4): the blob was written — + * and TAGGED — under the DEVICE_BOUND alias while the device had no lock + * screen. After a lock screen is enrolled (and the auth-gated alias is + * provisioned for NEW writes), reads must still decrypt this blob under + * its RECORDED alias, and neither capability surface may misreport it. + */ + @Test + fun aliasTagRoutesReadsAfterLocklessDegradation() = runBlocking { + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + fake.degradeWritesToDeviceBound = true + storage.storePrivateKey(pub, secret) // written + tagged devicebound + + // Lock enrolled later; gated alias provisioned for new writes. + fake.degradeWritesToDeviceBound = false + fake.policyKeyProvisioned = true + + assertTrue(storage.isPrivateKeyDecryptable(pub)) + assertTrue(storage.probeIdentityKeyRecoverability(pub)) + assertArrayEquals(secret, storage.retrievePrivateKey(pub)) + assertEquals(0, fake.legacyRsaFallbackCalls) // routed by tag, not recovery + } + + /** + * Pins finding 3 (the cheap/probing split): the signer-facing capability + * check must NEVER decrypt — it runs under runBlocking on the Rust + * callback thread. Presence and fingerprint reads only, across every blob + * shape. + */ + @Test + fun cheapCapabilityCheckNeverDecrypts() = runBlocking { + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + storage.storePrivateKey(pub, secret) + assertTrue(storage.isPrivateKeyDecryptable(pub)) + + val former = "02" + "11".repeat(32) + fake.scheme = FakeKeystoreManager.Scheme.FORMER_RSA + fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.RSA + storage.storePrivateKey(former, secret) + assertTrue(storage.isPrivateKeyDecryptable(former)) + + val legacy = "02" + "22".repeat(32) + fake.scheme = FakeKeystoreManager.Scheme.LEGACY_AES + fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.AES + storage.storePrivateKey(legacy, secret) + assertTrue(storage.isPrivateKeyDecryptable(legacy)) + + assertEquals(0, fake.policyDecryptCalls) + assertEquals(0, fake.deviceBoundDecryptCalls) + assertEquals(0, fake.legacyAesDecryptCalls) + assertEquals(0, fake.legacyRsaFallbackCalls) + } + + /** + * #4172's invalidation recovery survives the alias split (finding 1): a + * KeyPermanentlyInvalidatedException at the policy alias runs the + * generation-checked alias deletion and RETHROWS the typed signal (never + * swallowed into the recovery ladder — the signer must suppress its + * biometric retry). After the deletion the stored fingerprint can no + * longer match, so the capability check reports the blob non-current and + * the repair path re-derives instead of trusting a stale certificate + * (the brick-loop fix). + */ + @Test + fun invalidatedPolicyKeyRunsGenerationCheckedCleanupAndRethrows() = runBlocking { + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + storage.storePrivateKey(pub, secret) + assertTrue(storage.isPrivateKeyDecryptable(pub)) + + fake.throwInvalidatedOnPolicyDecrypt = true + assertThrows(KeyPermanentlyInvalidatedException::class.java) { + runBlocking { storage.retrievePrivateKey(pub) } + } + assertTrue("cleanup must have deleted the invalidated generation", fake.invalidatedCleanupRan) + assertEquals(0, fake.legacyRsaFallbackCalls) // typed signal, not recovery + + // Alias gone → fingerprint unreadable → blob non-current → repair path. + fake.throwInvalidatedOnPolicyDecrypt = false + assertFalse(storage.isPrivateKeyDecryptable(pub)) + assertNull(storage.retrievePrivateKey(pub)) + } + + /** + * Defense in depth on the fast path: a fingerprint-matched blob whose + * decrypt still fails with an unexpected crypto error (rotation race / + * provider quirk) falls to the recovery ladder — and, with no former key + * present, surfaces as null rather than an escaped exception. + */ + @Test + fun fastPathCryptoFailureFallsToRecoveryLadder() = runBlocking { + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + storage.storePrivateKey(pub, secret) + + fake.throwBadPaddingOnPolicyDecrypt = true + assertNull(storage.retrievePrivateKey(pub)) + assertTrue(fake.legacyRsaFallbackCalls > 0) // ladder was consulted + } + + /** + * 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 key; legacy AES blobs carry a + * non-empty IV. Only the key 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 + * ([KeystoreManager.isLegacyKeysBlob], [KeystoreManager.isKeysBlobDecryptable]) + * are inherited unchanged. + * + * Fingerprint model: each present key has the deterministic fingerprint + * `fake-fp-`; the write-time capture ([encryptForIdentityKeys]) + * returns the fingerprint of the key that actually produced the blob, so the + * production fingerprint gate routes exactly as it would against real keys + * (a FORMER_RSA blob carries the former key's fingerprint, which never + * matches a policy alias). + */ +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 deviceBoundKeyPresent: Boolean = false + var degradeWritesToDeviceBound: Boolean = false + var throwAuthOnPolicyDecrypt: Boolean = false + var throwAuthOnLegacyRsaDecrypt: Boolean = false + var throwInvalidatedOnPolicyDecrypt: Boolean = false + var throwBadPaddingOnPolicyDecrypt: Boolean = false + var invalidatedCleanupRan: Boolean = false + + var policyDecryptCalls: Int = 0 + var deviceBoundDecryptCalls: Int = 0 + var legacyAesDecryptCalls: Int = 0 + 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 + + override val keysAlias: String get() = POLICY_ALIAS + + override fun effectiveKeySecurityPolicy(): KeySecurityPolicy = keySecurityPolicy + + override fun keysAliasFingerprint(alias: String): String = fpOf(alias) + + // The real implementation hashes an AndroidKeyStore public key, which + // cannot exist on the JVM; presence-driven per-alias values instead. + override fun keysAliasFingerprintOrNull(alias: String): String? = when (alias) { + POLICY_ALIAS -> if (policyKeyProvisioned) fpOf(POLICY_ALIAS) else null + KEYS_ALIAS_DEVICE_BOUND -> if (deviceBoundKeyPresent) fpOf(KEYS_ALIAS_DEVICE_BOUND) else null + else -> null + } + + override fun hasIdentityKeysKey(alias: String): Boolean = when (alias) { + POLICY_ALIAS -> policyKeyProvisioned + KEYS_ALIAS_DEVICE_BOUND -> deviceBoundKeyPresent + else -> false + } + + override fun hasLegacyKeysKey(): Boolean = keysAliasKind == KeysAliasKind.AES + + override fun hasLegacyRsaKeysKey(): Boolean = keysAliasKind == KeysAliasKind.RSA + + override fun encryptForIdentityKeys(plaintext: ByteArray): KeysAliasEncryptedBlob = + when (scheme) { + Scheme.CURRENT -> { + onNextPolicyEncrypt?.let { hook -> + onNextPolicyEncrypt = null + hook() + } + if (degradeWritesToDeviceBound) { + // Lockless degradation: the write lands on (and tags) the + // DEVICE_BOUND alias, provisioning it. + deviceBoundKeyPresent = true + KeysAliasEncryptedBlob( + rsaBlob(TAG_DEVICE_BOUND, plaintext), + fpOf(KEYS_ALIAS_DEVICE_BOUND), + KEYS_ALIAS_DEVICE_BOUND, + ) + } else { + policyKeyProvisioned = true // public-key encrypt provisions the alias + KeysAliasEncryptedBlob(rsaBlob(TAG_POLICY, plaintext), fpOf(POLICY_ALIAS), POLICY_ALIAS) + } + } + // Pre-alias-split write: the blob carries the FORMER key's + // fingerprint and (having predated the tag) resolves to the policy + // alias on read. Does NOT provision the policy alias. + Scheme.FORMER_RSA -> KeysAliasEncryptedBlob( + rsaBlob(TAG_FORMER_RSA, plaintext), + FP_FORMER_RSA, + POLICY_ALIAS, + ) + // A blob produced by the DEVICE_BOUND sibling but NOT tagged as + // such (pre-tag data / policy switch): neither the policy alias key + // nor the former RSA key can open it (dashpay/platform#4060). + Scheme.SIBLING_POLICY -> KeysAliasEncryptedBlob( + rsaBlob(TAG_DEVICE_BOUND, plaintext), + fpOf(KEYS_ALIAS_DEVICE_BOUND), + POLICY_ALIAS, + ) + Scheme.LEGACY_AES -> KeysAliasEncryptedBlob(aesBlob(plaintext), FP_LEGACY_AES, POLICY_ALIAS) + } + + override fun decrypt(blob: EncryptedBlob, alias: String): ByteArray = when (alias) { + POLICY_ALIAS -> { + policyDecryptCalls++ + if (throwInvalidatedOnPolicyDecrypt) { + // Mirror the production KeystoreManager.decrypt contract: + // generation-checked deletion of the invalidated alias, THEN + // the typed rethrow. + invalidatedCleanupRan = + deleteIdentityKeysAliasIfCurrentGeneration(POLICY_ALIAS, fpOf(POLICY_ALIAS)) + throw KeyPermanentlyInvalidatedException() + } + if (throwAuthOnPolicyDecrypt) throw UserNotAuthenticatedException() + if (throwBadPaddingOnPolicyDecrypt) throw BadPaddingException("simulated rotation race") + if (policyKeyProvisioned && blob.ciphertext[0] == TAG_POLICY) { + plaintextOfRsa(blob) + } else { + throw BadPaddingException("wrong key for $alias") + } + } + KEYS_ALIAS_DEVICE_BOUND -> { + deviceBoundDecryptCalls++ + if (deviceBoundKeyPresent && blob.ciphertext[0] == TAG_DEVICE_BOUND) { + plaintextOfRsa(blob) + } else { + throw BadPaddingException("wrong key for $alias") + } + } + else -> throw IllegalArgumentException("test only decrypts under the RSA policy aliases") + } + + override fun deleteIdentityKeysAliasIfCurrentGeneration( + alias: String, + expectedFingerprint: String, + ): Boolean { + if (alias != POLICY_ALIAS || !policyKeyProvisioned) return false + if (expectedFingerprint != fpOf(POLICY_ALIAS)) return false + policyKeyProvisioned = false + return true + } + + override fun opensUnderNonGatedDeviceBoundSibling(blob: EncryptedBlob): Boolean = + deviceBoundKeyPresent && blob.iv.isEmpty() && blob.ciphertext[0] == TAG_DEVICE_BOUND + + override fun decryptLegacyKeysBlob(blob: EncryptedBlob): ByteArray? { + if (keysAliasKind != KeysAliasKind.AES) return null + legacyAesDecryptCalls++ + return plaintextOfAes(blob) + } + + 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") + } + + private fun fpOf(alias: String): String = "fake-fp-$alias" + + 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_DEVICE_BOUND: Byte = 3 + const val FP_FORMER_RSA = "fake-fp-former-keys-alias" + const val FP_LEGACY_AES = "fake-fp-legacy-aes" + } +} From 8be0599a8a8b5d54eeef73e1f61882f9e43a1b13 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:18:52 -0400 Subject: [PATCH 06/26] feat(kotlin-sdk): queryable pendingIdentityKeys state for watch-only key failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of the #4053 pending-keys machinery: when the identity-key persist callback's derive/store fails, the key lands in Room watch-only and every signature with it will fail — previously a single Log.w was the only trace. PlatformWalletPersistenceHandler now records a PendingIdentityKey entry (wallet/identity/keyId, derivation breadcrumbs, reason, timestamp) in a StateFlow keyed by public-key hex, exposed through PlatformWalletManager.pendingIdentityKeys so hosts can surface a repair path. Transactional with the changeset round (finding de3cf44a71fc): while a store round is open, record/clear mutations are staged as pure map deltas in the round's ChangesetBuffer and published in ONE atomic MutableStateFlow.update only after the Room transaction commits; a rolled-back or aborted round discards them with the buffer, leaving the pre-round map untouched. Standalone upserts publish immediately, as does markIdentityKeyRepaired — which repairIdentityKey now calls on success, since the repair stores the scalar through the deriver and bypasses the only persist path that clears pending entries. Co-Authored-By: Claude Fable 5 --- .../PlatformWalletPersistenceHandler.kt | 209 +++++++++++++++- .../dashsdk/wallet/PlatformWalletManager.kt | 23 +- .../PlatformWalletPersistenceHandlerTest.kt | 228 ++++++++++++++++++ 3 files changed, 450 insertions(+), 10 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 9dce9df079..e5efa86679 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). */ @@ -290,6 +313,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 @@ -314,7 +383,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() @@ -329,7 +399,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 } @@ -341,11 +413,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 } @@ -1029,17 +1104,61 @@ class PlatformWalletPersistenceHandler( // wallet-deletion sweep still reaches it through the committed row). val deriveResult: DerivedKeyStoreResult? = 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(publicKeyData.toHex())) + } else { + // NOT silent (dashpay/platform#4053): the key is being + // persisted watch-only, so every signature with it will + // fail until it is re-derived. Log loudly and record a + // queryable pending entry (see [pendingIdentityKeys]). + val reason = outcome.exceptionOrNull()?.let { t -> + t.message ?: t.javaClass.simpleName + } ?: "deriver returned no storage identifier" + Log.e( + TAG, + "identity private-key derive/store FAILED — key " + + "${publicKeyData.toHex()} (identity ${identityId.toBase58String()}, " + + "keyId $keyId, slot $identityIndex/$keyIndex) is persisted " + + "WATCH-ONLY and cannot sign until re-derived " + + "(see PlatformWalletPersistenceHandler.pendingIdentityKeys): $reason", + outcome.exceptionOrNull(), + ) + // 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 } @@ -2425,6 +2544,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/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 84745fc9e4..b146752f91 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 @@ -418,6 +418,17 @@ class PlatformWalletManager( private val identityKeyDeriver get() = coreChildren.identityKeyDeriver private val persistenceHandler get() = coreChildren.persistenceHandler + /** + * 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 @@ -437,6 +448,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, @@ -452,12 +469,16 @@ class PlatformWalletManager( // skips the actual re-derive when the stored scalar is already // decryptable — exactly the case a health-sheet repair is invoked // for (an undecryptable legacy blob) is NOT skipped. - identityKeyDeriver.deriveAndStore( + val storageIdentifier = identityKeyDeriver.deriveAndStore( walletId = walletId, publicKeyData = publicKeyData, identityIndex = identityIndex, keyIndex = keyIndex, )?.identifier + if (storageIdentifier != null) { + persistenceHandler.markIdentityKeyRepaired(publicKeyData.toHex()) + } + storageIdentifier } /** diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 2db5e488de..f0c4ff8374 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 @@ -1010,6 +1010,234 @@ 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, + ): DerivedKeyStoreResult = throw IllegalStateException("keystore unavailable") + + override fun deleteUnownedStored( + pubkeyHexes: Collection, + excludingWalletId: ByteArray, + ): Set = emptySet() + } + + 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, + ): DerivedKeyStoreResult = + if (boom) { + throw IllegalStateException("transient") + } else { + DerivedKeyStoreResult("privkey.cafebabe", wasNewlyCreated = true) + } + + override fun deleteUnownedStored( + pubkeyHexes: Collection, + excludingWalletId: ByteArray, + ): Set = emptySet() + } + 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, + ): DerivedKeyStoreResult = + if (boom) { + throw IllegalStateException("transient") + } else { + DerivedKeyStoreResult("privkey.cafebabe", wasNewlyCreated = true) + } + + override fun deleteUnownedStored( + pubkeyHexes: Collection, + excludingWalletId: ByteArray, + ): Set { + deletedAliases.addAll(pubkeyHexes) + return pubkeyHexes.toSet() + } + } + 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 From eddb25c03a1be060a7a50eaab0da04744ef38b7a Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:23:20 -0400 Subject: [PATCH 07/26] =?UTF-8?q?feat(kotlin-sdk):=20make=20the=20pending-?= =?UTF-8?q?repair=20signal=20durable=20across=20restart=20(Room=207?= =?UTF-8?q?=E2=86=928)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pendingIdentityKeys was process-lifetime only: the durable public_keys row kept just privateKeyKeychainIdentifier = null, dropping the derivation breadcrumbs a repair needs — kill the app and the repair signal was gone. - Room migration 7→8 adds nullable public_keys.derivationIdentityIndex / derivationKeyIndex. Room is the durability substrate deliberately: the wallet-deletion cascade removes these rows, so pending entries die with their wallet automatically (a DataStore side-table would leak). - onPersistIdentityKeyUpsert records the breadcrumbs whenever Rust supplied derivation indices — success AND failure paths (the breadcrumb is not a failure marker; the null identifier is). - reconstructPendingIdentityKeysFromPersistence rebuilds the map on launch: a breadcrumbed, non-read-only row re-seeds pending when its identifier is null OR the stored blob fails the CHEAP capability check — the second disjunct resurrects repair slots for blobs stranded by a Keystore keypair replacement, not just never-derived keys. One atomic update, live entries never overwritten, reason = "reconstructed from persistence after restart". PlatformWalletManager.loadPersistedWallets runs it before the manager is handed to the host. - DashDatabaseMigrationTest gains the 7→8 case (NULL for pre-existing rows, explicit values accepted); the 4→latest and 1→latest chains extend to 8; schema 8.json exported. Co-Authored-By: Claude Fable 5 --- .../8.json | 3965 +++++++++++++++++ .../persistence/DashDatabaseMigrationTest.kt | 62 +- .../dashsdk/persistence/DashDatabase.kt | 29 +- .../PlatformWalletPersistenceHandler.kt | 71 + .../dashsdk/persistence/dao/PublicKeyDao.kt | 13 + .../persistence/entities/PublicKeyEntity.kt | 14 + .../dashsdk/wallet/PlatformWalletManager.kt | 12 + .../PlatformWalletPersistenceHandlerTest.kt | 107 + 8 files changed, 4269 insertions(+), 4 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/8.json diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/8.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/8.json new file mode 100644 index 0000000000..e8cb0819df --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/8.json @@ -0,0 +1,3965 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "7c05146891642d5abd6a173803616b94", + "entities": [ + { + "tableName": "wallets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `walletGroupId` BLOB NOT NULL, `networkRaw` INTEGER, `name` TEXT, `walletDescription` TEXT, `birthHeight` INTEGER NOT NULL, `syncedHeight` INTEGER NOT NULL, `lastSynced` INTEGER NOT NULL, `lastAppliedChainLockBytes` BLOB, `isImported` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletGroupId", + "columnName": "walletGroupId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "walletDescription", + "columnName": "walletDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "birthHeight", + "columnName": "birthHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncedHeight", + "columnName": "syncedHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSynced", + "columnName": "lastSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAppliedChainLockBytes", + "columnName": "lastAppliedChainLockBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "isImported", + "columnName": "isImported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_wallets_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_wallets_walletGroupId", + "unique": false, + "columnNames": [ + "walletGroupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_walletGroupId` ON `${TABLE_NAME}` (`walletGroupId`)" + } + ] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `walletId` BLOB NOT NULL, `accountType` INTEGER NOT NULL, `accountIndex` INTEGER NOT NULL, `accountTypeName` TEXT NOT NULL, `balanceConfirmed` INTEGER NOT NULL, `balanceUnconfirmed` INTEGER NOT NULL, `externalHighestUsed` INTEGER NOT NULL, `internalHighestUsed` INTEGER NOT NULL, `standardTag` INTEGER NOT NULL, `registrationIndex` INTEGER NOT NULL, `keyClass` INTEGER NOT NULL, `userIdentityId` BLOB NOT NULL, `friendIdentityId` BLOB NOT NULL, `accountExtendedPubKeyBytes` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountType", + "columnName": "accountType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountTypeName", + "columnName": "accountTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceConfirmed", + "columnName": "balanceConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balanceUnconfirmed", + "columnName": "balanceUnconfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "externalHighestUsed", + "columnName": "externalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalHighestUsed", + "columnName": "internalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "standardTag", + "columnName": "standardTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "registrationIndex", + "columnName": "registrationIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyClass", + "columnName": "keyClass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userIdentityId", + "columnName": "userIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "friendIdentityId", + "columnName": "friendIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountExtendedPubKeyBytes", + "columnName": "accountExtendedPubKeyBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId", + "unique": true, + "columnNames": [ + "walletId", + "accountType", + "accountIndex", + "standardTag", + "registrationIndex", + "keyClass", + "userIdentityId", + "friendIdentityId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId` ON `${TABLE_NAME}` (`walletId`, `accountType`, `accountIndex`, `standardTag`, `registrationIndex`, `keyClass`, `userIdentityId`, `friendIdentityId`)" + }, + { + "name": "index_accounts_accountExtendedPubKeyBytes", + "unique": true, + "columnNames": [ + "accountExtendedPubKeyBytes" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_accountExtendedPubKeyBytes` ON `${TABLE_NAME}` (`accountExtendedPubKeyBytes`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txid` BLOB NOT NULL, `transactionData` BLOB NOT NULL, `context` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `blockHash` BLOB, `blockTimestamp` INTEGER NOT NULL, `blockPosition` INTEGER NOT NULL, `hasBlockPosition` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `transactionType` TEXT NOT NULL, `transactionTypeKind` INTEGER NOT NULL, `netAmount` INTEGER NOT NULL, `fee` INTEGER, `label` TEXT NOT NULL, `firstSeen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`txid`))", + "fields": [ + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionData", + "columnName": "transactionData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "context", + "columnName": "context", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHash", + "columnName": "blockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "blockTimestamp", + "columnName": "blockTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockPosition", + "columnName": "blockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockPosition", + "columnName": "hasBlockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transactionType", + "columnName": "transactionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionTypeKind", + "columnName": "transactionTypeKind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "netAmount", + "columnName": "netAmount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER" + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstSeen", + "columnName": "firstSeen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "txid" + ] + }, + "indices": [ + { + "name": "index_transactions_firstSeen", + "unique": false, + "columnNames": [ + "firstSeen" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_firstSeen` ON `${TABLE_NAME}` (`firstSeen`)" + } + ] + }, + { + "tableName": "transaction_account_involvements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`transactionTxid` BLOB NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`transactionTxid`, `accountId`), FOREIGN KEY(`transactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "transactionTxid", + "columnName": "transactionTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "transactionTxid", + "accountId" + ] + }, + "indices": [ + { + "name": "index_transaction_account_involvements_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transaction_account_involvements_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "transactionTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "txos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outpoint` BLOB NOT NULL, `vout` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `address` TEXT NOT NULL, `scriptPubKey` BLOB NOT NULL, `height` INTEGER NOT NULL, `isCoinbase` INTEGER NOT NULL, `isConfirmed` INTEGER NOT NULL, `isInstantLocked` INTEGER NOT NULL, `isLocked` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `txid` BLOB, `spendingTxid` BLOB, `spendingInputIndex` INTEGER, `accountId` INTEGER, `coreAddressId` TEXT, PRIMARY KEY(`outpoint`), FOREIGN KEY(`txid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`spendingTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`coreAddressId`) REFERENCES `core_addresses`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "vout", + "columnName": "vout", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scriptPubKey", + "columnName": "scriptPubKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCoinbase", + "columnName": "isCoinbase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isConfirmed", + "columnName": "isConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstantLocked", + "columnName": "isInstantLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "isLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingInputIndex", + "columnName": "spendingInputIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreAddressId", + "columnName": "coreAddressId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outpoint" + ] + }, + "indices": [ + { + "name": "index_txos_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_txos_txid", + "unique": false, + "columnNames": [ + "txid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_txid` ON `${TABLE_NAME}` (`txid`)" + }, + { + "name": "index_txos_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_txos_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_txos_coreAddressId", + "unique": false, + "columnNames": [ + "coreAddressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_coreAddressId` ON `${TABLE_NAME}` (`coreAddressId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "txid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "transactions", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "core_addresses", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "coreAddressId" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "core_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `poolTypeTag` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "poolTypeTag", + "columnName": "poolTypeTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + }, + "indices": [ + { + "name": "index_core_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_core_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "asset_locks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `walletId` BLOB NOT NULL, `transactionBytes` BLOB NOT NULL, `fundingTypeRaw` INTEGER NOT NULL, `identityIndexRaw` INTEGER NOT NULL, `accountIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `proofBytes` BLOB, `recipientPlatformAddressHash` BLOB, `recipientPlatformAddressType` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionBytes", + "columnName": "transactionBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingTypeRaw", + "columnName": "fundingTypeRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityIndexRaw", + "columnName": "identityIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndexRaw", + "columnName": "accountIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proofBytes", + "columnName": "proofBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressHash", + "columnName": "recipientPlatformAddressHash", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressType", + "columnName": "recipientPlatformAddressType", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_asset_locks_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_asset_locks_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "identities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` BLOB NOT NULL, `balance` INTEGER NOT NULL, `revision` INTEGER NOT NULL, `isLocal` INTEGER NOT NULL, `alias` TEXT, `dpnsName` TEXT, `mainDpnsName` TEXT, `identityType` TEXT NOT NULL, `votingPrivateKeyIdentifier` TEXT, `ownerPrivateKeyIdentifier` TEXT, `payoutPrivateKeyIdentifier` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `networkRaw` INTEGER NOT NULL, `walletId` BLOB, `identityIndex` INTEGER NOT NULL, PRIMARY KEY(`identityId`), FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT" + }, + { + "fieldPath": "dpnsName", + "columnName": "dpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "mainDpnsName", + "columnName": "mainDpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "identityType", + "columnName": "identityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "votingPrivateKeyIdentifier", + "columnName": "votingPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerPrivateKeyIdentifier", + "columnName": "ownerPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "payoutPrivateKeyIdentifier", + "columnName": "payoutPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB" + }, + { + "fieldPath": "identityIndex", + "columnName": "identityIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identityId" + ] + }, + "indices": [ + { + "name": "index_identities_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_identities_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "public_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyId` INTEGER NOT NULL, `purpose` TEXT NOT NULL, `securityLevel` TEXT NOT NULL, `keyType` TEXT NOT NULL, `readOnly` INTEGER NOT NULL, `disabledAt` INTEGER, `publicKeyData` BLOB NOT NULL, `contractBoundsData` BLOB, `contractBoundsDocumentTypeName` TEXT, `privateKeyKeychainIdentifier` TEXT, `derivationIdentityIndex` INTEGER, `derivationKeyIndex` INTEGER, `identityId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessed` INTEGER, `identityIdData` BLOB, FOREIGN KEY(`identityIdData`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyType", + "columnName": "keyType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readOnly", + "columnName": "readOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disabledAt", + "columnName": "disabledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "publicKeyData", + "columnName": "publicKeyData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractBoundsData", + "columnName": "contractBoundsData", + "affinity": "BLOB" + }, + { + "fieldPath": "contractBoundsDocumentTypeName", + "columnName": "contractBoundsDocumentTypeName", + "affinity": "TEXT" + }, + { + "fieldPath": "privateKeyKeychainIdentifier", + "columnName": "privateKeyKeychainIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "derivationIdentityIndex", + "columnName": "derivationIdentityIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "derivationKeyIndex", + "columnName": "derivationKeyIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessed", + "columnName": "lastAccessed", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityIdData", + "columnName": "identityIdData", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_public_keys_identityId_keyId", + "unique": false, + "columnNames": [ + "identityId", + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityId_keyId` ON `${TABLE_NAME}` (`identityId`, `keyId`)" + }, + { + "name": "index_public_keys_identityIdData", + "unique": false, + "columnNames": [ + "identityIdData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityIdData` ON `${TABLE_NAME}` (`identityIdData`)" + }, + { + "name": "index_public_keys_publicKeyData", + "unique": false, + "columnNames": [ + "publicKeyData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_publicKeyData` ON `${TABLE_NAME}` (`publicKeyData`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityIdData" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dpns_names", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `label` TEXT NOT NULL, `normalizedLabel` TEXT NOT NULL, `parentDomainName` TEXT NOT NULL, `normalizedParentDomainName` TEXT NOT NULL, `acquiredAt` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `normalizedParentDomainName`, `normalizedLabel`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedLabel", + "columnName": "normalizedLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentDomainName", + "columnName": "parentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedParentDomainName", + "columnName": "normalizedParentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "acquiredAt", + "columnName": "acquiredAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "normalizedParentDomainName", + "normalizedLabel" + ] + }, + "indices": [ + { + "name": "index_dpns_names_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_identityId` ON `${TABLE_NAME}` (`identityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `identityId`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "identityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_profiles_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_profiles_identityId` ON `${TABLE_NAME}` (`identityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `isOutgoing` INTEGER NOT NULL, `senderKeyIndex` INTEGER NOT NULL, `recipientKeyIndex` INTEGER NOT NULL, `accountReference` INTEGER NOT NULL, `encryptedPublicKey` BLOB NOT NULL, `encryptedAccountLabel` BLOB, `autoAcceptProof` BLOB, `coreHeightCreatedAt` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `paymentChannelBroken` INTEGER NOT NULL DEFAULT 0, `contactAlias` TEXT, `contactNote` TEXT, `contactHidden` INTEGER NOT NULL DEFAULT 0, `contactAccountLabel` TEXT, `contactAcceptedAccounts` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`, `isOutgoing`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "isOutgoing", + "columnName": "isOutgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderKeyIndex", + "columnName": "senderKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recipientKeyIndex", + "columnName": "recipientKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountReference", + "columnName": "accountReference", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedPublicKey", + "columnName": "encryptedPublicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptedAccountLabel", + "columnName": "encryptedAccountLabel", + "affinity": "BLOB" + }, + { + "fieldPath": "autoAcceptProof", + "columnName": "autoAcceptProof", + "affinity": "BLOB" + }, + { + "fieldPath": "coreHeightCreatedAt", + "columnName": "coreHeightCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paymentChannelBroken", + "columnName": "paymentChannelBroken", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAlias", + "columnName": "contactAlias", + "affinity": "TEXT" + }, + { + "fieldPath": "contactNote", + "columnName": "contactNote", + "affinity": "TEXT" + }, + { + "fieldPath": "contactHidden", + "columnName": "contactHidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAccountLabel", + "columnName": "contactAccountLabel", + "affinity": "TEXT" + }, + { + "fieldPath": "contactAcceptedAccounts", + "columnName": "contactAcceptedAccounts", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId", + "isOutgoing" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_requests_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_requests_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_ignored_senders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `ignoredSenderId` BLOB NOT NULL, `ignoredAt` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `ignoredSenderId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredSenderId", + "columnName": "ignoredSenderId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredAt", + "columnName": "ignoredAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "ignoredSenderId" + ] + }, + "indices": [ + { + "name": "index_dashpay_ignored_senders_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_ignored_senders_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `checkedAtMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "checkedAtMs", + "columnName": "checkedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_profiles_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_profiles_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_payments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `counterpartyIdentityId` BLOB NOT NULL, `amountDuffs` INTEGER NOT NULL, `directionRaw` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `txid` TEXT NOT NULL, `memo` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `txid`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directionRaw", + "columnName": "directionRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "txid" + ] + }, + "indices": [ + { + "name": "index_dashpay_payments_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_payments_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "data_contracts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `name` TEXT NOT NULL, `serializedContract` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, `binarySerialization` BLOB, `version` INTEGER, `ownerId` BLOB, `contractDescription` TEXT, `schemaData` BLOB NOT NULL, `documentTypesData` BLOB NOT NULL, `groupsData` BLOB, `networkRaw` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `canBeDeleted` INTEGER NOT NULL, `readonly` INTEGER NOT NULL, `keepsHistory` INTEGER NOT NULL, `schemaDefs` INTEGER, `documentsKeepHistoryContractDefault` INTEGER NOT NULL, `documentsMutableContractDefault` INTEGER NOT NULL, `documentsCanBeDeletedContractDefault` INTEGER NOT NULL, `hasTokens` INTEGER NOT NULL, `tokensData` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serializedContract", + "columnName": "serializedContract", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "binarySerialization", + "columnName": "binarySerialization", + "affinity": "BLOB" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "BLOB" + }, + { + "fieldPath": "contractDescription", + "columnName": "contractDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "schemaData", + "columnName": "schemaData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypesData", + "columnName": "documentTypesData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "groupsData", + "columnName": "groupsData", + "affinity": "BLOB" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "canBeDeleted", + "columnName": "canBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readonly", + "columnName": "readonly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsHistory", + "columnName": "keepsHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "schemaDefs", + "columnName": "schemaDefs", + "affinity": "INTEGER" + }, + { + "fieldPath": "documentsKeepHistoryContractDefault", + "columnName": "documentsKeepHistoryContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutableContractDefault", + "columnName": "documentsMutableContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeletedContractDefault", + "columnName": "documentsCanBeDeletedContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasTokens", + "columnName": "hasTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokensData", + "columnName": "tokensData", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_data_contracts_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_data_contracts_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "document_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `name` TEXT NOT NULL, `schemaJSON` BLOB NOT NULL, `propertiesJSON` BLOB NOT NULL, `documentsKeepHistory` INTEGER NOT NULL, `documentsMutable` INTEGER NOT NULL, `documentsCanBeDeleted` INTEGER NOT NULL, `documentsTransferable` INTEGER NOT NULL, `requiredFieldsJSON` BLOB, `securityLevel` INTEGER NOT NULL, `tradeMode` INTEGER NOT NULL, `creationRestrictionMode` INTEGER NOT NULL, `requiresIdentityEncryptionBoundedKey` INTEGER NOT NULL, `requiresIdentityDecryptionBoundedKey` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "schemaJSON", + "columnName": "schemaJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentsKeepHistory", + "columnName": "documentsKeepHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutable", + "columnName": "documentsMutable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeleted", + "columnName": "documentsCanBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsTransferable", + "columnName": "documentsTransferable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiredFieldsJSON", + "columnName": "requiredFieldsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationRestrictionMode", + "columnName": "creationRestrictionMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityEncryptionBoundedKey", + "columnName": "requiresIdentityEncryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityDecryptionBoundedKey", + "columnName": "requiresIdentityDecryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_document_types_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_document_types_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "documents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`documentId` TEXT NOT NULL, `documentType` TEXT NOT NULL, `revision` INTEGER NOT NULL, `data` BLOB NOT NULL, `contractId` TEXT NOT NULL, `ownerId` TEXT NOT NULL, `contractIdData` BLOB NOT NULL, `ownerIdData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `transferredAt` INTEGER, `createdAtBlockHeight` INTEGER, `updatedAtBlockHeight` INTEGER, `transferredAtBlockHeight` INTEGER, `createdAtCoreBlockHeight` INTEGER, `updatedAtCoreBlockHeight` INTEGER, `transferredAtCoreBlockHeight` INTEGER, `networkRaw` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `localCreatedAt` INTEGER NOT NULL, `localUpdatedAt` INTEGER NOT NULL, `documentTypeRelationId` BLOB, `dataContractId` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`documentId`), FOREIGN KEY(`documentTypeRelationId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentType", + "columnName": "documentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractIdData", + "columnName": "contractIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ownerIdData", + "columnName": "ownerIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferredAt", + "columnName": "transferredAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtBlockHeight", + "columnName": "createdAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtBlockHeight", + "columnName": "updatedAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtBlockHeight", + "columnName": "transferredAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtCoreBlockHeight", + "columnName": "createdAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtCoreBlockHeight", + "columnName": "updatedAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtCoreBlockHeight", + "columnName": "transferredAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localCreatedAt", + "columnName": "localCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "localUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeRelationId", + "columnName": "documentTypeRelationId", + "affinity": "BLOB" + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "documentId" + ] + }, + "indices": [ + { + "name": "index_documents_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_documents_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_documents_ownerId", + "unique": false, + "columnNames": [ + "ownerId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerId` ON `${TABLE_NAME}` (`ownerId`)" + }, + { + "name": "index_documents_documentTypeRelationId", + "unique": false, + "columnNames": [ + "documentTypeRelationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_documentTypeRelationId` ON `${TABLE_NAME}` (`documentTypeRelationId`)" + }, + { + "name": "index_documents_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + }, + { + "name": "index_documents_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeRelationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "indices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `unique` INTEGER NOT NULL, `nullSearchable` INTEGER NOT NULL, `contested` INTEGER NOT NULL, `propertiesJSON` BLOB NOT NULL, `contestedDetailsJSON` BLOB, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unique", + "columnName": "unique", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nullSearchable", + "columnName": "nullSearchable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contested", + "columnName": "contested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contestedDetailsJSON", + "columnName": "contestedDetailsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_indices_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_indices_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `keyword` TEXT NOT NULL, `contractId` TEXT NOT NULL, `dataContractId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keywords_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_keywords_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "properties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `format` TEXT, `contentMediaType` TEXT, `byteArray` INTEGER NOT NULL, `minItems` INTEGER, `maxItems` INTEGER, `pattern` TEXT, `minLength` INTEGER, `maxLength` INTEGER, `minValue` INTEGER, `maxValue` INTEGER, `fieldDescription` TEXT, `transient` INTEGER NOT NULL, `isRequired` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT" + }, + { + "fieldPath": "contentMediaType", + "columnName": "contentMediaType", + "affinity": "TEXT" + }, + { + "fieldPath": "byteArray", + "columnName": "byteArray", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minItems", + "columnName": "minItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxItems", + "columnName": "maxItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT" + }, + { + "fieldPath": "minLength", + "columnName": "minLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxLength", + "columnName": "maxLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "fieldDescription", + "columnName": "fieldDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "transient", + "columnName": "transient", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRequired", + "columnName": "isRequired", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_properties_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_properties_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "pending_inputs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `outpoint` BLOB NOT NULL, `inputIndex` INTEGER NOT NULL, `spendingTxid` BLOB NOT NULL, `spendingTransactionTxid` BLOB, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`spendingTransactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "inputIndex", + "columnName": "inputIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spendingTransactionTxid", + "columnName": "spendingTransactionTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_pending_inputs_outpoint", + "unique": false, + "columnNames": [ + "outpoint" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_outpoint` ON `${TABLE_NAME}` (`outpoint`)" + }, + { + "name": "index_pending_inputs_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_pending_inputs_spendingTransactionTxid", + "unique": false, + "columnNames": [ + "spendingTransactionTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTransactionTxid` ON `${TABLE_NAME}` (`spendingTransactionTxid`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTransactionTxid" + ], + "referencedColumns": [ + "txid" + ] + } + ] + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `position` INTEGER NOT NULL, `name` TEXT NOT NULL, `baseSupply` TEXT NOT NULL, `maxSupply` TEXT, `decimals` INTEGER NOT NULL, `localizations` TEXT, `isPaused` INTEGER NOT NULL, `allowTransferToFrozenBalance` INTEGER NOT NULL, `keepsTransferHistory` INTEGER NOT NULL, `keepsFreezingHistory` INTEGER NOT NULL, `keepsMintingHistory` INTEGER NOT NULL, `keepsBurningHistory` INTEGER NOT NULL, `keepsDirectPricingHistory` INTEGER NOT NULL, `keepsDirectPurchaseHistory` INTEGER NOT NULL, `conventionsChangeRules` TEXT, `maxSupplyChangeRules` TEXT, `manualMintingRules` TEXT, `manualBurningRules` TEXT, `freezeRules` TEXT, `unfreezeRules` TEXT, `destroyFrozenFundsRules` TEXT, `emergencyActionRules` TEXT, `perpetualDistribution` TEXT, `preProgrammedDistribution` TEXT, `newTokensDestinationIdentity` BLOB, `mintingAllowChoosingDestination` INTEGER NOT NULL, `distributionChangeRules` TEXT, `tradeMode` TEXT NOT NULL, `tradeModeChangeRules` TEXT, `mainControlGroupPosition` INTEGER, `mainControlGroupCanBeModified` TEXT, `tokenDescription` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `canManuallyMint` INTEGER NOT NULL, `canManuallyBurn` INTEGER NOT NULL, `canFreeze` INTEGER NOT NULL, `canUnfreeze` INTEGER NOT NULL, `canDestroyFrozenFunds` INTEGER NOT NULL, `hasEmergencyActions` INTEGER NOT NULL, `canChangeMaxSupply` INTEGER NOT NULL, `canChangeConventions` INTEGER NOT NULL, `canChangeTradeMode` INTEGER NOT NULL, `hasDistribution` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseSupply", + "columnName": "baseSupply", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSupply", + "columnName": "maxSupply", + "affinity": "TEXT" + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localizations", + "columnName": "localizations", + "affinity": "TEXT" + }, + { + "fieldPath": "isPaused", + "columnName": "isPaused", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowTransferToFrozenBalance", + "columnName": "allowTransferToFrozenBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsTransferHistory", + "columnName": "keepsTransferHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsFreezingHistory", + "columnName": "keepsFreezingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsMintingHistory", + "columnName": "keepsMintingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsBurningHistory", + "columnName": "keepsBurningHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPricingHistory", + "columnName": "keepsDirectPricingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPurchaseHistory", + "columnName": "keepsDirectPurchaseHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conventionsChangeRules", + "columnName": "conventionsChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "maxSupplyChangeRules", + "columnName": "maxSupplyChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualMintingRules", + "columnName": "manualMintingRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualBurningRules", + "columnName": "manualBurningRules", + "affinity": "TEXT" + }, + { + "fieldPath": "freezeRules", + "columnName": "freezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "unfreezeRules", + "columnName": "unfreezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "destroyFrozenFundsRules", + "columnName": "destroyFrozenFundsRules", + "affinity": "TEXT" + }, + { + "fieldPath": "emergencyActionRules", + "columnName": "emergencyActionRules", + "affinity": "TEXT" + }, + { + "fieldPath": "perpetualDistribution", + "columnName": "perpetualDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "preProgrammedDistribution", + "columnName": "preProgrammedDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "newTokensDestinationIdentity", + "columnName": "newTokensDestinationIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "mintingAllowChoosingDestination", + "columnName": "mintingAllowChoosingDestination", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distributionChangeRules", + "columnName": "distributionChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tradeModeChangeRules", + "columnName": "tradeModeChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "mainControlGroupPosition", + "columnName": "mainControlGroupPosition", + "affinity": "INTEGER" + }, + { + "fieldPath": "mainControlGroupCanBeModified", + "columnName": "mainControlGroupCanBeModified", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDescription", + "columnName": "tokenDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdatedAt", + "columnName": "lastUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyMint", + "columnName": "canManuallyMint", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyBurn", + "columnName": "canManuallyBurn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canFreeze", + "columnName": "canFreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canUnfreeze", + "columnName": "canUnfreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDestroyFrozenFunds", + "columnName": "canDestroyFrozenFunds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasEmergencyActions", + "columnName": "hasEmergencyActions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeMaxSupply", + "columnName": "canChangeMaxSupply", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeConventions", + "columnName": "canChangeConventions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeTradeMode", + "columnName": "canChangeTradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDistribution", + "columnName": "hasDistribution", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tokens_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tokens_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_balances", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tokenId` TEXT NOT NULL, `identityId` BLOB NOT NULL, `balance` BLOB NOT NULL, `frozen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `tokenName` TEXT, `tokenSymbol` TEXT, `tokenDecimals` INTEGER, `networkRaw` INTEGER NOT NULL, `identityRef` BLOB, `tokenRef` BLOB, FOREIGN KEY(`identityRef`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenId", + "columnName": "tokenId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "frozen", + "columnName": "frozen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "tokenName", + "columnName": "tokenName", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenSymbol", + "columnName": "tokenSymbol", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDecimals", + "columnName": "tokenDecimals", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityRef", + "columnName": "identityRef", + "affinity": "BLOB" + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_balances_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_token_balances_tokenId_identityId", + "unique": false, + "columnNames": [ + "tokenId", + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenId_identityId` ON `${TABLE_NAME}` (`tokenId`, `identityId`)" + }, + { + "name": "index_token_balances_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_token_balances_identityRef", + "unique": false, + "columnNames": [ + "identityRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityRef` ON `${TABLE_NAME}` (`identityRef`)" + }, + { + "name": "index_token_balances_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "identityRef" + ], + "referencedColumns": [ + "identityId" + ] + }, + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_history_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `eventType` TEXT NOT NULL, `transactionId` BLOB, `blockHeight` INTEGER, `coreBlockHeight` INTEGER, `fromIdentity` BLOB, `toIdentity` BLOB, `performedByIdentity` BLOB NOT NULL, `amount` TEXT, `balanceBefore` TEXT, `balanceAfter` TEXT, `additionalDataJSON` BLOB, `eventDescription` TEXT, `createdAt` INTEGER NOT NULL, `eventTimestamp` INTEGER NOT NULL, `tokenRef` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionId", + "columnName": "transactionId", + "affinity": "BLOB" + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreBlockHeight", + "columnName": "coreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "fromIdentity", + "columnName": "fromIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "toIdentity", + "columnName": "toIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "performedByIdentity", + "columnName": "performedByIdentity", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceBefore", + "columnName": "balanceBefore", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceAfter", + "columnName": "balanceAfter", + "affinity": "TEXT" + }, + { + "fieldPath": "additionalDataJSON", + "columnName": "additionalDataJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "eventDescription", + "columnName": "eventDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventTimestamp", + "columnName": "eventTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_history_events_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_history_events_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `addressType` INTEGER NOT NULL, `addressHash` BLOB NOT NULL, `publicKey` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `nonce` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`walletId`, `address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressType", + "columnName": "addressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressHash", + "columnName": "addressHash", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "address" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_walletId_addressHash", + "unique": true, + "columnNames": [ + "walletId", + "addressHash" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_platform_addresses_walletId_addressHash` ON `${TABLE_NAME}` (`walletId`, `addressHash`)" + }, + { + "name": "index_platform_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `networkRaw` INTEGER NOT NULL, `syncHeight` INTEGER NOT NULL, `syncTimestamp` INTEGER NOT NULL, `lastKnownRecentBlock` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncHeight", + "columnName": "syncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncTimestamp", + "columnName": "syncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastKnownRecentBlock", + "columnName": "lastKnownRecentBlock", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_sync_states_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_sync_states_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + } + ] + }, + { + "tableName": "shielded_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nullifier` BLOB NOT NULL, `walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `position` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `value` INTEGER NOT NULL, `noteData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`nullifier`))", + "fields": [ + { + "fieldPath": "nullifier", + "columnName": "nullifier", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteData", + "columnName": "noteData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nullifier" + ] + }, + "indices": [ + { + "name": "index_shielded_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_outgoing_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `recipient` BLOB NOT NULL, `value` INTEGER NOT NULL, `memo` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `cmx`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "recipient", + "columnName": "recipient", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "cmx" + ] + }, + "indices": [ + { + "name": "index_shielded_outgoing_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_outgoing_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_activities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `entryId` BLOB NOT NULL, `kindTag` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `status` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `fee` INTEGER NOT NULL, `hasFee` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `hasBlockHeight` INTEGER NOT NULL, `createdAtMs` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `counterparty` BLOB NOT NULL, `memo` BLOB NOT NULL, `noteCmxs` BLOB NOT NULL, `spentNullifiers` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `entryId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entryId", + "columnName": "entryId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "kindTag", + "columnName": "kindTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasFee", + "columnName": "hasFee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockHeight", + "columnName": "hasBlockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterparty", + "columnName": "counterparty", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "noteCmxs", + "columnName": "noteCmxs", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spentNullifiers", + "columnName": "spentNullifiers", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "entryId" + ] + }, + "indices": [ + { + "name": "index_shielded_activities_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_activities_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `lastSyncedIndex` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedIndex", + "columnName": "lastSyncedIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_sync_states_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_sync_states_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "shielded_viewing_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `fvkBytes` BLOB NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fvkBytes", + "columnName": "fvkBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_viewing_keys_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_viewing_keys_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "wallet_manager_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `combinedSyncHeight` INTEGER NOT NULL, `combinedSyncBlockHash` BLOB, `walletCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`))", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncHeight", + "columnName": "combinedSyncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncBlockHash", + "columnName": "combinedSyncBlockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "walletCount", + "columnName": "walletCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7c05146891642d5abd6a173803616b94')" + ] + } +} \ No newline at end of file diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt index 9ba7c7b1da..ddb55ed666 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt @@ -264,27 +264,82 @@ class DashDatabaseMigrationTest { db.close() } + /** + * v7 → v8 adds the nullable derivation-breadcrumb columns on + * `public_keys` (dashpay/platform#4060 finding 5). Pre-existing rows + * must survive with NULL breadcrumbs (unknown — back-filled by the next + * persist of each key), and new rows must accept explicit values. + */ + @Test + fun migrate7To8AddsDerivationBreadcrumbColumns() { + helper.createDatabase(dbName, 7).apply { + execSQL( + "INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, " + + "syncedHeight, lastSynced, isImported, createdAt, lastUpdated) " + + "VALUES (x'01', x'02', 1, 'w', 0, 0, 0, 0, 0, 0)", + ) + execSQL( + "INSERT INTO identities (identityId, balance, revision, isLocal, identityType, " + + "createdAt, lastUpdated, networkRaw, identityIndex, walletId) " + + "VALUES (x'0A', 0, 0, 1, 'User', 0, 0, 1, 0, x'01')", + ) + execSQL( + "INSERT INTO public_keys (keyId, purpose, securityLevel, keyType, readOnly, " + + "publicKeyData, identityId, createdAt, identityIdData) " + + "VALUES (0, '0', '0', '0', 0, x'02AB', 'id-base58', 0, x'0A')", + ) + close() + } + + val db = helper.runMigrationsAndValidate(dbName, 8, true, DashDatabase.MIGRATION_7_8) + + // Pre-existing rows survive with NULL breadcrumbs. + db.query( + "SELECT derivationIdentityIndex, derivationKeyIndex FROM public_keys WHERE keyId = 0", + ).use { c -> + assertTrue(c.moveToFirst()) + assertTrue(c.isNull(0)) + assertTrue(c.isNull(1)) + } + // New rows accept explicit breadcrumbs. + db.execSQL( + "INSERT INTO public_keys (keyId, purpose, securityLevel, keyType, readOnly, " + + "publicKeyData, identityId, createdAt, identityIdData, " + + "derivationIdentityIndex, derivationKeyIndex) " + + "VALUES (1, '0', '0', '0', 0, x'02CD', 'id-base58', 0, x'0A', 3, 5)", + ) + db.query( + "SELECT derivationIdentityIndex, derivationKeyIndex FROM public_keys WHERE keyId = 1", + ).use { c -> + assertTrue(c.moveToFirst()) + assertEquals(3, c.getInt(0)) + assertEquals(5, c.getInt(1)) + } + db.close() + } + /** The requested contiguous path from the pre-u64 v4 schema to latest. */ @Test fun migrate4ToLatest() { helper.createDatabase(dbName, 4).close() helper.runMigrationsAndValidate( dbName, - 7, + 8, true, DashDatabase.MIGRATION_4_5, DashDatabase.MIGRATION_5_6, DashDatabase.MIGRATION_6_7, + DashDatabase.MIGRATION_7_8, ).close() } - /** The full chain from v1 must also land on a valid v7 schema. */ + /** The full chain from v1 must also land on a valid v8 schema. */ @Test fun migrateAllTheWayFrom1() { helper.createDatabase(dbName, 1).close() helper.runMigrationsAndValidate( dbName, - 7, + 8, true, DashDatabase.MIGRATION_1_2, DashDatabase.MIGRATION_2_3, @@ -292,6 +347,7 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_4_5, DashDatabase.MIGRATION_5_6, DashDatabase.MIGRATION_6_7, + DashDatabase.MIGRATION_7_8, ).close() } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index 95074cb0e6..7dc3dfa525 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt @@ -99,9 +99,17 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * Version 7 (provider restore): adds transaction block position and an * explicit transaction↔typed-account involvement table for payload-only * provider transactions. + * + * Version 8 (durable repair signal, dashpay/platform#4060): adds the + * nullable `public_keys.derivationIdentityIndex` / `derivationKeyIndex` + * derivation-breadcrumb columns, so identity keys whose private half is + * missing or undecryptable can re-seed the pending-repair state after a + * process restart. Room is the durability substrate deliberately: the + * wallet-deletion cascade removes these rows, so pending entries die with + * their wallet automatically (a DataStore side-table would leak). */ @Database( - version = 7, + version = 8, exportSchema = true, entities = [ WalletEntity::class, @@ -449,6 +457,24 @@ abstract class DashDatabase : RoomDatabase() { } } + /** + * v7 → v8: additive nullable derivation-breadcrumb columns on + * `public_keys` (dashpay/platform#4060 finding 5). NULL for every + * pre-existing row — correct, because a legacy row's breadcrumbs are + * unknown; the persist callback back-fills them on the next upsert of + * each key, after which the pending-repair reconstruction can see it. + */ + val MIGRATION_7_8: Migration = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE `public_keys` ADD COLUMN `derivationIdentityIndex` INTEGER", + ) + db.execSQL( + "ALTER TABLE `public_keys` ADD COLUMN `derivationKeyIndex` INTEGER", + ) + } + } + /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -464,6 +490,7 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, + MIGRATION_7_8, ) .build() 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 e5efa86679..4a9ea9ac3a 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 @@ -1197,6 +1197,15 @@ class PlatformWalletPersistenceHandler( // watch-only wallets / no-deriver builds. privateKeyKeychainIdentifier = derivedKeychainId ?: existing?.privateKeyKeychainIdentifier, + // Derivation breadcrumbs are recorded whenever Rust supplied + // them — success AND failure paths (the breadcrumb is not a + // failure marker; the null identifier is). They make the + // pending-repair state reconstructible after restart + // (dashpay/platform#4060 finding 5). + derivationIdentityIndex = + if (derivationIndicesIsSome) identityIndex else existing?.derivationIdentityIndex, + derivationKeyIndex = + if (derivationIndicesIsSome) keyIndex else existing?.derivationKeyIndex, identityId = identityBase58, identityIdData = identityId, createdAt = existing?.createdAt ?: java.util.Date(), @@ -2616,6 +2625,68 @@ class PlatformWalletPersistenceHandler( _pendingIdentityKeys.update(clearPendingKeyDelta(publicKeyHex)) } + /** + * Rebuild [pendingIdentityKeys] from persistence after a process restart + * (dashpay/platform#4060 finding 5) — the in-memory map is process- + * lifetime only, but the durable `public_keys` rows carry the derivation + * breadcrumbs. A row is (re-)seeded when it has breadcrumbs AND its + * private half is unusable: either no keychain identifier was ever + * recorded (the derive failed at persist time), or the identifier exists + * but [isPrivateKeyDecryptable] (the CHEAP capability check — no + * decrypt, no prompt, no key generation) rejects the stored blob — the + * second disjunct resurrects the repair slot for blobs stranded by a + * Keystore keypair replacement, not just never-derived ones. Read-only + * keys are never seeded (they are not ours to derive). + * + * Seeding is ONE atomic [MutableStateFlow.update]; live entries (from + * callbacks that already fired this process) are never overwritten — + * their reason/timestamp are fresher. Publishes immediately: no round is + * open at load time, same as [markIdentityKeyRepaired]. + * + * Called by `PlatformWalletManager.loadPersistedWallets` after the Room + * rows are loaded, before the manager is handed to the host; the wallet + * scoping comes from each row's identity (network + wallet id), matching + * this handler's [network] when set. + */ + internal suspend fun reconstructPendingIdentityKeysFromPersistence( + isPrivateKeyDecryptable: suspend (pubkeyHex: String) -> Boolean, + nowMs: Long = System.currentTimeMillis(), + ) { + val rows = database.publicKeyDao().getWithDerivationBreadcrumbs() + if (rows.isEmpty()) return + val entries = mutableListOf() + for (row in rows) { + if (row.readOnly) continue + val identityIndex = row.derivationIdentityIndex ?: continue + val keyIndex = row.derivationKeyIndex ?: continue + val identityIdData = row.identityIdData ?: continue + val identity = database.identityDao().getByIdentityId(identityIdData) ?: continue + val networkRaw = network?.ffiValue + if (networkRaw != null && identity.networkRaw != networkRaw) continue + val walletId = identity.walletId ?: continue + val pubkeyHex = row.publicKeyData.toHex() + val usable = row.privateKeyKeychainIdentifier != null && + runCatching { isPrivateKeyDecryptable(pubkeyHex) }.getOrDefault(false) + if (usable) continue + entries += PendingIdentityKey( + walletIdHex = walletId.toHex(), + identityIdBase58 = row.identityId, + keyId = row.keyId, + publicKeyHex = pubkeyHex, + identityIndex = identityIndex, + keyIndex = keyIndex, + reason = "reconstructed from persistence after restart", + failedAtMs = nowMs, + ) + } + if (entries.isEmpty()) return + _pendingIdentityKeys.update { map -> + entries.fold(map) { acc, entry -> + if (entry.publicKeyHex in acc) acc else acc + (entry.publicKeyHex to entry) + } + } + } + // ── Error / threading guards ────────────────────────────────────── /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/PublicKeyDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/PublicKeyDao.kt index cf92f41e24..a0a92134ae 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/PublicKeyDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/PublicKeyDao.kt @@ -57,6 +57,19 @@ interface PublicKeyDao { ownedIdentityIds: List, ): Int + /** + * Rows carrying a derivation breadcrumb (dashpay/platform#4060 finding + * 5) — the candidate set for reconstructing the pending-repair state + * after a restart. Kotlin-side filtering decides which of these are + * actually unrecoverable (null keychain identifier, or a stored blob the + * cheap capability check rejects). + */ + @Query( + "SELECT * FROM public_keys WHERE derivationIdentityIndex IS NOT NULL " + + "AND derivationKeyIndex IS NOT NULL", + ) + suspend fun getWithDerivationBreadcrumbs(): List + @Insert suspend fun insert(publicKey: PublicKeyEntity): Long diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt index 7680a76cf8..8ac11a3762 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt @@ -57,6 +57,20 @@ data class PublicKeyEntity( /** Document-type qualifier for `.singleContractDocumentType` bounds. */ val contractBoundsDocumentTypeName: String? = null, val privateKeyKeychainIdentifier: String? = null, + /** + * Derivation breadcrumb (DIP-9 identity index) captured from the + * persist-callback payload whenever Rust supplied derivation indices — + * on SUCCESSFUL derives too, not just failures (the breadcrumb is not a + * failure marker; a null [privateKeyKeychainIdentifier] is). Makes the + * "keys pending repair" state reconstructible after process restart: + * a row with breadcrumbs whose private half is missing or undecryptable + * re-seeds `PlatformWalletPersistenceHandler.pendingIdentityKeys` + * (dashpay/platform#4060 finding 5). Null for rows persisted without + * derivation indices (read-only / foreign keys). + */ + val derivationIdentityIndex: Int? = null, + /** Derivation breadcrumb (key index) — see [derivationIdentityIndex]. */ + val derivationKeyIndex: Int? = null, /** Owning identity id as base58 String (Swift stores String here). */ val identityId: String, val createdAt: Date = Date(), 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 b146752f91..61faa1094c 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 @@ -951,6 +951,18 @@ class PlatformWalletManager( suspend fun loadPersistedWallets(): List = withContext(Dispatchers.IO) { mapNativeErrors { WalletManagerNative.loadFromPersistor(managerHandle) } + // Rebuild the durable "keys pending repair" state before the manager + // is handed to the host (dashpay/platform#4060 finding 5): rows with + // derivation breadcrumbs whose private half is missing or fails the + // CHEAP capability check re-seed pendingIdentityKeys, so a repair + // signal recorded before a process death (or a blob stranded by a + // Keystore keypair replacement) resurfaces on every launch. + runCatching { + persistenceHandler.reconstructPendingIdentityKeysFromPersistence( + isPrivateKeyDecryptable = { walletStorage.isPrivateKeyDecryptable(it) }, + ) + } + // Room is the source of truth for the restorable id list — the same // rows the load callback just fed to Rust. Scope to THIS network so // a per-network manager only restores its own wallets (matching the 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 f0c4ff8374..88e22b8336 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 @@ -1238,6 +1238,113 @@ class PlatformWalletPersistenceHandlerTest { assertNotNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) } + // ── Pending-key reconstruction after restart (#4060 finding 5) ───── + + @Test + fun reconstructionSeedsPendingFromBreadcrumbRowsWithNullIdentifier() = runTest { + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + val identityId = ByteArray(32) { 21 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 12 } + upsertIdentityKey(pubkey, identityId) // derive fails → watch-only + breadcrumbs + + // Model a process restart: a fresh handler starts with an empty + // in-memory map, then rebuilds it from the durable rows. + val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + assertTrue(restarted.pendingIdentityKeys.value.isEmpty()) + restarted.reconstructPendingIdentityKeysFromPersistence( + isPrivateKeyDecryptable = { false }, + nowMs = 42L, + ) + + val entry = restarted.pendingIdentityKeys.value[pubkey.toHex()] + assertNotNull("breadcrumb row with null identifier must re-seed", entry) + assertEquals(walletId.toHex(), entry!!.walletIdHex) + assertEquals(identityId.toBase58String(), entry.identityIdBase58) + assertEquals(0, entry.keyId) + assertEquals(3, entry.identityIndex) + assertEquals(5, entry.keyIndex) + assertEquals("reconstructed from persistence after restart", entry.reason) + assertEquals(42L, entry.failedAtMs) + } + + @Test + fun reconstructionSeedsStrandedBlobRowsDespiteRecordedIdentifier() = runTest { + // The derive SUCCEEDED at persist time (identifier recorded), but the + // stored blob no longer passes the cheap capability check — e.g. the + // Keystore keypair was replaced. The repair slot must resurface. + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, FakeDeriver()) + val identityId = ByteArray(32) { 22 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 13 } + upsertIdentityKey(pubkey, identityId) + assertTrue(handler.pendingIdentityKeys.value.isEmpty()) // healthy at persist time + + val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + restarted.reconstructPendingIdentityKeysFromPersistence( + isPrivateKeyDecryptable = { false }, // blob stranded + ) + assertNotNull(restarted.pendingIdentityKeys.value[pubkey.toHex()]) + } + + @Test + fun reconstructionSkipsHealthyRows() = runTest { + // Identifier recorded AND the blob still decrypts: nothing to repair, + // so a restart must not fabricate pending state. + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, FakeDeriver()) + val identityId = ByteArray(32) { 23 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 14 } + upsertIdentityKey(pubkey, identityId) + + val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + restarted.reconstructPendingIdentityKeysFromPersistence( + isPrivateKeyDecryptable = { true }, + ) + assertTrue(restarted.pendingIdentityKeys.value.isEmpty()) + } + + @Test + fun repairedRowUpdatePreventsReseeding() = runTest { + // A failed derive leaves a pending row; the repair path later records + // the identifier on the Room row (and the blob decrypts). The next + // restart's reconstruction must NOT resurrect the repaired key. + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + val identityId = ByteArray(32) { 24 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 16 } + upsertIdentityKey(pubkey, identityId) + + val row = db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!! + assertNull(row.privateKeyKeychainIdentifier) + db.publicKeyDao().update( + row.copy(privateKeyKeychainIdentifier = "privkey." + pubkey.toHex()), + ) + + val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + restarted.reconstructPendingIdentityKeysFromPersistence( + isPrivateKeyDecryptable = { true }, + ) + assertTrue(restarted.pendingIdentityKeys.value.isEmpty()) + } + + @Test + fun reconstructionNeverOverwritesALiveEntry() = runTest { + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + val identityId = ByteArray(32) { 25 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 17 } + upsertIdentityKey(pubkey, identityId) + + // The live entry (fresh reason/timestamp) must win over the + // reconstructed placeholder. + val liveReason = handler.pendingIdentityKeys.value[pubkey.toHex()]!!.reason + handler.reconstructPendingIdentityKeysFromPersistence( + isPrivateKeyDecryptable = { false }, + ) + assertEquals(liveReason, handler.pendingIdentityKeys.value[pubkey.toHex()]!!.reason) + } + // ── Shielded load round-trip ────────────────────────────────────── @Test From 9659f5f97cea3d3cc667609f189ce2cd779fa6eb Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:26:18 -0400 Subject: [PATCH 08/26] feat(kotlin-sdk): force-replace and verify in identity-key repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit repairIdentityKey previously routed through storeIfAbsent, whose shape+fingerprint usability short-circuit can be satisfied by a blob that does not actually decrypt — the derive was skipped and markIdentityKeyRepaired fired anyway, clearing the pending signal while the key stayed broken (dashpay/platform#4060 finding 6). - WalletStorage.replacePrivateKey: forced store with the same outside-the-lock derive discipline (and tombstone checks) as storeIfAbsent, but on re-entry it UNCONDITIONALLY replaces blob + fingerprint + alias tag in one atomic edit — never the addOwnerIfUsableLocked short-circuit. - PrivateKeyDeriver.deriveAndStore gains force: Boolean = false; IdentityKeyPrivateKeyDeriver routes force → replacePrivateKey. The persistence-callback call site keeps force = false (idempotent upserts must not re-derive on every sync). - PlatformWalletManager.repairIdentityKey: deriveAndStore(force = true), then VERIFY with the real-decrypt probeIdentityKeyRecoverability before clearing pending state — a wrong-key crypto failure fails the repair with a typed PlatformWallet.SigningKeyUnavailable (UserNotAuthenticatedException counts as verified: the key is present and opens after auth, and the just-written fingerprint rules out the wrong-key-behind-locked-gate ambiguity). Only after verification: markIdentityKeyRepaired + the Room rows' privateKeyKeychainIdentifier update, so the durable pending-repair reconstruction does not resurrect the repaired key. Co-Authored-By: Claude Fable 5 --- .../PlatformWalletPersistenceHandler.kt | 7 +++ .../security/IdentityKeyPrivateKeyDeriver.kt | 34 ++++++---- .../dashsdk/security/WalletStorage.kt | 32 ++++++++++ .../dashsdk/wallet/PlatformWalletManager.kt | 62 ++++++++++++++++--- .../PlatformWalletPersistenceHandlerTest.kt | 4 ++ .../WalletStorageUpgradeMatrixTest.kt | 59 ++++++++++++++++++ 6 files changed, 177 insertions(+), 21 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 4a9ea9ac3a..f32676e0dc 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 @@ -2777,12 +2777,19 @@ interface PrivateKeyDeriver { * * @param publicKeyData the compressed public-key bytes — used as the * storage key so the signer can locate the scalar. + * @param force when true, skip the "already usable" short-circuit and + * REPLACE the stored entry unconditionally — the repair path + * (dashpay/platform#4060 finding 6), where a shape+fingerprint-valid + * but undecryptable blob must not suppress the re-derive. The + * persistence-callback call site keeps the default `false` (idempotent + * upserts must not re-derive on every sync). */ fun deriveAndStore( walletId: ByteArray, publicKeyData: ByteArray, identityIndex: Int, keyIndex: Int, + force: Boolean = false, ): DerivedKeyStoreResult? /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt index 20ca9b96e0..5d680182ac 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt @@ -50,6 +50,7 @@ class IdentityKeyPrivateKeyDeriver( publicKeyData: ByteArray, identityIndex: Int, keyIndex: Int, + force: Boolean, ): DerivedKeyStoreResult { val pubkeyHex = publicKeyData.toHex() var scalar: ByteArray? = null @@ -62,19 +63,30 @@ class IdentityKeyPrivateKeyDeriver( // (the single FFI call below) ONLY when the alias doesn't already // have a usable stored scalar under ANY owner — a re-derive of an // existing key would just reproduce the same deterministic bytes. + // + // force = true (the repair path, dashpay/platform#4060 finding 6) + // routes through WalletStorage.replacePrivateKey instead: the + // usability short-circuit is exactly what must NOT run when a + // shape+fingerprint-valid but undecryptable blob is being repaired. + val deriveOnce: suspend () -> ByteArray = { + // Single Rust FFI call — the whole derivation, deadlock-safe — + // runs OUTSIDE WalletStorage's private-key lock (the + // storeIfAbsent/replacePrivateKey contract). + IdentityNative.deriveIdentityPrivateKeyWithResolver( + networkOrd = network.ffiValue, + walletId = walletId, + resolverHandle = mnemonicResolverHandle, + identityIndex = identityIndex, + keyIndex = keyIndex, + ).also { scalar = it } + } val wasNewlyCreated = try { runBlocking { - walletStorage.storeIfAbsent(pubkeyHex, ownerWalletId = walletId) { - // Single Rust FFI call — the whole derivation, - // deadlock-safe — runs OUTSIDE WalletStorage's - // private-key lock (storeIfAbsent's own contract). - IdentityNative.deriveIdentityPrivateKeyWithResolver( - networkOrd = network.ffiValue, - walletId = walletId, - resolverHandle = mnemonicResolverHandle, - identityIndex = identityIndex, - keyIndex = keyIndex, - ).also { scalar = it } + if (force) { + walletStorage.replacePrivateKey(pubkeyHex, ownerWalletId = walletId, derive = deriveOnce) + true + } else { + walletStorage.storeIfAbsent(pubkeyHex, ownerWalletId = walletId, derive = deriveOnce) } } } finally { 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 bfbb2a88ea..af43771e25 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 @@ -379,6 +379,38 @@ class WalletStorage( } } + /** + * FORCED variant of [storeIfAbsent] for the repair path + * (dashpay/platform#4060 finding 6): derive and store [pubkeyHex]'s + * private key UNCONDITIONALLY, replacing whatever entry exists — never + * the [addOwnerIfUsableLocked] short-circuit, whose shape+fingerprint + * usability check can be satisfied by a blob that does not actually + * decrypt (a stale-but-matching corner) and would silently skip the + * re-derive a repair exists to perform. Blob, fingerprint, and alias tag + * are replaced in ONE atomic edit; ownership is recorded like any store. + * + * Same outside-the-lock derive discipline as [storeIfAbsent] ([derive] + * is a native FFI call — never run it holding [privateKeyMutex]), and + * the same scrubbing contract: this function does NOT zero the bytes + * [derive] returns; the caller owns that on every path. The tombstone + * check runs both before the derive (fail fast) and again under the + * write lock (a deletion can win the race while deriving). + * + * Throws [WalletTombstonedException] if [ownerWalletId] was deleted. + */ + suspend fun replacePrivateKey( + pubkeyHex: String, + ownerWalletId: ByteArray, + derive: suspend () -> ByteArray, + ) { + privateKeyMutex.withLock { rejectIfTombstonedLocked(ownerWalletId) } + val derived = derive() + privateKeyMutex.withLock { + rejectIfTombstonedLocked(ownerWalletId) + storePrivateKeyEntryLocked(pubkeyHex, derived, ownerWalletId) + } + } + /** * If [pubkeyHex] already has a decryptable ciphertext entry (under any * owner), record [ownerWalletId]'s ownership and return `true`; 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 61faa1094c..533d094dbd 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 @@ -449,11 +449,20 @@ class PlatformWalletManager( * 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. + * FORCE-replaces the stored entry (never trusts the shape+fingerprint + * usability short-circuit) and then VERIFIES the write with the + * real-decrypt probe ([WalletStorage.probeIdentityKeyRecoverability]) + * before declaring success — a blob that does not actually decrypt fails + * the repair with a typed + * [DashSdkError.PlatformWallet.SigningKeyUnavailable] + * (dashpay/platform#4060 finding 6). + * + * Only after verification is the key 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), and the Room rows' `privateKeyKeychainIdentifier` updated so + * the durable pending-repair reconstruction does not resurrect the key. */ suspend fun repairIdentityKey( walletId: ByteArray, @@ -465,18 +474,51 @@ class PlatformWalletManager( require(keyIndex >= 0) { "keyIndex must be non-negative, got $keyIndex" } // 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). It only - // skips the actual re-derive when the stored scalar is already - // decryptable — exactly the case a health-sheet repair is invoked - // for (an undecryptable legacy blob) is NOT skipped. + // mid-derive (callers run on their own Compose scopes). + // + // force = true (dashpay/platform#4060 finding 6): storeIfAbsent's + // shape+fingerprint usability short-circuit can be satisfied by a + // blob that does not actually decrypt, silently skipping the + // re-derive a repair exists to perform. The forced path routes + // through WalletStorage.replacePrivateKey, which unconditionally + // replaces blob + fingerprint + alias tag in one atomic edit. val storageIdentifier = identityKeyDeriver.deriveAndStore( walletId = walletId, publicKeyData = publicKeyData, identityIndex = identityIndex, keyIndex = keyIndex, + force = true, )?.identifier if (storageIdentifier != null) { - persistenceHandler.markIdentityKeyRepaired(publicKeyData.toHex()) + val pubkeyHex = publicKeyData.toHex() + // VERIFY with the real-decrypt probe before declaring success: a + // wrong-key crypto failure fails the repair with a typed error. + // UserNotAuthenticatedException counts as verified inside the + // probe (key present, opens after auth — this manager holds no + // BiometricGate on this path, and the just-written fingerprint + // rules out the wrong-key-behind-locked-gate ambiguity because + // we wrote the blob ourselves under the captured public key). + val verified = walletStorage.probeIdentityKeyRecoverability(pubkeyHex) + if (!verified) { + throw DashSdkError.PlatformWallet.SigningKeyUnavailable( + "identity-key repair stored a blob that does not decrypt " + + "for pubkey $pubkeyHex (identityIndex=$identityIndex, " + + "keyIndex=$keyIndex) — the key remains unusable; " + + "re-run the repair after resolving the Keystore state", + ) + } + // Record the identifier on the Room rows too, so the durable + // pending-repair reconstruction (finding 5) does not resurrect + // this key on the next launch. + runCatching { + database.publicKeyDao().getByPublicKeyData(publicKeyData).forEach { row -> + if (row.privateKeyKeychainIdentifier != storageIdentifier) { + database.publicKeyDao() + .update(row.copy(privateKeyKeychainIdentifier = storageIdentifier)) + } + } + } + persistenceHandler.markIdentityKeyRepaired(pubkeyHex) } storageIdentifier } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 88e22b8336..6b2b5d427f 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 @@ -778,6 +778,7 @@ class PlatformWalletPersistenceHandlerTest { publicKeyData: ByteArray, identityIndex: Int, keyIndex: Int, + force: Boolean, ): DerivedKeyStoreResult? { calls.add(Triple(walletId, identityIndex, keyIndex)) lastPublicKey = publicKeyData @@ -1019,6 +1020,7 @@ class PlatformWalletPersistenceHandlerTest { publicKeyData: ByteArray, identityIndex: Int, keyIndex: Int, + force: Boolean, ): DerivedKeyStoreResult = throw IllegalStateException("keystore unavailable") override fun deleteUnownedStored( @@ -1089,6 +1091,7 @@ class PlatformWalletPersistenceHandlerTest { publicKeyData: ByteArray, identityIndex: Int, keyIndex: Int, + force: Boolean, ): DerivedKeyStoreResult = if (boom) { throw IllegalStateException("transient") @@ -1195,6 +1198,7 @@ class PlatformWalletPersistenceHandlerTest { publicKeyData: ByteArray, identityIndex: Int, keyIndex: Int, + force: Boolean, ): DerivedKeyStoreResult = if (boom) { throw IllegalStateException("transient") diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt index a464d66a10..481654d186 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt @@ -414,6 +414,65 @@ class WalletStorageUpgradeMatrixTest { assertNull(storage.retrievePrivateKey(pub)) } + /** + * Finding 6 (forced replacement): a blob that passes the shape + + * fingerprint usability check but does not actually decrypt must NOT + * short-circuit the repair path. replacePrivateKey overwrites the entry + * unconditionally (blob + fingerprint + alias tag in one edit), while + * storeIfAbsent — the idempotent persist path — keeps its short-circuit + * and would have skipped the re-derive. + */ + @Test + fun forcedReplaceOverwritesAFingerprintValidButUndecryptableBlob() = runBlocking { + val owner = ByteArray(32) { 6 } + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + storage.storePrivateKey(pub, secret, ownerWalletId = owner) + + // The blob still LOOKS current (shape + fingerprint) but the key no + // longer opens it — the stale-but-matching corner. + fake.throwBadPaddingOnPolicyDecrypt = true + assertTrue(storage.isPrivateKeyDecryptable(pub)) // cheap check fooled + assertFalse(storage.probeIdentityKeyRecoverability(pub)) // probe not fooled + + // storeIfAbsent short-circuits on the fingerprint match — no derive. + var storeIfAbsentDerives = 0 + val skipped = storage.storeIfAbsent(pub, ownerWalletId = owner) { + storeIfAbsentDerives++ + secret + } + assertFalse(skipped) + assertEquals(0, storeIfAbsentDerives) + + // replacePrivateKey derives unconditionally and replaces the entry. + val replacement = ByteArray(32) { (it + 100).toByte() } + var replaceDerives = 0 + storage.replacePrivateKey(pub, ownerWalletId = owner) { + replaceDerives++ + replacement + } + assertEquals(1, replaceDerives) + + fake.throwBadPaddingOnPolicyDecrypt = false + assertArrayEquals(replacement, storage.retrievePrivateKey(pub)) + assertTrue(storage.probeIdentityKeyRecoverability(pub)) + assertTrue(storage.ownedPrivateKeyAliases(owner).contains(pub)) + } + + /** replacePrivateKey honors wallet tombstones like every other store. */ + @Test + fun forcedReplaceRejectsATombstonedWallet() { + runBlocking { + val owner = ByteArray(32) { 9 } + storage.withPrivateKeyExclusion { tombstoneWallet(owner) } + org.junit.Assert.assertThrows(WalletTombstonedException::class.java) { + runBlocking { + storage.replacePrivateKey(pub, ownerWalletId = owner) { secret } + } + } + assertFalse(storage.hasPrivateKey(pub)) + } + } + /** * A closed auth window must NOT be mistaken for a wrong key: the * UserNotAuthenticatedException propagates so KeystoreSigner can prompt, From 466e2118b960f4da717daa207f435780a5376d06 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:27:40 -0400 Subject: [PATCH 09/26] feat(kotlin-sdk)!: map platform-wallet code 98 to typed PlatformWallet.NotFound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlatformWalletFFIResultCode::NotFound (98) — the blanket Option→result miss (unknown wallet id, unmanaged identity, …) — previously collapsed into the top-level DashSdkError.NotFound shared with rs-sdk-ffi codes 7/8. It now maps to the new DashSdkError.PlatformWallet.NotFound, keeping 98 inside the wallet-error family in exact parity with Swift's PlatformWalletError.notFound, so callers can match a wallet-level absence without sniffing Generic codes. BREAKING: Kotlin hosts catching DashSdkError.NotFound from platform-wallet operations (e.g. rescanSpvFilters with an unknown wallet id) now receive DashSdkError.PlatformWallet.NotFound; rs-sdk-ffi codes 7/8 still map to the top-level NotFound. Dashpay's managed-identity local reads are unaffected — translateManagedIdentityNotFoundToZero intercepts the RAW code (#4051) before this mapping ever runs. Co-Authored-By: Claude Fable 5 --- .../dashsdk/errors/DashSdkError.kt | 37 +++++++++++++--- .../dashfoundation/dashsdk/tokens/Dashpay.kt | 5 ++- .../dashsdk/wallet/PlatformWalletManager.kt | 3 +- .../dashsdk/errors/DashSdkErrorTest.kt | 42 +++++++++++++++++-- 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 3637fd57a4..7ffe9604c3 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 @@ -205,6 +205,26 @@ sealed class DashSdkError( } } + /** + * `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 @@ -230,10 +250,7 @@ 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 [NotFound]; Dashpay's managed-identity - * local reads intercept the RAW code (offset + 98) via - * `translateManagedIdentityNotFoundToZero` before [fromNative] runs - * and turn the miss into an absence (zero handle → null / empty). + * by the wallet). Mapped to the typed [PlatformWallet.NotFound]. */ const val PLATFORM_WALLET_NOT_FOUND_CODE = 98 @@ -284,8 +301,18 @@ sealed class DashSdkError( } 7, // ErrorIdentityNotFound 8, // ErrorContactNotFound - PLATFORM_WALLET_NOT_FOUND_CODE, // 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. BREAKING for Kotlin hosts that + // caught DashSdkError.NotFound from platform-wallet operations. + 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 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 ec8761fc89..c43a4a2475 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 @@ -578,8 +578,9 @@ class EstablishedContactRef internal constructor(handle: Long) : AutoCloseable { * `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 - * a typed `DashSdkError.NotFound("…ManagedIdentity not found")` - * instead of returning null. Any other error is rethrown untouched. + * a typed `DashSdkError.PlatformWallet.NotFound("…ManagedIdentity not + * found")` instead of returning null. Any other error is rethrown + * untouched. */ internal inline fun translateManagedIdentityNotFoundToZero(getHandle: () -> Long): Long = try { 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 533d094dbd..b769bdefac 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 @@ -1731,7 +1731,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 c097a04454..7a6b40af66 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,12 +58,24 @@ 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) assertTrue("ShieldedNoRecordedAnchor is retryable", noAnchor.isRetryable) @@ -153,6 +165,23 @@ class DashSdkErrorTest { 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 { @@ -175,7 +204,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) } } From 22fd3e550737bbc040e6ca97840f5b5fe28fff02 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:36:53 -0400 Subject: [PATCH 10/26] feat(sdk-ffi): structured SigningKeyUnavailable discriminator across the signer wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace end-to-end message sniffing for the signer's "missing key" failure with a typed discriminator (dashpay/platform#4060 finding 7): - rs-sdk-ffi: DashSDKSignerErrorCode { Generic = 0, SigningKeyUnavailable = 1, AuthenticationFailed = 2 (reserved) }; SignCompletionCallback and dash_sdk_sign_async_completion gain error_code: i32 (before error_message). SignResult stays Result, ProtocolError> (a new rs-dpp ProtocolError variant would carry serialization blast radius), so code 1 rides the single Rust-owned machine prefix DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX through ProtocolError::Generic — typed at both ABI edges, one constant bridging the string segment. This is an internal coordinated ABI change: every piece versions together in this monorepo. - rs-platform-wallet-ffi: PlatformWalletFFIResultCode:: ErrorSigningKeyUnavailable = 31 (codes 26-28 are reserved for #4185's reservation-token errors and 29/30 for #4184's asset-lock errors on sibling branches — documented in the enum as #4184 does). The From conversion restores the typed code from the prefix FIRST (before the loose keyword sniffs), and the From blanket impl restores it on the catch-all only (dedicated retry-semantics codes are never overridden) — covering the Sdk(dash_sdk::Error::Protocol(..)) wrapping path. - JNI/Kotlin: SignerNative.completeSign(token, signature, errorCode, errorMessage); KeystoreSigner passes SIGNER_ERROR_CODE_KEY_UNAVAILABLE on the null-key branch (keeping the MESSAGE_MARKER text for the transition window) and Generic everywhere else. DashSdkError maps 31 → PlatformWallet.SigningKeyUnavailable; the #4191 marker sniff on the catch-all codes remains as a deprecated old-native fallback with a removal note tied to the next minor release. - Swift: KeychainSigner trampolines forward the code (missing-row / missing-scalar outcomes classify as 1); PlatformWalletResultCode gains errorSigningKeyUnavailable = 31 → PlatformWalletError .signingKeyUnavailable (Kotlin parity). - Tests: rs-sdk-ffi completion-code tests (prefix present for code 1, absent for generic), platform-wallet-ffi prefix→31 tests on both conversion points, Kotlin code-31 + fallback-marker tests, Swift mapping and trampoline-classifier tests. Co-Authored-By: Claude Fable 5 --- .../dashsdk/errors/DashSdkError.kt | 51 +++++-- .../dashsdk/ffi/SignerNative.kt | 27 +++- .../dashsdk/security/KeystoreSigner.kt | 44 ++++-- .../dashsdk/errors/DashSdkErrorTest.kt | 23 +++- packages/rs-platform-wallet-ffi/src/error.rs | 92 ++++++++++++- packages/rs-sdk-ffi/src/signer.rs | 125 +++++++++++++++++- packages/rs-sdk-ffi/src/test_utils.rs | 1 + packages/rs-sdk-ffi/src/token/claim.rs | 1 + .../rs-sdk-ffi/src/token/config_update.rs | 1 + .../src/token/destroy_frozen_funds.rs | 1 + .../rs-sdk-ffi/src/token/emergency_action.rs | 1 + packages/rs-sdk-ffi/src/token/freeze.rs | 1 + packages/rs-sdk-ffi/src/token/mint.rs | 1 + packages/rs-sdk-ffi/src/token/purchase.rs | 1 + packages/rs-sdk-ffi/src/token/set_price.rs | 1 + packages/rs-sdk-ffi/src/token/transfer.rs | 1 + packages/rs-sdk-ffi/src/token/unfreeze.rs | 1 + packages/rs-unified-sdk-jni/src/signer.rs | 36 +++-- .../SwiftDashSDK/FFI/KeychainSigner.swift | 39 +++++- .../PlatformWallet/PlatformWalletResult.swift | 21 +++ .../ErrorHandlingTests.swift | 33 +++++ 21 files changed, 450 insertions(+), 52 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 7ffe9604c3..afa85958f3 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 @@ -182,12 +182,13 @@ sealed class DashSdkError( * 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. + * Primary path (dashpay/platform#4060 finding 7): `KeystoreSigner` + * completes with the STRUCTURED + * `SignerNative.SIGNER_ERROR_CODE_KEY_UNAVAILABLE`, which travels + * typed across the Rust boundary and returns as + * `PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable` (31) — + * mapped directly here, no message inspection involved. Hosts 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. @@ -197,9 +198,17 @@ sealed class DashSdkError( 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. + * completion error message. `KeystoreSigner` builds its + * message from this constant, so the emitter and the matcher + * cannot drift. + * + * DEPRECATED as a discriminator: superseded by the typed + * code-31 mapping above; the marker match is retained only + * for the transition window where an old native library + * (pre-code-31) is paired with new Kotlin during partial + * builds. Remove the fallback (and this constant's matcher + * role) in the next minor release once the native artifacts + * are guaranteed current. */ const val MESSAGE_MARKER = "no private key stored for" } @@ -280,11 +289,13 @@ sealed class DashSdkError( * [PlatformWallet] subtree — mirror of Swift's * `PlatformWalletError(result:)` construction. Retry-semantics-bearing * codes get dedicated types; the rest fall through to - * [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). + * [PlatformWallet.Generic]. The `KeystoreSigner` "missing key" + * completion arrives TYPED as code 31 + * (`ErrorSigningKeyUnavailable`, dashpay/platform#4060 finding 7); + * the legacy message-marker sniff on the catch-all codes is a + * deprecated transition fallback for old native libraries (never + * applied to the dedicated retry-semantics types, so those are + * never overridden). */ private fun fromPlatformWalletNative( code: Int, @@ -294,6 +305,10 @@ sealed class DashSdkError( // PlatformWalletFFIResultCode variants (platform-wallet-ffi/src/error.rs) 1 -> PlatformWallet.InvalidHandle(message, cause) // ErrorInvalidHandle 6 -> // ErrorWalletOperation + // @Deprecated fallback: the marker sniff survives only for + // old-native/new-Kotlin partial builds; the typed code 31 + // below is the real discriminator (#4060 finding 7). Remove + // with MESSAGE_MARKER's matcher role next minor release. if (isSigningKeyUnavailable(message)) { PlatformWallet.SigningKeyUnavailable(message, cause) } else { @@ -321,7 +336,15 @@ sealed class DashSdkError( 23 -> PlatformWallet.AssetLockNotTracked(message, cause) // ErrorAssetLockNotTracked 24 -> PlatformWallet.AssetLockAlreadyConsumed(message, cause) // ErrorAssetLockAlreadyConsumed 25 -> PlatformWallet.AssetLockFundingMismatch(message, cause) // ErrorAssetLockFundingMismatch + // ErrorSigningKeyUnavailable — the STRUCTURED signer + // discriminator (dashpay/platform#4060 finding 7): the typed + // completion code rides the whole Rust round-trip, no message + // sniffing involved. (Codes 26-30 are reserved by sibling PRs + // #4185 / #4184 — see PlatformWalletFFIResultCode.) + 31 -> PlatformWallet.SigningKeyUnavailable(message, cause) else -> + // @Deprecated fallback — see the code-6 arm; code 31 is the + // real discriminator. if (isSigningKeyUnavailable(message)) { PlatformWallet.SigningKeyUnavailable(message, cause) } else { diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/SignerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/SignerNative.kt index a39a2d307b..40c032b1b9 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/SignerNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/SignerNative.kt @@ -6,6 +6,22 @@ package org.dashfoundation.dashsdk.ffi */ internal object SignerNative { + /** + * `DashSDKSignerErrorCode::Generic` — unclassified signing failure + * (the historical behavior). Mirrors `rs-sdk-ffi/src/signer.rs`. + */ + const val SIGNER_ERROR_CODE_GENERIC = 0 + + /** + * `DashSDKSignerErrorCode::SigningKeyUnavailable` — the signer has no + * usable private key for the requested public key; the operation itself + * did not fail. Travels typed across the completion ABI and comes back + * as `PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable` (31) → + * `DashSdkError.PlatformWallet.SigningKeyUnavailable` + * (dashpay/platform#4060 finding 7). Mirrors `rs-sdk-ffi/src/signer.rs`. + */ + const val SIGNER_ERROR_CODE_KEY_UNAVAILABLE = 1 + /** Create a native `SignerHandle` backed by [bridge] (held as GlobalRef). */ external fun createSigner(bridge: NativeSignerBridge): Long @@ -14,9 +30,16 @@ internal object SignerNative { /** * Complete an in-flight sign request. Exactly once per token; pass - * either a signature or an error message. + * either a signature or an error message. [errorCode] is a + * `DashSDKSignerErrorCode` discriminant classifying a failure + * ([SIGNER_ERROR_CODE_GENERIC] when unclassified; ignored on success). */ - external fun completeSign(token: Long, signature: ByteArray?, errorMessage: String?) + external fun completeSign( + token: Long, + signature: ByteArray?, + errorCode: Int, + errorMessage: String?, + ) /** * One-shot ECDSA sign: raw 32-byte private key + payload → signature, 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 7de0a78623..d9ce718f40 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 @@ -84,6 +84,7 @@ class KeystoreSigner( SignerNative.completeSign( completionToken, null, + SignerNative.SIGNER_ERROR_CODE_GENERIC, e.message ?: "signing failed", ) } @@ -101,13 +102,17 @@ 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. + // The typed SIGNER_ERROR_CODE_KEY_UNAVAILABLE rides the + // completion ABI and comes back as platform-wallet code 31 → + // DashSdkError.PlatformWallet.SigningKeyUnavailable + // (dashpay/platform#4060 finding 7). The MESSAGE_MARKER text + // is ALSO kept during the transition window so an old native + // library paired with new Kotlin (partial builds) still maps + // via the deprecated message fallback. SignerNative.completeSign( completionToken, null, + SignerNative.SIGNER_ERROR_CODE_KEY_UNAVAILABLE, "${DashSdkError.PlatformWallet.SigningKeyUnavailable.MESSAGE_MARKER} " + "${storageKey.take(16)}…", ) @@ -115,9 +120,19 @@ class KeystoreSigner( } val signature = SignerNative.signWithPrivateKey(key, network.ffiValue, data) if (signature != null) { - SignerNative.completeSign(completionToken, signature, null) + SignerNative.completeSign( + completionToken, + signature, + SignerNative.SIGNER_ERROR_CODE_GENERIC, + null, + ) } else { - SignerNative.completeSign(completionToken, null, "signing returned no data") + SignerNative.completeSign( + completionToken, + null, + SignerNative.SIGNER_ERROR_CODE_GENERIC, + "signing returned no data", + ) } } finally { key?.fill(0) @@ -146,6 +161,7 @@ class KeystoreSigner( SignerNative.completeSign( completionToken, null, + SignerNative.SIGNER_ERROR_CODE_GENERIC, "no platform address row for $hashHex", ) return @@ -157,6 +173,7 @@ class KeystoreSigner( SignerNative.completeSign( completionToken, null, + SignerNative.SIGNER_ERROR_CODE_GENERIC, "no signable platform address row for $hashHex " + "(no candidate has both a derivation path and a stored mnemonic)", ) @@ -170,6 +187,7 @@ class KeystoreSigner( SignerNative.completeSign( completionToken, null, + SignerNative.SIGNER_ERROR_CODE_GENERIC, "no mnemonic stored for wallet of platform address $hashHex", ) return @@ -183,9 +201,19 @@ class KeystoreSigner( SignerNative.signWithMnemonicAndPathInto(m, path, net, payload) } if (signature != null) { - SignerNative.completeSign(completionToken, signature, null) + SignerNative.completeSign( + completionToken, + signature, + SignerNative.SIGNER_ERROR_CODE_GENERIC, + null, + ) } else { - SignerNative.completeSign(completionToken, null, "signing returned no data") + SignerNative.completeSign( + completionToken, + null, + SignerNative.SIGNER_ERROR_CODE_GENERIC, + "signing returned no data", + ) } } 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 7a6b40af66..a7a20b7ca5 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 @@ -128,14 +128,29 @@ class DashSdkErrorTest { assertFalse("Generic platform-wallet errors are not retryable", mapped.isRetryable) } + @Test + fun signingKeyUnavailableCode31MapsTyped() { + // The STRUCTURED discriminator (dashpay/platform#4060 finding 7): + // PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable (31) maps + // to the typed error on the code alone — no message inspection. + val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET + val mapped = DashSdkError.fromNative( + DashSDKException(offset + 31, "arbitrary human text, no marker"), + ) + assertTrue(mapped is DashSdkError.PlatformWallet.SigningKeyUnavailable) + assertFalse(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). + // DEPRECATED transition fallback: an OLD native library (pre the + // typed code 31) still returns the completion error as free text + // under the catch-all codes (ErrorUnknown = 99 via the blanket + // PlatformWalletError conversion, sometimes wrapped as + // ErrorWalletOperation = 6) — both must keep surfacing typed until + // the fallback's removal (#4052, #4060 finding 7). for (code in intArrayOf(6, 99)) { val mapped = DashSdkError.fromNative( DashSDKException(offset + code, "Signing failed: $marker deadbeef00112233…"), diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 44532de863..8264048c53 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -172,6 +172,29 @@ pub enum PlatformWalletFFIResultCode { /// rejected the transaction, so its UTXO reservation was released and the /// host may safely retry after addressing the rejection reason. ErrorTransactionBroadcastRejected = 26, + // Code 26 above (ErrorTransactionBroadcastRejected) landed on v4.1-dev. + // Codes 27-28 remain reserved: the deferred-payment reservation-token + // errors (ErrorStaleReservationToken / ErrorReservationTokenConsumed / + // ErrorReservationWalletMismatch) claim them on the split-build-broadcast + // branch (dashpay/platform#4185) — which must now itself renumber off 26, + // since v4.1-dev took it — and 29/30 belong to the asset-lock funding + // errors (ErrorAssetLockInsufficientFunds / + // ErrorAssetLockCrossDomainConsentRequired) on the multi-account branch + // (dashpay/platform#4184); allocating any of them here too would merge + // without textual conflict and silently misclassify across hosts. + /// 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, stranded, or written under a different Keystore/Keychain + /// alias — rather than the operation itself failing. Restored from the + /// typed signer completion code + /// ([`rs_sdk_ffi::DashSDKSignerErrorCode::SigningKeyUnavailable`]) via + /// the stable machine prefix + /// [`rs_sdk_ffi::DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX`] riding the + /// `ProtocolError::Generic` segment (dashpay/platform#4060 finding 7). + /// Hosts route this to key repair instead of treating it as an opaque + /// wallet-operation failure. Not retryable as-is — the key must be + /// (re-)derived first. + ErrorSigningKeyUnavailable = 31, NotFound = 98, // Used exclusively for all the Option that are retuned as errors ErrorUnknown = 99, @@ -336,6 +359,19 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::AssetLockFundingMismatch { .. } => { PlatformWalletFFIResultCode::ErrorAssetLockFundingMismatch } + // A signer failure can also reach this blanket impl wrapped as + // `PlatformWalletError::Sdk(dash_sdk::Error::Protocol(..))` (any + // wallet operation that propagates the SDK error via `?`). The + // typed discriminator rides the stable machine prefix in the + // rendered message — restore it here too, but ONLY on the + // catch-all: the dedicated retry-semantics codes above are never + // overridden (dashpay/platform#4060 finding 7). + _ if error + .to_string() + .contains(rs_sdk_ffi::DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX) => + { + PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable + } _ => PlatformWalletFFIResultCode::ErrorUnknown, }; PlatformWalletFFIResult::err(code, error.to_string()) @@ -428,7 +464,13 @@ impl From for PlatformWalletFFIResult { impl From for PlatformWalletFFIResult { fn from(e: dpp::ProtocolError) -> Self { let msg = e.to_string(); - let code = if msg.contains("identifier") { + // The signer's typed SigningKeyUnavailable completion rides the + // stable machine prefix through ProtocolError::Generic + // (dashpay/platform#4060 finding 7) — restore the typed code FIRST, + // before any of the loose keyword sniffs below can misroute it. + let code = if msg.contains(rs_sdk_ffi::DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX) { + PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable + } else if msg.contains("identifier") { PlatformWalletFFIResultCode::ErrorInvalidIdentifier } else if msg.contains("deserialization") || msg.contains("decode") { PlatformWalletFFIResultCode::ErrorDeserialization @@ -835,4 +877,52 @@ mod tests { let result: PlatformWalletFFIResult = err.into(); assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorUnknown); } + + /// The typed SigningKeyUnavailable signer completion rides the stable + /// machine prefix through `ProtocolError::Generic`; the conversion must + /// restore code 31 (dashpay/platform#4060 finding 7) — and must do so + /// BEFORE the loose keyword sniffs (the human message may well contain + /// "identifier" or similar). + #[test] + fn signer_key_unavailable_prefix_maps_to_code_31() { + let e = dpp::ProtocolError::Generic(format!( + "{}no private key stored for identifier 02abcd", + rs_sdk_ffi::DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX + )); + let result: PlatformWalletFFIResult = e.into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable + ); + } + + /// The same prefix arriving wrapped in the SDK-error path (the blanket + /// `From` catch-all) restores code 31 too — but + /// only on the catch-all; typed variants keep their codes. + #[test] + fn signer_key_unavailable_prefix_maps_on_the_sdk_catch_all() { + let err = PlatformWalletError::Sdk(dash_sdk::Error::Protocol(dpp::ProtocolError::Generic( + format!( + "{}no private key stored for 02abcd", + rs_sdk_ffi::DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX + ), + ))); + let result: PlatformWalletFFIResult = err.into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable + ); + } + + /// A generic protocol error without the prefix keeps the historical + /// mapping — no message sniffing beyond the machine prefix. + #[test] + fn generic_protocol_error_without_prefix_is_unchanged() { + let e = dpp::ProtocolError::Generic("no private key stored for 02abcd".to_string()); + let result: PlatformWalletFFIResult = e.into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorWalletOperation + ); + } } diff --git a/packages/rs-sdk-ffi/src/signer.rs b/packages/rs-sdk-ffi/src/signer.rs index 1f355e8ef6..b25862f38e 100644 --- a/packages/rs-sdk-ffi/src/signer.rs +++ b/packages/rs-sdk-ffi/src/signer.rs @@ -154,6 +154,39 @@ pub type SignAsyncCallback = unsafe extern "C" fn( completion: SignCompletionCallback, ); +/// Structured discriminator for signer completion failures, carried as a +/// typed `i32` across the C ABI (dashpay/platform#4060 finding 7) so +/// language layers never have to sniff human-readable messages. +/// +/// Only [`Generic`](DashSDKSignerErrorCode::Generic) and +/// [`SigningKeyUnavailable`](DashSDKSignerErrorCode::SigningKeyUnavailable) +/// are emitted today; `AuthenticationFailed` is RESERVED for a follow-up so +/// the numeric space is stable across SDK releases. +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DashSDKSignerErrorCode { + /// Unclassified signing failure — the historical behavior. + Generic = 0, + /// The signer has no usable private key for the requested public key + /// (missing / stranded / written under a different Keystore alias) — + /// the operation itself did not fail; the key must be (re-)derived. + SigningKeyUnavailable = 1, + /// RESERVED (not yet emitted): user authentication was required and + /// failed/was dismissed. + AuthenticationFailed = 2, +} + +/// Stable machine prefix that carries the +/// [`DashSDKSignerErrorCode::SigningKeyUnavailable`] discriminator through +/// the `ProtocolError::Generic` string segment between this crate's signer +/// completion and `platform-wallet-ffi`'s error conversion (which maps it to +/// `PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable`). The +/// discriminator is TYPED at both ABI edges (an `i32` across C, a result +/// code across the Kotlin/Swift FFI); this Rust-owned constant is the one +/// bridge across the `ProtocolError` segment — never sniff human-readable +/// message text. +pub const DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX: &str = "signer_error:key_unavailable: "; + /// Completion callback invoked by the C / iOS side when a signature is ready. /// /// # Parameters @@ -161,6 +194,9 @@ pub type SignAsyncCallback = unsafe extern "C" fn( /// - `signature` / `signature_len`: signature bytes on success. Ignored when /// `error_message` is non-null. May be null with `signature_len == 0` if /// `error_message` is non-null. +/// - `error_code`: a [`DashSDKSignerErrorCode`] discriminant classifying the +/// failure; ignored on success (`error_message` null). Callers that have +/// no classification pass 0 (`Generic`). /// - `error_message`: null-terminated UTF-8 error string on failure, null on /// success. Ownership is *not* transferred — the Rust side copies the string /// before returning, so the caller can free/reuse the buffer immediately @@ -175,6 +211,7 @@ pub type SignCompletionCallback = unsafe extern "C" fn( completion_ctx: *mut c_void, signature: *const u8, signature_len: usize, + error_code: i32, error_message: *const c_char, ); @@ -670,6 +707,7 @@ pub unsafe extern "C" fn dash_sdk_sign_async_completion( completion_ctx: *mut c_void, signature: *const u8, signature_len: usize, + error_code: i32, error_message: *const c_char, ) { if completion_ctx.is_null() { @@ -690,7 +728,19 @@ pub unsafe extern "C" fn dash_sdk_sign_async_completion( let result: SignResult = if !error_message.is_null() { let msg = CStr::from_ptr(error_message).to_string_lossy().into_owned(); - Err(ProtocolError::Generic(msg)) + // `SignResult` stays `Result, ProtocolError>` (a new rs-dpp + // ProtocolError variant would have serialization blast radius), so + // the typed `error_code` rides the one Rust-owned machine prefix + // through the Generic string segment; platform-wallet-ffi's error + // conversion recognizes the prefix and restores the typed code + // (dashpay/platform#4060 finding 7). + if error_code == DashSDKSignerErrorCode::SigningKeyUnavailable as i32 { + Err(ProtocolError::Generic(format!( + "{DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX}{msg}" + ))) + } else { + Err(ProtocolError::Generic(msg)) + } } else if signature.is_null() { Err(ProtocolError::Generic( "Signer completion returned null signature with no error message".to_string(), @@ -968,7 +1018,7 @@ mod tests { COMPLETION_CALLED.store(true, Ordering::SeqCst); let sig = [0xABu8; 64]; // Null error_message = success. - completion(completion_ctx, sig.as_ptr(), sig.len(), std::ptr::null()); + completion(completion_ctx, sig.as_ptr(), sig.len(), 0, std::ptr::null()); } /// Test sign callback that reports an error via the completion callback. @@ -983,7 +1033,29 @@ mod tests { completion: SignCompletionCallback, ) { let msg = c"simulated hsm error"; - completion(completion_ctx, std::ptr::null(), 0, msg.as_ptr()); + completion(completion_ctx, std::ptr::null(), 0, 0, msg.as_ptr()); + } + + /// Test sign callback that reports a MISSING KEY via the structured + /// `error_code` (dashpay/platform#4060 finding 7). + unsafe extern "C" fn test_sign_async_key_unavailable( + _signer: *const c_void, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, + _data: *const u8, + _data_len: usize, + completion_ctx: *mut c_void, + completion: SignCompletionCallback, + ) { + let msg = c"no private key stored for 02abcd"; + completion( + completion_ctx, + std::ptr::null(), + 0, + DashSDKSignerErrorCode::SigningKeyUnavailable as i32, + msg.as_ptr(), + ); } /// Test sign callback that spawns a *different thread* to invoke the @@ -1010,6 +1082,7 @@ mod tests { ctx_usize as *mut c_void, sig.as_ptr(), sig.len(), + 0, std::ptr::null(), ); } @@ -1193,6 +1266,44 @@ mod tests { ); } + /// Pins the structured discriminator's Rust segment (dashpay/platform#4060 + /// finding 7): an `error_code = SigningKeyUnavailable` completion must + /// surface as a `ProtocolError::Generic` whose message carries the stable + /// machine prefix (followed by the human message), so + /// `platform-wallet-ffi` can restore the typed code without sniffing + /// human-readable text. + #[tokio::test] + async fn completion_callback_key_unavailable_carries_machine_prefix() { + let signer = make_signer(test_sign_async_key_unavailable); + let key = make_dummy_key(); + + let err = signer + .sign(&key, &[4, 5, 6]) + .await + .expect_err("sign should fail"); + let msg = err.to_string(); + assert!( + msg.contains(&format!( + "{DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX}no private key stored for 02abcd" + )), + "typed code must ride the stable prefix: got {msg}" + ); + } + + /// A generic-code error must NOT acquire the machine prefix. + #[tokio::test] + async fn completion_callback_generic_error_has_no_machine_prefix() { + let signer = make_signer(test_sign_async_error); + let key = make_dummy_key(); + + let err = signer + .sign(&key, &[4, 5, 6]) + .await + .expect_err("sign should fail"); + let msg = err.to_string(); + assert!(!msg.contains(DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX)); + } + #[tokio::test] async fn completion_callback_cross_thread() { // Exercises the realistic case: completion runs on a different @@ -1222,13 +1333,13 @@ mod tests { completion: SignCompletionCallback, ) { let sig = [0x42u8; 64]; - completion(completion_ctx, sig.as_ptr(), sig.len(), std::ptr::null()); + completion(completion_ctx, sig.as_ptr(), sig.len(), 0, std::ptr::null()); // Duplicate error payload — must not overwrite the first result. let err_msg = c"duplicate completion — should be ignored"; - completion(completion_ctx, std::ptr::null(), 0, err_msg.as_ptr()); + completion(completion_ctx, std::ptr::null(), 0, 0, err_msg.as_ptr()); // Duplicate success payload — still a no-op. let sig2 = [0x99u8; 64]; - completion(completion_ctx, sig2.as_ptr(), sig2.len(), std::ptr::null()); + completion(completion_ctx, sig2.as_ptr(), sig2.len(), 0, std::ptr::null()); } #[tokio::test] @@ -1286,7 +1397,7 @@ mod tests { }; *cap.observed.lock().unwrap() = Some((key_type, bytes)); let sig = [0x77u8; 64]; - completion(completion_ctx, sig.as_ptr(), sig.len(), std::ptr::null()); + completion(completion_ctx, sig.as_ptr(), sig.len(), 0, std::ptr::null()); } unsafe extern "C" fn capture_can_sign( diff --git a/packages/rs-sdk-ffi/src/test_utils.rs b/packages/rs-sdk-ffi/src/test_utils.rs index bcd4444c5b..7118a4624b 100644 --- a/packages/rs-sdk-ffi/src/test_utils.rs +++ b/packages/rs-sdk-ffi/src/test_utils.rs @@ -64,6 +64,7 @@ pub mod test_utils { completion_ctx, signature.as_ptr(), signature.len(), + 0, std::ptr::null(), ); } diff --git a/packages/rs-sdk-ffi/src/token/claim.rs b/packages/rs-sdk-ffi/src/token/claim.rs index 6eb5dec427..b32d3d2373 100644 --- a/packages/rs-sdk-ffi/src/token/claim.rs +++ b/packages/rs-sdk-ffi/src/token/claim.rs @@ -252,6 +252,7 @@ mod tests { completion_ctx, signature.as_ptr(), signature.len(), + 0, std::ptr::null(), ); } diff --git a/packages/rs-sdk-ffi/src/token/config_update.rs b/packages/rs-sdk-ffi/src/token/config_update.rs index 7c1fca2a73..4e173a784e 100644 --- a/packages/rs-sdk-ffi/src/token/config_update.rs +++ b/packages/rs-sdk-ffi/src/token/config_update.rs @@ -296,6 +296,7 @@ mod tests { completion_ctx, signature.as_ptr(), signature.len(), + 0, std::ptr::null(), ); } diff --git a/packages/rs-sdk-ffi/src/token/destroy_frozen_funds.rs b/packages/rs-sdk-ffi/src/token/destroy_frozen_funds.rs index 24e42ddf5a..627de80cf2 100644 --- a/packages/rs-sdk-ffi/src/token/destroy_frozen_funds.rs +++ b/packages/rs-sdk-ffi/src/token/destroy_frozen_funds.rs @@ -242,6 +242,7 @@ mod tests { completion_ctx, signature.as_ptr(), signature.len(), + 0, std::ptr::null(), ); } diff --git a/packages/rs-sdk-ffi/src/token/emergency_action.rs b/packages/rs-sdk-ffi/src/token/emergency_action.rs index 6e4b7f3adc..1cf00009b0 100644 --- a/packages/rs-sdk-ffi/src/token/emergency_action.rs +++ b/packages/rs-sdk-ffi/src/token/emergency_action.rs @@ -259,6 +259,7 @@ mod tests { completion_ctx, signature.as_ptr(), signature.len(), + 0, std::ptr::null(), ); } diff --git a/packages/rs-sdk-ffi/src/token/freeze.rs b/packages/rs-sdk-ffi/src/token/freeze.rs index 2bd16e076a..d53f21dcdd 100644 --- a/packages/rs-sdk-ffi/src/token/freeze.rs +++ b/packages/rs-sdk-ffi/src/token/freeze.rs @@ -261,6 +261,7 @@ mod tests { completion_ctx, signature.as_ptr(), signature.len(), + 0, std::ptr::null(), ); } diff --git a/packages/rs-sdk-ffi/src/token/mint.rs b/packages/rs-sdk-ffi/src/token/mint.rs index 0eaaf98963..bc955541c7 100644 --- a/packages/rs-sdk-ffi/src/token/mint.rs +++ b/packages/rs-sdk-ffi/src/token/mint.rs @@ -350,6 +350,7 @@ mod tests { completion_ctx, signature.as_ptr(), signature.len(), + 0, std::ptr::null(), ); } diff --git a/packages/rs-sdk-ffi/src/token/purchase.rs b/packages/rs-sdk-ffi/src/token/purchase.rs index 7227fdb2b5..14e3847234 100644 --- a/packages/rs-sdk-ffi/src/token/purchase.rs +++ b/packages/rs-sdk-ffi/src/token/purchase.rs @@ -233,6 +233,7 @@ mod tests { completion_ctx, signature.as_ptr(), signature.len(), + 0, std::ptr::null(), ); } diff --git a/packages/rs-sdk-ffi/src/token/set_price.rs b/packages/rs-sdk-ffi/src/token/set_price.rs index 4f469973d0..fd527b10cb 100644 --- a/packages/rs-sdk-ffi/src/token/set_price.rs +++ b/packages/rs-sdk-ffi/src/token/set_price.rs @@ -281,6 +281,7 @@ mod tests { completion_ctx, signature.as_ptr(), signature.len(), + 0, std::ptr::null(), ); } diff --git a/packages/rs-sdk-ffi/src/token/transfer.rs b/packages/rs-sdk-ffi/src/token/transfer.rs index da1b40b94f..8a240b10c8 100644 --- a/packages/rs-sdk-ffi/src/token/transfer.rs +++ b/packages/rs-sdk-ffi/src/token/transfer.rs @@ -250,6 +250,7 @@ mod tests { completion_ctx, signature.as_ptr(), signature.len(), + 0, std::ptr::null(), ); } diff --git a/packages/rs-sdk-ffi/src/token/unfreeze.rs b/packages/rs-sdk-ffi/src/token/unfreeze.rs index f20dba4b56..fb20cf1cda 100644 --- a/packages/rs-sdk-ffi/src/token/unfreeze.rs +++ b/packages/rs-sdk-ffi/src/token/unfreeze.rs @@ -241,6 +241,7 @@ mod tests { completion_ctx, signature.as_ptr(), signature.len(), + 0, std::ptr::null(), ); } diff --git a/packages/rs-unified-sdk-jni/src/signer.rs b/packages/rs-unified-sdk-jni/src/signer.rs index d010cc1a93..d21a8c1ccf 100644 --- a/packages/rs-unified-sdk-jni/src/signer.rs +++ b/packages/rs-unified-sdk-jni/src/signer.rs @@ -10,7 +10,8 @@ //! auth window expired), signs via the one-shot //! `dash_sdk_signer_create_from_private_key` + `dash_sdk_signer_sign` //! helpers (exactly like Swift — no crypto in the language layer), -//! zeroes the buffer, then calls `completeSign(token, signature, error)`. +//! zeroes the buffer, then calls +//! `completeSign(token, signature, errorCode, error)`. //! 4. `completeSign` reclaims the token and fires the Rust completion //! exactly once. //! @@ -46,12 +47,21 @@ struct PendingSign { // reclaims the Box in completeSign, exactly once. unsafe impl Send for PendingSign {} -/// Fire a parked completion with an error message. Consumes the pending -/// completion (callers deref the reconstituted box exactly once). -unsafe fn complete_with_error(pending: PendingSign, message: &str) { +/// Fire a parked completion with an error code + message. Consumes the +/// pending completion (callers deref the reconstituted box exactly once). +/// [`error_code`] is a `DashSDKSignerErrorCode` discriminant — internal JNI +/// failure paths pass 0 (`Generic`); Kotlin supplies the typed code +/// (dashpay/platform#4060 finding 7). +unsafe fn complete_with_error(pending: PendingSign, error_code: i32, message: &str) { let c_message = CString::new(message) .unwrap_or_else(|_| CString::new("signing failed").expect("static string")); - (pending.completion)(pending.completion_ctx, ptr::null(), 0, c_message.as_ptr()); + (pending.completion)( + pending.completion_ctx, + ptr::null(), + 0, + error_code, + c_message.as_ptr(), + ); } unsafe extern "C" fn sign_async_trampoline( @@ -120,7 +130,7 @@ unsafe extern "C" fn sign_async_trampoline( match outcome { Ok(Ok(())) => {} - Ok(Err(pending)) => complete_with_error(*pending, "JNI signer dispatch failed"), + Ok(Err(pending)) => complete_with_error(*pending, 0, "JNI signer dispatch failed"), // The pending box was moved into the closure; on panic before the // token leak it was dropped — nothing left to complete. Rust's // 5-minute completion timeout bounds the damage of this edge. @@ -214,15 +224,18 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_destroyS /// Complete an in-flight sign request. Exactly one of /// `signature`/`errorMessage` should be non-null; a null signature with a -/// null error is treated as a generic failure. The token is consumed — -/// calling twice with the same token is undefined and prevented on the -/// Kotlin side. +/// null error is treated as a generic failure. `errorCode` is a +/// `DashSDKSignerErrorCode` discriminant classifying the failure (0 = +/// generic; ignored on success) — the structured discriminator of +/// dashpay/platform#4060 finding 7. The token is consumed — calling twice +/// with the same token is undefined and prevented on the Kotlin side. #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_completeSign( mut env: JNIEnv, _class: JClass, token: jlong, signature: JByteArray, + error_code: jint, error_message: JString, ) { guard(&mut env, (), |env| { @@ -238,13 +251,14 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_complete pending.completion_ctx, bytes.as_ptr(), bytes.len(), + 0, ptr::null(), ); } return; } let _ = env.exception_clear(); - unsafe { complete_with_error(*pending, "signature marshalling failed") }; + unsafe { complete_with_error(*pending, 0, "signature marshalling failed") }; return; } @@ -258,7 +272,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_complete String::from("signing failed") }) }; - unsafe { complete_with_error(*pending, &message) }; + unsafe { complete_with_error(*pending, error_code, &message) }; }); } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift index 7cf6439802..4850c08a80 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift @@ -775,18 +775,19 @@ private func keychainSignerSignAsyncTrampoline( dataToSign = Data() } - func reportError(_ message: String) { + func reportError(_ message: String, code: Int32 = KeychainSignerCompletionErrorCode.generic) { // C strings have to outlive the call. `withCString` does this - // for us. + // for us. `code` is the structured DashSDKSignerErrorCode + // discriminator (dashpay/platform#4060 finding 7). message.withCString { errPtr in - completion(completionCtx, nil, 0, errPtr) + completion(completionCtx, nil, 0, code, errPtr) } } func reportSuccess(_ sig: Data) { sig.withUnsafeBytes { sigBuf in let base = sigBuf.bindMemory(to: UInt8.self).baseAddress - completion(completionCtx, base, UInt(sigBuf.count), nil) + completion(completionCtx, base, UInt(sigBuf.count), 0, nil) } } @@ -845,7 +846,14 @@ private func keychainSignerSignAsyncTrampoline( let privateKey: Data switch signer.lookupIdentityPrivateKey(publicKey: pubkeyData) { case .failure(let err): - reportError(err.localizedDescription) + // "No stored key" outcomes carry the structured + // SigningKeyUnavailable code so hosts get the typed + // PlatformWalletError.signingKeyUnavailable without message + // sniffing (dashpay/platform#4060 finding 7). + reportError( + err.localizedDescription, + code: keychainSignerCompletionErrorCode(for: err) + ) return case .success(let priv): privateKey = priv @@ -883,3 +891,24 @@ private func keychainSignerCanSignTrampoline( /// `dash_sdk_signer_destroy` runs from `deinit`. Kept around so /// the Rust vtable's `destroy` slot is always non-null. private func keychainSignerDestroyTrampoline(_: UnsafeMutableRawPointer?) {} + +/// Mirror of `rs-sdk-ffi`'s `DashSDKSignerErrorCode` — the structured +/// completion-failure discriminator (dashpay/platform#4060 finding 7). +/// Only `generic` and `signingKeyUnavailable` are emitted today. +enum KeychainSignerCompletionErrorCode { + static let generic: Int32 = 0 + static let signingKeyUnavailable: Int32 = 1 +} + +/// Classify a `KeychainSigner.Error` for the completion's structured +/// `error_code`: the "no stored key" outcomes — missing row/scalar or a +/// keychain entry the identifier no longer resolves — are +/// `signingKeyUnavailable`; everything else stays `generic`. +func keychainSignerCompletionErrorCode(for error: KeychainSigner.Error) -> Int32 { + switch error { + case .publicKeyNotFound, .privateKeyMissingFromKeychain: + return KeychainSignerCompletionErrorCode.signingKeyUnavailable + default: + return KeychainSignerCompletionErrorCode.generic + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 3834ca71b4..3ca87b12fe 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -69,6 +69,16 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// Core definitively rejected the transaction. Its reserved inputs were /// released and a corrected transaction may be submitted again. case errorTransactionBroadcastRejected = 26 + // Raw value 26 above (errorTransactionBroadcastRejected) landed on + // v4.1-dev. Raw values 27-28 remain reserved for the deferred-payment + // reservation-token errors (dashpay/platform#4185, which must renumber off + // 26) and 29/30 for the asset-lock funding errors + // (dashpay/platform#4184) on sibling branches. + /// A state transition could not be signed because the signer has no + /// usable private key for the requested public key — restored from the + /// structured signer completion code (dashpay/platform#4060 finding 7). + /// Route to key repair; not retryable as-is. + case errorSigningKeyUnavailable = 31 case notFound = 98 case errorUnknown = 99 @@ -128,6 +138,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorAssetLockFundingMismatch case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_TRANSACTION_BROADCAST_REJECTED: self = .errorTransactionBroadcastRejected + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SIGNING_KEY_UNAVAILABLE: + self = .errorSigningKeyUnavailable case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -250,6 +262,12 @@ public enum PlatformWalletError: LocalizedError { /// to retry, and the retry re-fetches the address nonce so the mismatch /// self-heals. The submitted/expected nonce values are in the message. case addressNonceMismatch(String) + /// The signer has no usable private key for the requested public key + /// (missing / stranded scalar) — the operation itself did not fail. + /// Restored from the structured signer completion code + /// (dashpay/platform#4060 finding 7); route to key repair. Kotlin + /// parity: `DashSdkError.PlatformWallet.SigningKeyUnavailable`. + case signingKeyUnavailable(String) case notFound(String) case unknown(String) @@ -272,6 +290,7 @@ public enum PlatformWalletError: LocalizedError { .transactionBroadcastUnconfirmed(let m), .transactionBroadcastRejected(let m), .addressNonceMismatch(let m), + .signingKeyUnavailable(let m), .notFound(let m), .unknown(let m): return m } @@ -313,6 +332,8 @@ public enum PlatformWalletError: LocalizedError { self = .transactionBroadcastRejected(detail) case .errorAddressNonceMismatch: self = .addressNonceMismatch(detail) + case .errorSigningKeyUnavailable: + self = .signingKeyUnavailable(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index 1170520130..5d9802c31c 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -33,6 +33,39 @@ final class ErrorHandlingTests: XCTestCase { ) } + func testSigningKeyUnavailableFFIResultMapping() { + // The structured signer discriminator (dashpay/platform#4060 + // finding 7): PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable + // (31) surfaces as the typed .errorSigningKeyUnavailable / + // PlatformWalletError.signingKeyUnavailable — no message sniffing. + XCTAssertEqual( + PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SIGNING_KEY_UNAVAILABLE + ), + .errorSigningKeyUnavailable + ) + } + + func testKeychainSignerMissingKeyErrorsClassifyAsSigningKeyUnavailable() { + // The trampoline's structured completion code: "no stored key" + // outcomes carry SigningKeyUnavailable (1); operational failures + // stay Generic (0). + XCTAssertEqual( + keychainSignerCompletionErrorCode(for: .publicKeyNotFound), + KeychainSignerCompletionErrorCode.signingKeyUnavailable + ) + XCTAssertEqual( + keychainSignerCompletionErrorCode( + for: .privateKeyMissingFromKeychain(account: "acct") + ), + KeychainSignerCompletionErrorCode.signingKeyUnavailable + ) + XCTAssertEqual( + keychainSignerCompletionErrorCode(for: .ffiSignFailed(message: "boom")), + KeychainSignerCompletionErrorCode.generic + ) + } + // MARK: - ErrorCategory Tests func testErrorCategoryIsUserRecoverable() { From 1db39d15a88d071c6d04cec1eab659a42a26c616 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:37:06 -0400 Subject: [PATCH 11/26] test(kotlin-sdk): adapt instrumented Keystore tests to the policy-alias split The inherited #4172 instrumented tests hardcode the legacy KEYS_ALIAS and the single-alias API; after the alias split the default-policy storage writes under KEYS_ALIAS_AUTH_GATED, so simulating keypair replacement by deleting the legacy alias no longer invalidates anything (the CI failure shumkov diagnosed on storeIfAbsentRederivesWhenTheKeysAliasKeypairWasReplaced). - WalletStorageOwnershipTest: the replaced-keypair, rejected-blob, and no-regeneration probes delete/inspect KEYS_ALIAS_AUTH_GATED; the fingerprint-capture test uses encryptForIdentityKeysAlias (and pins the captured producing alias); the stale-invalidation-cleanup test uses the alias-parameterized deleteIdentityKeysAliasIfCurrentGeneration. - KeystoreSignerInstrumentedTest: canSignWith rejection simulates replacement of the policy alias. The five DashPayUnlockAndSyncTest / WalletManagerRoundTripTest failures from PR #4183's CI run were NOT test or SDK defects: the branch predated #4172's workflow hardening (screen_off_timeout / stayon / dismiss-keyguard + the hard deviceLocked=0 guard), so the emulator re-locked mid-run and every MASTER_ALIAS operation threw InvalidKeyException. Rebuilding on the current base inherits the fixed workflow; those tests are unchanged. Co-Authored-By: Claude Fable 5 --- .../KeystoreSignerInstrumentedTest.kt | 5 +- .../security/WalletStorageOwnershipTest.kt | 83 ++++++++++--------- 2 files changed, 48 insertions(+), 40 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/security/KeystoreSignerInstrumentedTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/security/KeystoreSignerInstrumentedTest.kt index 1106824c26..0b1e7bc1fc 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/security/KeystoreSignerInstrumentedTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/security/KeystoreSignerInstrumentedTest.kt @@ -55,9 +55,10 @@ class KeystoreSignerInstrumentedTest { // The DataStore entry still exists after a Keystore replacement, // but the replacement private key can never decrypt it. Capability - // must describe signability, not mere ciphertext presence. + // must describe signability, not mere ciphertext presence. The + // default-policy storage writes under KEYS_ALIAS_AUTH_GATED. val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - keyStore.deleteEntry(KeystoreManager.KEYS_ALIAS) + keyStore.deleteEntry(KeystoreManager.KEYS_ALIAS_AUTH_GATED) assertFalse(signer.canSignWith(publicKey, keyType = 0)) } diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/security/WalletStorageOwnershipTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/security/WalletStorageOwnershipTest.kt index b5dc510b5a..fd2c262f45 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/security/WalletStorageOwnershipTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/security/WalletStorageOwnershipTest.kt @@ -24,13 +24,14 @@ import java.security.KeyStore * `NoSuchAlgorithmException` there). * * Any test here that reaches [WalletStorage.storePrivateKey] generates the - * `KEYS_ALIAS` RSA keypair via [KeystoreManager.ensureKeysKeyPair], which - * requires `setUserAuthenticationRequired(true)` — Android Keystore - * refuses to create that key without a secure lock screen enrolled on the - * device. This is the first androidTest in this module to exercise that - * code path (every existing androidTest avoids identity-key storage), so - * CI's emulator setup now enrolls one (`adb shell locksettings set-pin` - * in `.github/workflows/kotlin-sdk-build.yml`) before running this suite. + * default policy's `KEYS_ALIAS_AUTH_GATED` RSA keypair, which requires + * `setUserAuthenticationRequired(true)` — Android Keystore refuses to + * create that key without a secure lock screen enrolled on the device + * (a lockless device would degrade the write to the DEVICE_BOUND alias). + * This is the first androidTest in this module to exercise that code path + * (every existing androidTest avoids identity-key storage), so CI's + * emulator setup enrolls one (`adb shell locksettings set-pin` in + * `.github/workflows/kotlin-sdk-build.yml`) before running this suite. */ @RunWith(AndroidJUnit4::class) class WalletStorageOwnershipTest { @@ -147,13 +148,15 @@ class WalletStorageOwnershipTest { storage.storePrivateKey("d15ca4d3", ByteArray(32) { 4 }, ownerWalletId = walletA) assertTrue(storage.isPrivateKeyDecryptable("d15ca4d3")) - // Simulate KEYS_ALIAS being replaced (Keystore data loss + a fresh - // key generated on next use, or a DataStore-only backup restore - // reintroducing this exact blob onto a device with its own key) — - // delete the entry directly so the old RSA-shaped blob now sits - // under a keypair that never encrypted it. + // Simulate the POLICY alias being replaced (Keystore data loss + a + // fresh key generated on next use, or a DataStore-only backup + // restore reintroducing this exact blob onto a device with its own + // key) — delete the entry directly so the old RSA-shaped blob now + // sits under a keypair that never encrypted it. The default-policy + // storage writes under KEYS_ALIAS_AUTH_GATED (the legacy KEYS_ALIAS + // is read-only and never wrote this blob). val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - keyStore.deleteEntry(KeystoreManager.KEYS_ALIAS) + keyStore.deleteEntry(KeystoreManager.KEYS_ALIAS_AUTH_GATED) assertFalse( "an RSA-shaped blob encrypted under a replaced keypair must not be trusted by shape alone", @@ -178,7 +181,7 @@ class WalletStorageOwnershipTest { // signal, not an invitation to attempt OAEP with the replacement // private key and surface a provider-specific decryption failure. val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - keyStore.deleteEntry(KeystoreManager.KEYS_ALIAS) + keyStore.deleteEntry(KeystoreManager.KEYS_ALIAS_AUTH_GATED) assertNull(storage.retrievePrivateKey("57a1eb10")) } @@ -187,61 +190,65 @@ class WalletStorageOwnershipTest { fun capabilityProbeDoesNotRegenerateAMissingKeysAlias() = runBlocking { storage.storePrivateKey("9c0ffee0", ByteArray(32) { 4 }, ownerWalletId = walletA) - // Deleting KEYS_ALIAS is exactly what invalidation cleanup does. The - // signer capability probe reaches this through canSignWith on a Rust - // callback thread, so it must stay read-only: report the blob unusable - // WITHOUT falling through to ensureKeysKeyPair and generating a fresh - // RSA-2048 pair under the process-wide lock (which would block that - // thread and mutate Keystore state on a mere probe). + // Deleting the policy alias is exactly what invalidation cleanup + // does. The signer capability probe reaches this through canSignWith + // on a Rust callback thread, so it must stay read-only: report the + // blob unusable WITHOUT falling through to key generation for a + // fresh RSA-2048 pair under the process-wide lock (which would block + // that thread and mutate Keystore state on a mere probe). val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - keyStore.deleteEntry(KeystoreManager.KEYS_ALIAS) + keyStore.deleteEntry(KeystoreManager.KEYS_ALIAS_AUTH_GATED) assertFalse(storage.isPrivateKeyDecryptable("9c0ffee0")) assertNull( - "capability probe must not regenerate KEYS_ALIAS", - keyStore.getCertificate(KeystoreManager.KEYS_ALIAS), + "capability probe must not regenerate the policy alias", + keyStore.getCertificate(KeystoreManager.KEYS_ALIAS_AUTH_GATED), ) } @Test fun keysAliasEncryptionCarriesTheFingerprintOfItsEncryptionKey() { val keystore = KeystoreManager() + val alias = KeystoreManager.KEYS_ALIAS_AUTH_GATED - val encrypted = keystore.encryptForKeysAlias(ByteArray(32) { 6 }) + val encrypted = keystore.encryptForIdentityKeysAlias(alias, ByteArray(32) { 6 }) - // The returned fingerprint is a snapshot of the exact key the - // ciphertext was produced with, not a live re-read of the alias. - assertEquals(keystore.keysAliasFingerprint(), encrypted.keyFingerprint) + // The returned fingerprint (and producing alias) is a snapshot of + // the exact key the ciphertext was produced with, not a live + // re-read of the alias. + assertEquals(keystore.keysAliasFingerprint(alias), encrypted.keyFingerprint) + assertEquals(alias, encrypted.alias) - // Rotating KEYS_ALIAS afterward must not retroactively change what the + // Rotating the alias afterward must not retroactively change what the // blob claims: were the fingerprint re-derived from the current alias // instead of captured at encrypt time, this would still match the new // key and the mislabel race (old-key ciphertext, new-key fingerprint) // would be reachable. It must stay bound to the retired key. val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - keyStore.deleteEntry(KeystoreManager.KEYS_ALIAS) + keyStore.deleteEntry(alias) - assertNotEquals(keystore.keysAliasFingerprint(), encrypted.keyFingerprint) + assertNotEquals(keystore.keysAliasFingerprint(alias), encrypted.keyFingerprint) } @Test fun staleInvalidationCleanupDoesNotDeleteAReplacementKeysAlias() { val keystore = KeystoreManager() - val invalidatedGeneration = keystore.keysAliasFingerprint() + val alias = KeystoreManager.KEYS_ALIAS_AUTH_GATED + val invalidatedGeneration = keystore.keysAliasFingerprint(alias) // Model another invalidated decryptor winning the cleanup race, then - // a writer regenerating KEYS_ALIAS before this stale decryptor gets - // the lock. Its cleanup must not orphan ciphertext written under the - // replacement generation. + // a writer regenerating the policy alias before this stale decryptor + // gets the lock. Its cleanup must not orphan ciphertext written + // under the replacement generation. val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - keyStore.deleteEntry(KeystoreManager.KEYS_ALIAS) - val replacementGeneration = keystore.keysAliasFingerprint() + keyStore.deleteEntry(alias) + val replacementGeneration = keystore.keysAliasFingerprint(alias) assertNotEquals(invalidatedGeneration, replacementGeneration) assertFalse( - keystore.deleteKeysAliasIfCurrentGeneration(invalidatedGeneration), + keystore.deleteIdentityKeysAliasIfCurrentGeneration(alias, invalidatedGeneration), ) - assertEquals(replacementGeneration, keystore.keysAliasFingerprint()) + assertEquals(replacementGeneration, keystore.keysAliasFingerprint(alias)) } @Test From a7d564dfbc79c6e8eed63d9631ee20ef6b31cf5b Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:45:17 -0400 Subject: [PATCH 12/26] docs(sdk): record Keystore-rework parity divergences, code-31 capability, and Room v8 shift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sdk-parity-manifest.json (+ regenerated PARITY_SUMMARY.md): new capability entries for the KeySecurityPolicy alias split (Swift not-applicable — iOS Keychain has no per-alias auth-parameter analog), the now-CONVERGENT platform-wallet code-98 typed mapping (was a divergence), the Kotlin-only durable pending-repair surface (Swift gap recorded), and the structured SigningKeyUnavailable discriminator (both hosts, unit-verification pointers on each). shared_symbols gains dash_sdk_sign_async_completion. - KOTLIN_SWIFT_SHARED_PARITY_SPEC.md: rationale for each divergence / convergence, the deprecated MESSAGE_MARKER fallback with its next-minor-release removal horizon, and the migration ladder update (v8 = derivation breadcrumbs; invitations shift to v9). - KOTLIN_MIGRATION_LEFTOVERS.md: marker-fallback removal and the on-device KeyPermanentlyInvalidatedException residual (unit-tier only via the fake seam, same as #4172). - Swift: testPlatformWalletNotFoundFFIResultMapping pins the convergent 98 mapping the manifest references. Co-Authored-By: Claude Fable 5 --- docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md | 18 ++ docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md | 38 +++- docs/sdk/sdk-parity-manifest.json | 162 ++++++++++++++++++ packages/kotlin-sdk/PARITY_SUMMARY.md | 18 +- .../ErrorHandlingTests.swift | 10 ++ 5 files changed, 237 insertions(+), 9 deletions(-) diff --git a/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md b/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md index dd4904bc39..38a641c102 100644 --- a/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md +++ b/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md @@ -54,6 +54,24 @@ when the four stacked PRs collapse into one. names its concrete remaining FFI, persistence, UI/catalog-adaptation, device, or restart gate. +- **`SigningKeyUnavailable` MESSAGE_MARKER fallback removal.** The signer's + "missing key" failure now travels as a typed completion code (rs-sdk-ffi + `DashSDKSignerErrorCode::SigningKeyUnavailable` → platform-wallet code 31 → + `DashSdkError.PlatformWallet.SigningKeyUnavailable`). The message-marker + sniff on the catch-all codes is retained ONLY for old-native/new-Kotlin + partial builds; delete it (and `MESSAGE_MARKER`'s matcher role) in the next + minor release. Accepted residual until rs-dpp grows a typed variant: the + Rust-internal segment rides the `signer_error:key_unavailable: ` prefix + through `ProtocolError::Generic` (typed at both ABI edges, one Rust-owned + constant bridging the string segment). + +- **On-device `KeyPermanentlyInvalidatedException` coverage.** The + invalidation recovery (generation-checked alias deletion + re-derive via + forced repair) is pinned at the unit tier through the fake Keystore seam; + a REAL KPIE requires biometric re-enrollment mid-test, which CI's emulator + cannot do — same residual #4172 accepted. Exercise manually per the device + test plan when touching the invalidation path. + ## Environment-bound (cannot be code-fixed here) - **End-to-end send→accept→pay testnet UAT** — device/testnet-bound; not runnable in diff --git a/docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md b/docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md index 3ca47b7ff7..fbde1adf23 100644 --- a/docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md +++ b/docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md @@ -630,8 +630,10 @@ Required validation per slice: ## 12. Compatibility and rollout - Database migrations are additive and serialized as v5 unsigned token storage, v6 - FVK, v7 provider membership/position, and v8 invitations; do not - destructively rewrite address identity columns. + FVK, v7 provider membership/position, and v8 identity-key derivation + breadcrumbs (pending-repair durability, dashpay/platform#4060); the + invitations migration shifts to v9. Do not destructively rewrite address + identity columns. - New Rust FFI functions are additive and new result PODs are opaque/versioned. Existing released split-builder entry points are deprecated before removal; existing struct layouts and JNI descriptors do not change silently. The @@ -644,6 +646,38 @@ Required validation per slice: - The parity manifest initially records known gaps. CI prevents regression but does not require all gaps to close in the first slice. +### Keystore rework divergences and convergences (dashpay/platform#4060) + +Recorded in `sdk-parity-manifest.json`; rationale here: + +- **`KeySecurityPolicy` + Keystore alias split (Kotlin-only, deliberate).** + Android Keystore fixes authentication parameters at key generation, so the + AUTH_GATED/DEVICE_BOUND policies require distinct aliases; iOS Keychain has + no per-alias auth-parameter analog (item access control covers the same + ground), so the manifest marks Swift `not-applicable` — no Swift port is + planned. The lockless-device degradation (AUTH_GATED writes redirect to the + DEVICE_BOUND alias, surfaced via `effectiveKeySecurityPolicy`) is likewise + Android-specific: KeyMint rejects gated key generation without a secure + lock screen. +- **Platform-wallet code 98 is now CONVERGENT.** Kotlin previously collapsed + the blanket Option-miss code into the top-level `DashSdkError.NotFound` + while Swift kept it in the wallet family; Kotlin now maps 98 to + `DashSdkError.PlatformWallet.NotFound` (BREAKING for hosts that caught the + top-level type from platform-wallet operations). +- **Durable pending-repair surface (Kotlin-only, port candidate).** + `pendingIdentityKeys` + forced/verified `repairIdentityKey` + the Room v8 + derivation breadcrumbs have no Swift counterpart; the manifest records the + gap as `unsupported` for Swift. +- **Structured `SigningKeyUnavailable` discriminator (both hosts).** The + signer completion carries a typed `error_code` (rs-sdk-ffi + `DashSDKSignerErrorCode`), restored as platform-wallet code 31 on both + hosts. The Rust-internal segment rides the machine prefix + `signer_error:key_unavailable: ` through `ProtocolError::Generic` (a typed + rs-dpp variant was rejected for serialization blast radius — accepted + residual). The Kotlin `MESSAGE_MARKER` text sniff survives ONLY as a + deprecated old-native fallback; remove it (and the marker's matcher role) + in the next minor release once native artifacts are guaranteed current. + ## 13. Explicitly out of scope - Redesigning DIP-13/DIP-15 invitation wire formats. diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index 6d4f698373..3fff995f3b 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -17,6 +17,7 @@ "core_wallet_broadcast_signed_transaction_v2": "packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs", "core_wallet_get_balance": "packages/rs-platform-wallet-ffi/src/core_wallet/wallet.rs", "core_wallet_tx_builder_finalize": "packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs", + "dash_sdk_sign_async_completion": "packages/rs-sdk-ffi/src/signer.rs", "dpns_name_array_free": "packages/rs-platform-wallet-ffi/src/dpns.rs", "managed_identity_get_contested_dpns_names": "packages/rs-platform-wallet-ffi/src/dpns.rs", "on_load_shielded_viewing_keys_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", @@ -1124,6 +1125,167 @@ "covers_restart": false } ] + }, + { + "id": "security.key_security_policy_alias_split", + "title": "Identity-key security policy selects a dedicated Keystore alias (AUTH_GATED default / DEVICE_BOUND opt-in), with honest lockless degradation", + "area": "shared_policy", + "shared_apis": [], + "required_persistence_capabilities": [], + "hosts": { + "swift": { + "sdk": "not-applicable", + "example_app": "not-applicable", + "restart": "not_applicable", + "reason": null + }, + "kotlin": { + "sdk": "supported", + "example_app": "supported", + "restart": "tested", + "reason": null + } + }, + "verification": [ + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyTest.kt", + "id": "effectivePolicyDegradesToDeviceBoundOnALocklessDevice", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:testDebugUnitTest", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt", + "id": "aliasTagRoutesReadsAfterLocklessDegradation", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:testDebugUnitTest", + "covers_restart": true + } + ] + }, + { + "id": "errors.platform_wallet_not_found_typed", + "title": "Platform-wallet code 98 (Option miss) surfaces typed inside the wallet-error family on both hosts", + "area": "shared_policy", + "shared_apis": [], + "required_persistence_capabilities": [], + "hosts": { + "swift": { + "sdk": "supported", + "example_app": "not-applicable", + "restart": "not_applicable", + "reason": null + }, + "kotlin": { + "sdk": "supported", + "example_app": "not-applicable", + "restart": "not_applicable", + "reason": null + } + }, + "verification": [ + { + "host": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift", + "id": "testPlatformWalletNotFoundFFIResultMapping", + "command": "swift test --package-path packages/swift-sdk", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt", + "id": "platformWalletNotFoundCodeMapsToTypedWalletNotFound", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:testDebugUnitTest", + "covers_restart": false + } + ] + }, + { + "id": "persistence.pending_identity_key_repair", + "title": "Watch-only identity-key failures surface as durable, restart-surviving pending-repair state", + "area": "persistence", + "shared_apis": [], + "required_persistence_capabilities": [ + "atomic_changesets", + "wallet_restore" + ], + "hosts": { + "swift": { + "sdk": "unsupported", + "example_app": "unsupported", + "restart": "not_applicable", + "reason": "Swift's persistence handler re-derives inline and has no queryable pending-keys surface or repair verification; the WalletKeyHealthSheet re-derive covers the user path but nothing is durable or typed. Port the pendingIdentityKeys/repairIdentityKey surface if iOS needs the same recoverability guarantees." + }, + "kotlin": { + "sdk": "supported", + "example_app": "not-applicable", + "restart": "tested", + "reason": null + } + }, + "verification": [ + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt", + "id": "reconstructionSeedsPendingFromBreadcrumbRowsWithNullIdentifier", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:testDebugUnitTest", + "covers_restart": true + }, + { + "host": "kotlin", + "kind": "device", + "file": "packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt", + "id": "migrate7To8AddsDerivationBreadcrumbColumns", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:connectedDebugAndroidTest", + "covers_restart": false + } + ] + }, + { + "id": "errors.signing_key_unavailable_discriminator", + "title": "Structured SigningKeyUnavailable signer discriminator (typed code 31, no message sniffing)", + "area": "shared_policy", + "shared_apis": [ + "dash_sdk_sign_async_completion" + ], + "required_persistence_capabilities": [], + "hosts": { + "swift": { + "sdk": "supported", + "example_app": "not-applicable", + "restart": "not_applicable", + "reason": null + }, + "kotlin": { + "sdk": "supported", + "example_app": "not-applicable", + "restart": "not_applicable", + "reason": null + } + }, + "verification": [ + { + "host": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift", + "id": "testSigningKeyUnavailableFFIResultMapping", + "command": "swift test --package-path packages/swift-sdk", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt", + "id": "signingKeyUnavailableCode31MapsTyped", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:testDebugUnitTest", + "covers_restart": false + } + ] } ] } diff --git a/packages/kotlin-sdk/PARITY_SUMMARY.md b/packages/kotlin-sdk/PARITY_SUMMARY.md index 46a78ad427..129cb4081f 100644 --- a/packages/kotlin-sdk/PARITY_SUMMARY.md +++ b/packages/kotlin-sdk/PARITY_SUMMARY.md @@ -2,23 +2,23 @@ # Kotlin/Swift executable parity summary Audit baseline: `PR #3999 @ 6dbc72a54df72d26eb9c4a014b425d2b95134e4e` -Capabilities tracked: **18** +Capabilities tracked: **22** ## Status counts | Host | Surface | Supported | Partial | Unsupported | Not applicable | | --- | --- | ---: | ---: | ---: | ---: | -| Swift | SDK | 10 | 8 | 0 | 0 | -| Swift | Example app | 4 | 12 | 0 | 2 | -| Kotlin | SDK | 5 | 12 | 1 | 0 | -| Kotlin | Example app | 4 | 11 | 1 | 2 | +| Swift | SDK | 12 | 8 | 1 | 1 | +| Swift | Example app | 4 | 12 | 1 | 5 | +| Kotlin | SDK | 9 | 12 | 1 | 0 | +| Kotlin | Example app | 5 | 11 | 1 | 5 | ## Restart coverage | Host | Tested | Required | Not applicable | | --- | ---: | ---: | ---: | -| Swift | 0 | 7 | 11 | -| Kotlin | 2 | 6 | 10 | +| Swift | 0 | 7 | 15 | +| Kotlin | 4 | 6 | 12 | ## Capability status @@ -42,6 +42,10 @@ Capabilities tracked: **18** | `documents.create_catalog` | supported / supported / not_applicable | supported / supported / not_applicable | | `tokens.max_supply_proposal_discovery` | partial / partial / not_applicable | partial / partial / not_applicable | | `core.immature_balance_display` | supported / supported / not_applicable | supported / supported / not_applicable | +| `security.key_security_policy_alias_split` | not-applicable / not-applicable / not_applicable | supported / supported / tested | +| `errors.platform_wallet_not_found_typed` | supported / not-applicable / not_applicable | supported / not-applicable / not_applicable | +| `persistence.pending_identity_key_repair` | unsupported / unsupported / not_applicable | supported / not-applicable / tested | +| `errors.signing_key_unavailable_discriminator` | supported / not-applicable / not_applicable | supported / not-applicable / not_applicable | Source: [`docs/sdk/sdk-parity-manifest.json`](../../docs/sdk/sdk-parity-manifest.json). Detailed legacy view mapping remains in [`PARITY.md`](PARITY.md), but its manual totals are not authoritative. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index 5d9802c31c..1001224cdd 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -33,6 +33,16 @@ final class ErrorHandlingTests: XCTestCase { ) } + func testPlatformWalletNotFoundFFIResultMapping() { + // Code 98 (the blanket Option→result miss) stays typed inside the + // wallet-error family — the mapping Kotlin now converges on + // (DashSdkError.PlatformWallet.NotFound), dashpay/platform#4060. + XCTAssertEqual( + PlatformWalletResultCode(ffi: PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND), + .notFound + ) + } + func testSigningKeyUnavailableFFIResultMapping() { // The structured signer discriminator (dashpay/platform#4060 // finding 7): PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable From b503531ad1db6beaa9158664157a66424c73e2dd Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:06:42 -0400 Subject: [PATCH 13/26] fix(kotlin-sdk): disprove replaced-alias ownership before counting a closed auth window as recoverable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit probeIdentityKeyRecoverability treated UserNotAuthenticatedException as "recoverable" unconditionally. UNAE is thrown at cipher.init — before the ciphertext is examined — so after a Keystore loss + regeneration the fresh auth-gated key (whose window is closed in the steady state; it is only open ~30 s after an auth) reported a blob it can never open as healthy: the key-health sheet offered no repair while pendingIdentityKeys simultaneously listed the same key (#4060 round-2 finding 1). Prompt-free fix: UNAE counts as recoverable ONLY while the blob's stored write-time fingerprint still matches the recorded alias's CURRENT key (non-generating read). A mismatch disproves ownership → not recoverable → repair is offered; a fingerprint-matched blob behind a closed window stays recoverable (genuinely just needs auth). The legacy former-RSA rung keeps the documented residual (no fingerprint surface for the retained legacy key). New matrix rows pin both directions. Co-Authored-By: Claude Fable 5 --- .../dashsdk/security/WalletStorage.kt | 36 ++++++++++--- .../WalletStorageUpgradeMatrixTest.kt | 52 ++++++++++++++++++- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt index af43771e25..17513dd20a 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 @@ -821,9 +821,23 @@ class WalletStorage( ) { return false } + // A closed auth window (UserNotAuthenticatedException, thrown at + // cipher.init BEFORE the ciphertext is examined) proves nothing + // about ownership. Count it as recoverable ONLY while the stored + // write-time fingerprint still matches the recorded alias's CURRENT + // key — a prompt-free disproof of the replaced-alias case: after a + // Keystore loss + regeneration, the fresh auth-gated key's window is + // closed almost always (it is only ever open ~AUTH_VALIDITY_SECONDS + // after an auth), so without the fingerprint gate the probe would + // report a blob that key can never open as "healthy" and the + // key-health sheet would offer no repair while pendingIdentityKeys + // simultaneously lists the same key (#4060 round-2 finding). + val storedFingerprint = prefs[privateKeyFingerprintKey(pubkeyHex)] + val unaeProvesRecoverable = storedFingerprint != null && + storedFingerprint == keystore.keysAliasFingerprintOrNull(recordedAlias) return ( keystore.hasIdentityKeysKey(recordedAlias) && - probeOpensBlob { keystore.decrypt(blob, recordedAlias) } + probeOpensBlob(unaeProvesRecoverable) { keystore.decrypt(blob, recordedAlias) } ) || ( keystore.hasLegacyRsaKeysKey() && @@ -834,12 +848,20 @@ class WalletStorage( /** * True iff [decrypt] recovers the 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 recoverable). A wrong-key crypto failure or an absent key (`null`) is - * false. Prompt-free by construction — see [probeIdentityKeyRecoverability]. - * Used only by the non-prompting key-health probe, never on a signing path. + * (`UserNotAuthenticatedException`) AND [unaeProvesRecoverable] — the + * caller's prompt-free evidence that the gated key actually owns the + * blob (the stored write-time fingerprint matches the alias's current + * key). UNAE is thrown at `cipher.init`, before the ciphertext is + * examined, so without that evidence a locked REPLACEMENT key would be + * indistinguishable from a locked legitimate owner. A wrong-key crypto + * failure or an absent key (`null`) is false. Prompt-free by + * construction — see [probeIdentityKeyRecoverability]. Used only by the + * non-prompting key-health probe, never on a signing path. */ - private fun probeOpensBlob(decrypt: () -> ByteArray?): Boolean = + private fun probeOpensBlob( + unaeProvesRecoverable: Boolean = true, + decrypt: () -> ByteArray?, + ): Boolean = try { val plain = decrypt() if (plain != null) { @@ -849,7 +871,7 @@ class WalletStorage( false } } catch (e: UserNotAuthenticatedException) { - true + unaeProvesRecoverable } catch (e: GeneralSecurityException) { false } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt index 481654d186..02abba899e 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt @@ -218,6 +218,48 @@ class WalletStorageUpgradeMatrixTest { assertTrue(storage.isPrivateKeyDecryptable(pub)) } + /** + * #4060 round-2 finding: a REPLACED auth-gated alias (Keystore loss + + * regeneration) whose window is closed — the normal state, the window is + * only ~30 s — throws UserNotAuthenticatedException at cipher.init, + * before the ciphertext is touched. Without the fingerprint disproof the + * probe reported the unopenable blob "healthy" (no repair offered) while + * pendingIdentityKeys listed the same key. The stored fingerprint no + * longer matches the replacement key, which disproves ownership + * prompt-free → not recoverable → the sheet offers repair. + */ + @Test + fun replacedAliasBlobWithClosedAuthWindowIsNotReportedRecoverable() = runBlocking { + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + storage.storePrivateKey(pub, secret) + assertTrue(storage.probeIdentityKeyRecoverability(pub)) + + // Keystore loss + regeneration: fresh keypair at the policy alias… + fake.policyFingerprintSuffix = "-replacement" + // …auth-gated, so its window is closed in the steady state. + fake.throwAuthOnPolicyDecrypt = true + + assertFalse(storage.probeIdentityKeyRecoverability(pub)) + // The cheap capability surface agrees (fingerprint mismatch). + assertFalse(storage.isPrivateKeyDecryptable(pub)) + } + + /** + * The complement guard: a fingerprint-MATCHED blob behind a closed auth + * window stays recoverable — it genuinely just needs authentication, and + * the probe must not prompt (also pinned by + * [authGatedPolicyKeyReportsRecoverableWithoutPrompting]). + */ + @Test + fun fingerprintMatchedBlobWithClosedAuthWindowStaysRecoverable() = runBlocking { + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + storage.storePrivateKey(pub, secret) + + fake.throwAuthOnPolicyDecrypt = true // same key, window merely closed + + assertTrue(storage.probeIdentityKeyRecoverability(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 @@ -521,6 +563,13 @@ private class FakeKeystoreManager : var deviceBoundKeyPresent: Boolean = false var degradeWritesToDeviceBound: Boolean = false var throwAuthOnPolicyDecrypt: Boolean = false + + /** + * Models a Keystore-loss + regeneration of the policy alias: the CURRENT + * key's fingerprint diverges from every previously captured one while + * the alias stays present (and, being auth-gated, usually locked). + */ + var policyFingerprintSuffix: String = "" var throwAuthOnLegacyRsaDecrypt: Boolean = false var throwInvalidatedOnPolicyDecrypt: Boolean = false var throwBadPaddingOnPolicyDecrypt: Boolean = false @@ -548,7 +597,8 @@ private class FakeKeystoreManager : // The real implementation hashes an AndroidKeyStore public key, which // cannot exist on the JVM; presence-driven per-alias values instead. override fun keysAliasFingerprintOrNull(alias: String): String? = when (alias) { - POLICY_ALIAS -> if (policyKeyProvisioned) fpOf(POLICY_ALIAS) else null + POLICY_ALIAS -> + if (policyKeyProvisioned) fpOf(POLICY_ALIAS) + policyFingerprintSuffix else null KEYS_ALIAS_DEVICE_BOUND -> if (deviceBoundKeyPresent) fpOf(KEYS_ALIAS_DEVICE_BOUND) else null else -> null } From 58fd8857164ac568fef3a130fa0c7096ef01b5c2 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:07:50 -0400 Subject: [PATCH 14/26] fix(kotlin-sdk): surface KeyPermanentlyInvalidatedException as the typed signer code on the first attempt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A KPIE escaping the sign path (retrieveKeyWithAuth — the biometric-gate retry only handles UserNotAuthenticatedException) previously fell into the signAsync catch-all and completed with SIGNER_ERROR_CODE_GENERIC: the host saw an opaque wallet-operation failure on the FIRST post-invalidation sign and only got the typed SigningKeyUnavailable on subsequent attempts (once the fingerprint gate reported the blob non-current). #4060 round-2 finding 2. - signWithStoredKey catches KPIE explicitly and completes with SIGNER_ERROR_CODE_KEY_UNAVAILABLE (marker text + invalidation reason kept in the message), so the typed code rides the wire immediately. - The signAsync catch-all classifies via the new pure completionErrorCodeFor(t) (KPIE → key-unavailable, everything else generic) as belt-and-braces for KPIE thrown outside the retrieval call. - KeystoreSignerCompletionCodeTest pins the classification (the full signer cannot be constructed on the JVM — its constructor creates a native handle — so the classification is a pure companion function). Co-Authored-By: Claude Fable 5 --- .../dashsdk/security/KeystoreSigner.kt | 54 +++++++++++++++++-- .../KeystoreSignerCompletionCodeTest.kt | 53 ++++++++++++++++++ 2 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeystoreSignerCompletionCodeTest.kt 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 d9ce718f40..8e05d796e5 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 @@ -1,5 +1,6 @@ package org.dashfoundation.dashsdk.security +import android.security.keystore.KeyPermanentlyInvalidatedException import android.security.keystore.UserNotAuthenticatedException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -81,10 +82,15 @@ class KeystoreSigner( } signWithStoredKey(pubkeyBytes, data, completionToken) } catch (e: Exception) { + // Classify before completing: a KeyPermanentlyInvalidatedException + // anywhere in the sign path means the key is unavailable until + // re-derived, and must surface as the typed code on the FIRST + // attempt — not as an opaque generic failure (#4060 round-2 + // finding 2). SignerNative.completeSign( completionToken, null, - SignerNative.SIGNER_ERROR_CODE_GENERIC, + completionErrorCodeFor(e), e.message ?: "signing failed", ) } @@ -100,7 +106,27 @@ class KeystoreSigner( var key: ByteArray? = null try { val storageKey = storageKeyFor(pubkeyBytes) - key = retrieveKeyWithAuth(storageKey) + key = try { + retrieveKeyWithAuth(storageKey) + } catch (e: KeyPermanentlyInvalidatedException) { + // The Keystore key that wraps this blob was permanently + // invalidated (biometric/credential re-enrollment; the + // generation-checked alias cleanup already ran inside + // KeystoreManager.decrypt for policy aliases). The key is + // unavailable until re-derived — complete with the TYPED + // code on this first attempt instead of letting the generic + // catch-all label it an opaque signing failure (#4060 + // round-2 finding 2). + SignerNative.completeSign( + completionToken, + null, + SignerNative.SIGNER_ERROR_CODE_KEY_UNAVAILABLE, + "${DashSdkError.PlatformWallet.SigningKeyUnavailable.MESSAGE_MARKER} " + + "${storageKey.take(16)}… (key permanently invalidated: " + + "${e.message ?: "re-enrollment"})", + ) + return + } if (key == null) { // The typed SIGNER_ERROR_CODE_KEY_UNAVAILABLE rides the // completion ABI and comes back as platform-wallet code 31 → @@ -263,7 +289,7 @@ class KeystoreSigner( if (h != 0L) SignerNative.destroySigner(h) } - private companion object { + companion object { /** * FFI dispatch tag the Rust `Signer` vtable ships * instead of a `KeyType` discriminant when the "pubkey bytes" are a @@ -271,7 +297,27 @@ class KeystoreSigner( * `SIGNER_KEY_TYPE_PLATFORM_ADDRESS_HASH` (0xFF) in * `rs-sdk-ffi/src/signer.rs`; arrives here as the unsigned value 255. */ - const val PLATFORM_ADDRESS_HASH_KEY_TYPE: Int = 0xFF + private const val PLATFORM_ADDRESS_HASH_KEY_TYPE: Int = 0xFF + + /** + * Structured completion code for a sign-path failure [t]: + * [KeyPermanentlyInvalidatedException] is a "key unavailable until + * re-derived" signal and maps to + * [SignerNative.SIGNER_ERROR_CODE_KEY_UNAVAILABLE] (→ platform-wallet + * code 31 → `DashSdkError.PlatformWallet.SigningKeyUnavailable`); + * everything else stays [SignerNative.SIGNER_ERROR_CODE_GENERIC]. + * Note `UserNotAuthenticatedException` never reaches this — the + * biometric-gate retry handles it, and an unhandled one is a generic + * failure, not a missing key. Factored pure so the classification is + * unit-testable without the native signer handle (#4060 round-2 + * finding 2). + */ + internal fun completionErrorCodeFor(t: Throwable): Int = + if (t is KeyPermanentlyInvalidatedException) { + SignerNative.SIGNER_ERROR_CODE_KEY_UNAVAILABLE + } else { + SignerNative.SIGNER_ERROR_CODE_GENERIC + } } } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeystoreSignerCompletionCodeTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeystoreSignerCompletionCodeTest.kt new file mode 100644 index 0000000000..b75de81a71 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeystoreSignerCompletionCodeTest.kt @@ -0,0 +1,53 @@ +package org.dashfoundation.dashsdk.security + +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.UserNotAuthenticatedException +import org.dashfoundation.dashsdk.ffi.SignerNative +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import javax.crypto.BadPaddingException + +/** + * Pins the sign-path completion-code classification (#4060 round-2 finding + * 2): a `KeyPermanentlyInvalidatedException` — the key was invalidated by + * biometric/credential re-enrollment and cannot sign until re-derived — + * must surface as the TYPED `SIGNER_ERROR_CODE_KEY_UNAVAILABLE` on the + * FIRST attempt (→ platform-wallet code 31 → + * `DashSdkError.PlatformWallet.SigningKeyUnavailable`), never as an opaque + * generic failure. Classification is a pure companion function because the + * full [KeystoreSigner] cannot be constructed on the JVM (its constructor + * creates a native signer handle); Robolectric supplies the + * `android.security.keystore` exception types. + */ +@RunWith(RobolectricTestRunner::class) +class KeystoreSignerCompletionCodeTest { + + @Test + fun keyPermanentlyInvalidatedClassifiesAsKeyUnavailable() { + assertEquals( + SignerNative.SIGNER_ERROR_CODE_KEY_UNAVAILABLE, + KeystoreSigner.completionErrorCodeFor(KeyPermanentlyInvalidatedException()), + ) + } + + @Test + fun otherSignFailuresStayGeneric() { + assertEquals( + SignerNative.SIGNER_ERROR_CODE_GENERIC, + KeystoreSigner.completionErrorCodeFor(IllegalStateException("boom")), + ) + assertEquals( + SignerNative.SIGNER_ERROR_CODE_GENERIC, + KeystoreSigner.completionErrorCodeFor(BadPaddingException("wrong key")), + ) + // A closed auth window is handled by the biometric-gate retry; an + // unhandled one is a generic failure, NOT a missing key — the key + // exists and opens after auth. + assertEquals( + SignerNative.SIGNER_ERROR_CODE_GENERIC, + KeystoreSigner.completionErrorCodeFor(UserNotAuthenticatedException()), + ) + } +} From 8504f22d05a13a9a81be8772e3ca44c6766c35cc Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:09:21 -0400 Subject: [PATCH 15/26] fix(kotlin-sdk): make a sign-time key invalidation durably repairable for legacy-alias keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A KeyPermanentlyInvalidatedException on a LEGACY-alias-backed key was invisible everywhere: the legacy Keystore aliases are read-only (no deletion boundary), so hasLegacyKeysKey() stays true forever — canSignWith keeps offering the key, the cheap capability check keeps reporting the blob usable, the restart reconstruction never seeds a repair slot, and every sign fails opaquely (#4060 round-2 finding 3). Fix without making the cheap check expensive (it is untouched): the sign path's KPIE classification now invokes an onSigningKeyInvalidated hook, wired by PlatformWalletManager to PlatformWalletPersistenceHandler.recordSigningKeyInvalidated, which nulls privateKeyKeychainIdentifier on the key's Room rows — the same durable signal the restart reconstruction reads — and re-runs the reconstruction so pendingIdentityKeys seeds after the FIRST failed sign (reason: "signing key permanently invalidated"), making repair reachable outside the health sheet. Best-effort: bookkeeping failure never eats the typed completion. Harmless for policy-alias keys (their generation-checked deletion already flips the fingerprint gate; this just accelerates the in-process seed). Pre-v8 rows without breadcrumbs still get the identifier null-out and seed once the next persist round back-fills their breadcrumbs. Co-Authored-By: Claude Fable 5 --- .../PlatformWalletPersistenceHandler.kt | 39 +++++++++++++++++- .../dashsdk/security/KeystoreSigner.kt | 19 ++++++++- .../dashsdk/wallet/PlatformWalletManager.kt | 15 +++++++ .../PlatformWalletPersistenceHandlerTest.kt | 41 +++++++++++++++++++ 4 files changed, 112 insertions(+), 2 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index f32676e0dc..9eac1c8ae4 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 @@ -2625,6 +2625,42 @@ class PlatformWalletPersistenceHandler( _pendingIdentityKeys.update(clearPendingKeyDelta(publicKeyHex)) } + /** + * Durable bookkeeping for a sign-time + * `KeyPermanentlyInvalidatedException` (#4060 round-2 finding 3): null + * out `privateKeyKeychainIdentifier` on every `public_keys` row carrying + * [pubkeyHex], then re-run the pending-repair reconstruction so + * [pendingIdentityKeys] seeds NOW — not just after the next restart. + * + * Load-bearing for LEGACY-alias-backed keys: the legacy Keystore aliases + * are read-only (no deletion boundary), so after a KPIE the CHEAP + * capability check keeps reporting the blob signable forever + * (`hasLegacyKeysKey()` stays true) — the null identifier is the only + * durable signal the reconstruction's usability filter can see. Harmless + * for policy-alias keys (their generation-checked deletion already flips + * the fingerprint gate; this merely accelerates the in-process seed). + * Wired from `KeystoreSigner.onSigningKeyInvalidated` via + * `PlatformWalletManager`. Rows without derivation breadcrumbs + * (pre-v8 legacy rows not yet re-persisted) cannot seed a repair slot — + * the identifier null-out still lands, so they seed as soon as the next + * persist round back-fills the breadcrumbs. + */ + internal suspend fun recordSigningKeyInvalidated( + pubkeyHex: String, + isPrivateKeyDecryptable: suspend (pubkeyHex: String) -> Boolean, + ) { + val publicKeyData = pubkeyHex.hexToByteArray() + for (row in database.publicKeyDao().getByPublicKeyData(publicKeyData)) { + if (row.privateKeyKeychainIdentifier != null) { + database.publicKeyDao().update(row.copy(privateKeyKeychainIdentifier = null)) + } + } + reconstructPendingIdentityKeysFromPersistence( + isPrivateKeyDecryptable = isPrivateKeyDecryptable, + reason = "signing key permanently invalidated", + ) + } + /** * Rebuild [pendingIdentityKeys] from persistence after a process restart * (dashpay/platform#4060 finding 5) — the in-memory map is process- @@ -2651,6 +2687,7 @@ class PlatformWalletPersistenceHandler( internal suspend fun reconstructPendingIdentityKeysFromPersistence( isPrivateKeyDecryptable: suspend (pubkeyHex: String) -> Boolean, nowMs: Long = System.currentTimeMillis(), + reason: String = "reconstructed from persistence after restart", ) { val rows = database.publicKeyDao().getWithDerivationBreadcrumbs() if (rows.isEmpty()) return @@ -2675,7 +2712,7 @@ class PlatformWalletPersistenceHandler( publicKeyHex = pubkeyHex, identityIndex = identityIndex, keyIndex = keyIndex, - reason = "reconstructed from persistence after restart", + reason = reason, failedAtMs = nowMs, ) } 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 8e05d796e5..7f79fd20a7 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 @@ -44,6 +44,20 @@ class KeystoreSigner( private val network: Network, private val biometricGate: BiometricGate?, private val platformAddressDao: PlatformAddressDao, + /** + * Invoked (on the signer's IO scope, best-effort) when a sign attempt + * classifies a [KeyPermanentlyInvalidatedException] for the given + * storage-key pubkey hex — the wiring point for durable pending-repair + * bookkeeping. Load-bearing for LEGACY-alias-backed keys (#4060 round-2 + * finding 3): the legacy aliases are read-only — there is no deletion + * boundary — so the cheap capability check (`hasLegacyKeysKey`) keeps + * reporting an invalidated legacy key signable forever and the restart + * reconstruction never seeds it; this hook is the only signal that + * makes the repair path reachable outside the health sheet. + * `PlatformWalletManager` wires it to record the invalidation on the + * Room rows and re-seed `pendingIdentityKeys`. + */ + private val onSigningKeyInvalidated: (suspend (pubkeyHex: String) -> Unit)? = null, ) : NativeSignerBridge(), AutoCloseable { private val handleRef = @@ -116,7 +130,10 @@ class KeystoreSigner( // unavailable until re-derived — complete with the TYPED // code on this first attempt instead of letting the generic // catch-all label it an opaque signing failure (#4060 - // round-2 finding 2). + // round-2 finding 2). Record the invalidation durably first + // (finding 3) — best-effort: bookkeeping failure must not + // eat the typed completion. + runCatching { onSigningKeyInvalidated?.invoke(storageKey) } SignerNative.completeSign( completionToken, null, 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 b769bdefac..a517022cde 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 @@ -374,6 +374,21 @@ class PlatformWalletManager( .also { mnemonicResolver = it } val keySigner = KeystoreSigner( walletStorage, network, biometricGate, database.platformAddressDao(), + // Durable invalidation bookkeeping (#4060 round-2 finding 3): + // a sign-time KeyPermanentlyInvalidatedException nulls the + // Room rows' keychain identifier and re-seeds + // pendingIdentityKeys, making repair reachable even for + // legacy-alias keys the cheap capability check can never see + // as broken. `persistenceHandler` resolves through + // coreChildren AFTER construction completes — the lambda only + // runs on later sign attempts, never during this block. + onSigningKeyInvalidated = { pubkeyHex -> + runCatching { + persistenceHandler.recordSigningKeyInvalidated(pubkeyHex) { + walletStorage.isPrivateKeyDecryptable(it) + } + } + }, ).also { signer = it } val deriver = IdentityKeyPrivateKeyDeriver( network = network, 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 6b2b5d427f..a63b6a121d 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 @@ -1332,6 +1332,47 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(restarted.pendingIdentityKeys.value.isEmpty()) } + /** + * #4060 round-2 finding 3: a KeyPermanentlyInvalidatedException on a + * LEGACY-alias-backed key is invisible to the cheap capability check — + * the legacy aliases are read-only (no deletion boundary), so + * `hasLegacyKeysKey()` / `isPrivateKeyDecryptable` stay true forever and + * neither canSignWith nor the restart reconstruction ever notices. The + * sign path's invalidation hook must write the durable signal (null the + * Room identifier) and seed pendingIdentityKeys immediately, EVEN while + * the cheap check still claims the key is usable. + */ + @Test + fun signingKeyInvalidationSeedsPendingDespiteAUsableCheapCheck() = runTest { + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, FakeDeriver()) + val identityId = ByteArray(32) { 26 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 18 } + upsertIdentityKey(pubkey, identityId) // healthy: identifier + breadcrumbs + assertTrue(handler.pendingIdentityKeys.value.isEmpty()) + + // Legacy-alias KPIE: the cheap check KEEPS reporting usable (true). + handler.recordSigningKeyInvalidated(pubkey.toHex()) { true } + + // Durable: the Room identifier is nulled… + val row = db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!! + assertNull(row.privateKeyKeychainIdentifier) + // …and the pending state seeds NOW, with the invalidation reason. + val entry = handler.pendingIdentityKeys.value[pubkey.toHex()] + assertNotNull("invalidation must seed a pending entry", entry) + assertEquals("signing key permanently invalidated", entry!!.reason) + assertEquals(3, entry.identityIndex) + assertEquals(5, entry.keyIndex) + + // And the SAME durable path re-seeds after a restart, still despite + // the cheap check claiming usable. + val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + restarted.reconstructPendingIdentityKeysFromPersistence( + isPrivateKeyDecryptable = { true }, + ) + assertNotNull(restarted.pendingIdentityKeys.value[pubkey.toHex()]) + } + @Test fun reconstructionNeverOverwritesALiveEntry() = runTest { handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) From a8554a3f7fcae9ec512ccdfa021a41b32bb968a2 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:10:38 -0400 Subject: [PATCH 16/26] docs(kotlin-sdk): correct the marker-fallback rationale; note the rung-2 TOCTOU window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 nits (#4060): - The MESSAGE_MARKER fallback's stated rationale — "old native library + new Kotlin partial builds" — was not viable: the sign-completion JNI arity change (3→4 args) makes that pairing a type-confused native call on every completion, so mixed artifacts are unsupported outright. The fallback's real purpose is the #4191 merge-order transition (whose marker-based classification predates the typed code 31) plus defense in depth for conversion paths that lose the machine prefix. KDoc/comments in DashSdkError, KeystoreSigner, DashSdkErrorTest, and the parity/leftovers docs now say so; removal horizon unchanged (next minor release). - retrievePrivateKey rung 2 gains a comment naming the accepted fingerprint-read→decrypt TOCTOU window (pre-existing #4172 parity): a concurrent rotation fails as a wrong-key crypto error into the recovery ladder — never stale plaintext. No behavior change. Co-Authored-By: Claude Fable 5 --- docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md | 8 +++-- docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md | 7 ++-- .../dashsdk/errors/DashSdkError.kt | 33 ++++++++++++------- .../dashsdk/security/KeystoreSigner.kt | 9 +++-- .../dashsdk/security/WalletStorage.kt | 7 ++++ .../dashsdk/errors/DashSdkErrorTest.kt | 10 +++--- 6 files changed, 50 insertions(+), 24 deletions(-) diff --git a/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md b/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md index 38a641c102..7113bb97c5 100644 --- a/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md +++ b/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md @@ -58,9 +58,11 @@ when the four stacked PRs collapse into one. "missing key" failure now travels as a typed completion code (rs-sdk-ffi `DashSDKSignerErrorCode::SigningKeyUnavailable` → platform-wallet code 31 → `DashSdkError.PlatformWallet.SigningKeyUnavailable`). The message-marker - sniff on the catch-all codes is retained ONLY for old-native/new-Kotlin - partial builds; delete it (and `MESSAGE_MARKER`'s matcher role) in the next - minor release. Accepted residual until rs-dpp grows a typed variant: the + sniff on the catch-all codes is retained ONLY for the #4191 merge-order + transition and for conversion paths that lose the machine prefix — NOT for + mixed old-native/new-Kotlin builds, which the completion JNI arity change + (3→4 args) makes unsupported outright; delete it (and `MESSAGE_MARKER`'s + matcher role) in the next minor release. Accepted residual until rs-dpp grows a typed variant: the Rust-internal segment rides the `signer_error:key_unavailable: ` prefix through `ProtocolError::Generic` (typed at both ABI edges, one Rust-owned constant bridging the string segment). diff --git a/docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md b/docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md index fbde1adf23..25aed864dd 100644 --- a/docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md +++ b/docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md @@ -675,8 +675,11 @@ Recorded in `sdk-parity-manifest.json`; rationale here: `signer_error:key_unavailable: ` through `ProtocolError::Generic` (a typed rs-dpp variant was rejected for serialization blast radius — accepted residual). The Kotlin `MESSAGE_MARKER` text sniff survives ONLY as a - deprecated old-native fallback; remove it (and the marker's matcher role) - in the next minor release once native artifacts are guaranteed current. + deprecated fallback for the #4191 merge-order transition (marker-based + classification predating the typed code) and for conversion paths that + lose the machine prefix; mixed old-native/new-Kotlin artifacts are + unsupported outright (the sign-completion JNI arity changed 3→4 args). + Remove it (and the marker's matcher role) in the next minor release. ## 13. Explicitly out of scope 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 afa85958f3..9c57d763b1 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 @@ -203,12 +203,17 @@ sealed class DashSdkError( * cannot drift. * * DEPRECATED as a discriminator: superseded by the typed - * code-31 mapping above; the marker match is retained only - * for the transition window where an old native library - * (pre-code-31) is paired with new Kotlin during partial - * builds. Remove the fallback (and this constant's matcher - * role) in the next minor release once the native artifacts - * are guaranteed current. + * code-31 mapping above. The marker match is retained for + * the #4191 merge-order transition — a consumer pinned at + * #4191's revision (marker classification, no native code + * 31) or any Rust conversion path that flattens the signer + * failure to text without the machine prefix still + * classifies via the marker. It is NOT a mixed-artifact + * escape hatch: an old native library paired with new + * Kotlin is unsupported outright (the sign-completion JNI + * arity changed from 3 to 4 args — every completion would + * be a type-confused native call). Remove the fallback (and + * this constant's matcher role) in the next minor release. */ const val MESSAGE_MARKER = "no private key stored for" } @@ -293,9 +298,10 @@ sealed class DashSdkError( * completion arrives TYPED as code 31 * (`ErrorSigningKeyUnavailable`, dashpay/platform#4060 finding 7); * the legacy message-marker sniff on the catch-all codes is a - * deprecated transition fallback for old native libraries (never + * deprecated fallback for the #4191 merge-order transition (never * applied to the dedicated retry-semantics types, so those are - * never overridden). + * never overridden; mixed old-native/new-Kotlin artifacts are + * unsupported — see [PlatformWallet.SigningKeyUnavailable.MESSAGE_MARKER]). */ private fun fromPlatformWalletNative( code: Int, @@ -305,10 +311,13 @@ sealed class DashSdkError( // PlatformWalletFFIResultCode variants (platform-wallet-ffi/src/error.rs) 1 -> PlatformWallet.InvalidHandle(message, cause) // ErrorInvalidHandle 6 -> // ErrorWalletOperation - // @Deprecated fallback: the marker sniff survives only for - // old-native/new-Kotlin partial builds; the typed code 31 - // below is the real discriminator (#4060 finding 7). Remove - // with MESSAGE_MARKER's matcher role next minor release. + // @Deprecated fallback: the marker sniff survives for the + // #4191 merge-order transition (and any conversion path + // that lost the machine prefix); the typed code 31 below is + // the real discriminator (#4060 finding 7). NOT for mixed + // old-native/new-Kotlin builds — those are unsupported (the + // completion JNI arity changed). Remove with + // MESSAGE_MARKER's matcher role next minor release. if (isSigningKeyUnavailable(message)) { PlatformWallet.SigningKeyUnavailable(message, cause) } else { 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 7f79fd20a7..825f89d19e 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 @@ -149,9 +149,12 @@ class KeystoreSigner( // completion ABI and comes back as platform-wallet code 31 → // DashSdkError.PlatformWallet.SigningKeyUnavailable // (dashpay/platform#4060 finding 7). The MESSAGE_MARKER text - // is ALSO kept during the transition window so an old native - // library paired with new Kotlin (partial builds) still maps - // via the deprecated message fallback. + // is ALSO kept for the #4191 merge-order transition (its + // marker-based classification predates the typed code) and + // as defense in depth for any conversion path that loses the + // machine prefix — NOT for mixed old-native/new-Kotlin + // builds, which the completion JNI arity change makes + // unsupported outright. SignerNative.completeSign( completionToken, null, 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 17513dd20a..e4374f7722 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 @@ -569,6 +569,13 @@ class WalletStorage( keystore.hasIdentityKeysKey(recordedAlias) ) { // Rung 2 — the blob is CURRENT under the recorded alias. + // + // TOCTOU window (pre-existing #4172 parity, accepted): the alias + // can rotate between the fingerprint read above and this + // decrypt. The stale decrypt then fails as a wrong-key crypto + // error and falls to the recovery ladder below (→ null when no + // former key opens it — a re-derive signal) — never stale + // plaintext, and never an uncaught crypto exception. return try { keystore.decrypt(blob, alias = recordedAlias) } catch (e: UserNotAuthenticatedException) { 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 a7a20b7ca5..8879712206 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 @@ -145,12 +145,14 @@ class DashSdkErrorTest { fun signingKeyUnavailableIsRecognizedByItsMessageMarker() { val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET val marker = DashSdkError.PlatformWallet.SigningKeyUnavailable.MESSAGE_MARKER - // DEPRECATED transition fallback: an OLD native library (pre the - // typed code 31) still returns the completion error as free text - // under the catch-all codes (ErrorUnknown = 99 via the blanket + // DEPRECATED fallback for the #4191 merge-order transition: at + // #4191's revision the completion error travels as free text under + // the catch-all codes (ErrorUnknown = 99 via the blanket // PlatformWalletError conversion, sometimes wrapped as // ErrorWalletOperation = 6) — both must keep surfacing typed until - // the fallback's removal (#4052, #4060 finding 7). + // the fallback's removal (#4052, #4060 finding 7). Mixed + // old-native/new-Kotlin artifacts are unsupported (JNI arity + // change), so that pairing is NOT what this covers. for (code in intArrayOf(6, 99)) { val mapped = DashSdkError.fromNative( DashSDKException(offset + code, "Signing failed: $marker deadbeef00112233…"), From 61dcfcdb4fafc34537fb3623da2b9288ede27d46 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:26:32 -0400 Subject: [PATCH 17/26] fix(kotlin-sdk): rethrow KeyPermanentlyInvalidatedException from the former-RSA rung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 verification finding: tryFormerRsaRecovery collapsed every GeneralSecurityException to null ('not this key'), which swallowed a KPIE from an invalidated pre-alias-split key — the signer's classifier never saw it, so the typed completion and the durable repair seeding (finding-3 hook) never fired for legacy blobs. KPIE now propagates like UserNotAuthenticatedException, with a matrix row pinning the rethrow and the post-regen recovery path. Co-Authored-By: Claude Fable 5 --- .../dashsdk/security/WalletStorage.kt | 9 +++++- .../WalletStorageUpgradeMatrixTest.kt | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) 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 e4374f7722..2ee6d8fd8a 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 @@ -623,13 +623,20 @@ class WalletStorage( * 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. + * never a wrong key, so it propagates unchanged — + * and `KeyPermanentlyInvalidatedException` must propagate too: it means the + * retained former-RSA key EXISTS but was invalidated (lock-screen / + * biometric change), which is not "not this key" — swallowing it to `null` + * would hide the invalidation from the signer's classifier and the durable + * repair seeding (the finding-3 hook) for pre-alias-split blobs. */ private fun tryFormerRsaRecovery(blob: KeystoreManager.EncryptedBlob): ByteArray? = try { keystore.decryptLegacyRsaKeysBlob(blob) } catch (e: UserNotAuthenticatedException) { throw e + } catch (e: KeyPermanentlyInvalidatedException) { + throw e } catch (e: GeneralSecurityException) { null } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt index 02abba899e..bd1e76ffab 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt @@ -401,6 +401,33 @@ class WalletStorageUpgradeMatrixTest { assertNull(storage.retrievePrivateKey(pub)) } + /** + * The invalidation signal must also escape the former-RSA rung: a + * KeyPermanentlyInvalidatedException from the retained pre-alias-split key + * RETHROWS instead of collapsing to null ("not this key"), so the signer's + * classifier types the failure and the durable repair seeding (finding-3 + * hook) fires for pre-alias-split blobs too — swallowing it here left + * legacy keys permanently invisible to reconstruction. + */ + @Test + fun invalidatedFormerRsaKeyRethrowsInsteadOfNull() = runBlocking { + fake.scheme = FakeKeystoreManager.Scheme.FORMER_RSA + storage.storePrivateKey(pub, secret) + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.RSA + + fake.throwInvalidatedOnLegacyRsaDecrypt = true + assertThrows(KeyPermanentlyInvalidatedException::class.java) { + runBlocking { storage.retrievePrivateKey(pub) } + } + + // Once the invalidated key is gone (Keystore regen), recovery resumes + // the normal not-this-key path instead of failing typed forever. + fake.throwInvalidatedOnLegacyRsaDecrypt = false + fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.NONE + assertNull(storage.retrievePrivateKey(pub)) + } + /** * Defense in depth on the fast path: a fingerprint-matched blob whose * decrypt still fails with an unexpected crypto error (rotation race / @@ -571,6 +598,7 @@ private class FakeKeystoreManager : */ var policyFingerprintSuffix: String = "" var throwAuthOnLegacyRsaDecrypt: Boolean = false + var throwInvalidatedOnLegacyRsaDecrypt: Boolean = false var throwInvalidatedOnPolicyDecrypt: Boolean = false var throwBadPaddingOnPolicyDecrypt: Boolean = false var invalidatedCleanupRan: Boolean = false @@ -708,6 +736,9 @@ private class FakeKeystoreManager : // 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() + // An invalidated (lock-screen/biometric change) former RSA key also + // throws at cipher.init, before any padding check. + if (throwInvalidatedOnLegacyRsaDecrypt) throw KeyPermanentlyInvalidatedException() if (blob.ciphertext[0] == TAG_FORMER_RSA) return plaintextOfRsa(blob) throw BadPaddingException("former RSA key cannot open this blob") } From 2ddc838e410f6ca0fec01cd899bf9a405ced2740 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:32:50 -0400 Subject: [PATCH 18/26] fix(kotlin-sdk): derive identity-key repair from persisted breadcrumbs and verify the pubkey before persisting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker 1 (shumkov): the repair path derived from caller-supplied indices (the example app passed the DPP key id as keyIndex), and its only check was probeIdentityKeyRecoverability — which proves the stored blob DECRYPTS, not that the derived key is the RIGHT one. A wrong index derives a different valid scalar that round-trips fine, clears pending state, and persists an unusable key. Fix: - IdentityKeyPrivateKeyDeriver: on the force (repair) path derive the KEYPAIR and verify the derived public key equals publicKeyData BEFORE any store; a mismatch throws IdentityKeyDerivationMismatchException without persisting. - PlatformWalletPersistenceHandler.repairIdentityKeyDurably: new orchestration that reads the derivation slot from the PERSISTED public_keys breadcrumbs (derivationIdentityIndex/derivationKeyIndex), never a caller index; a row without breadcrumbs fails the repair without clearing pending. Hoisted here (not the JVM-unconstructable manager) so it is unit-testable and shares the authoritative pendingIdentityKeys state. - PlatformWalletManager.repairIdentityKey: drop the identityIndex/keyIndex params and delegate; supply only the wallet-scoped probe. - WalletKeyHealthSheet: stop passing report.identityIndex / key.keyId. Tests (PlatformWalletPersistenceHandlerTest): correct breadcrumbs derive, verify, clear pending, and record the durable identifier (deriver called with the persisted 3/5, not a caller index); mismatched breadcrumbs are rejected (mismatch exception), nothing persisted, blob never probed, pending intact; a row without breadcrumbs fails with SigningKeyUnavailable and never derives. Co-Authored-By: Claude Opus 4.8 --- .../example/ui/wallet/WalletKeyHealthSheet.kt | 10 +- .../PlatformWalletPersistenceHandler.kt | 93 +++++++++++ .../security/IdentityKeyPrivateKeyDeriver.kt | 73 +++++++- .../dashsdk/wallet/PlatformWalletManager.kt | 126 ++++++-------- .../PlatformWalletPersistenceHandlerTest.kt | 157 ++++++++++++++++++ 5 files changed, 382 insertions(+), 77 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/WalletKeyHealthSheet.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/WalletKeyHealthSheet.kt index c265a5ea8f..b060d0a2d0 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/WalletKeyHealthSheet.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/WalletKeyHealthSheet.kt @@ -170,11 +170,17 @@ fun WalletKeyHealthSheet( scope.launch { runCatching { withContext(Dispatchers.IO) { + // The repair reads the derivation slot + // from the persisted breadcrumbs on the + // key's row — the example app must NOT + // pass an index (e.g. the DPP key id): + // a wrong slot derives a different valid + // scalar that round-trips fine and + // persists an unusable key + // (dashpay/platform#4060 blocker 1). activeManager.repairIdentityKey( walletId = walletId, publicKeyData = key.publicKeyData, - identityIndex = report.identityIndex, - keyIndex = key.keyId, ) } } 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 9eac1c8ae4..8973d0e840 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 @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.ffi.AccountSpecData import org.dashfoundation.dashsdk.ffi.ContactProfileRestoreData import org.dashfoundation.dashsdk.ffi.ContactRequestRestoreData @@ -2625,6 +2626,98 @@ class PlatformWalletPersistenceHandler( _pendingIdentityKeys.update(clearPendingKeyDelta(publicKeyHex)) } + /** + * Re-derive, verify, and durably repair the identity key identified by + * [publicKeyData] — the orchestration behind + * `PlatformWalletManager.repairIdentityKey`, hoisted here so it is + * unit-testable (the manager cannot be constructed on the JVM) and so it + * shares this handler's authoritative [pendingIdentityKeys] state. + * + * ## Derivation source (dashpay/platform#4060 blocker 1) + * + * The derivation indices are read from the PERSISTED `public_keys` row's + * derivation breadcrumbs ([PublicKeyEntity.derivationIdentityIndex] / + * [PublicKeyEntity.derivationKeyIndex]) — NEVER from a caller-supplied + * key id. A caller-supplied index (e.g. the DPP key id) can derive a + * DIFFERENT valid scalar that round-trips through encrypt/decrypt fine; + * the deriver's [PrivateKeyDeriver.deriveAndStore] `force` path then + * proves the derived PUBLIC key equals [publicKeyData] BEFORE persisting + * and throws [org.dashfoundation.dashsdk.security.IdentityKeyDerivationMismatchException] + * on mismatch (nothing persisted, pending state untouched). A row with no + * breadcrumbs cannot be safely repaired, so the repair fails without + * clearing pending. + * + * @param verifyRecoverable the real-decrypt probe + * (`WalletStorage.probeIdentityKeyRecoverability`) proving the just-written + * blob actually opens; injected by the manager (this handler holds no + * `WalletStorage`). + * @return the recorded storage identifier, or null when the deriver + * declined to store (pending left intact). Throws (pending left intact) + * on a derivation/verification failure. + */ + internal suspend fun repairIdentityKeyDurably( + walletId: ByteArray, + publicKeyData: ByteArray, + verifyRecoverable: suspend (pubkeyHex: String) -> Boolean, + ): String? { + val pubkeyHex = publicKeyData.toHex() + val deriver = privateKeyDeriver + ?: throw DashSdkError.PlatformWallet.SigningKeyUnavailable( + "identity-key repair for $pubkeyHex has no private-key deriver wired; " + + "the key remains unusable and pending state is left intact", + ) + + // BLOCKER 1: read the derivation indices from the persisted row, never + // from the caller. A row lacking breadcrumbs cannot be safely repaired + // (we would have to guess the slot), so fail WITHOUT clearing pending. + val breadcrumbs = database.publicKeyDao().getByPublicKeyData(publicKeyData) + .firstNotNullOfOrNull { row -> + val identityIndex = row.derivationIdentityIndex + val keyIndex = row.derivationKeyIndex + if (identityIndex != null && keyIndex != null) identityIndex to keyIndex else null + } ?: throw DashSdkError.PlatformWallet.SigningKeyUnavailable( + "cannot repair identity key $pubkeyHex: no derivation breadcrumbs are " + + "persisted for it (derivationIdentityIndex/derivationKeyIndex are " + + "null) — the correct slot is unknown; pending state left intact", + ) + val (identityIndex, keyIndex) = breadcrumbs + + // force = true routes through WalletStorage.replacePrivateKey and, in + // the production deriver, derives the KEYPAIR and verifies the derived + // public key equals publicKeyData BEFORE any store — a mismatch throws + // IdentityKeyDerivationMismatchException here, so nothing below runs + // and pending is never cleared (BLOCKER 1). + val storageIdentifier = deriver.deriveAndStore( + walletId = walletId, + publicKeyData = publicKeyData, + identityIndex = identityIndex, + keyIndex = keyIndex, + force = true, + )?.identifier ?: return null + + // Independent confirmation the stored blob actually decrypts. + if (!verifyRecoverable(pubkeyHex)) { + throw DashSdkError.PlatformWallet.SigningKeyUnavailable( + "identity-key repair stored a blob that does not decrypt for pubkey " + + "$pubkeyHex (slot $identityIndex/$keyIndex) — the key remains " + + "unusable; pending state left intact", + ) + } + + // Record the identifier on the Room rows so the durable pending-repair + // reconstruction does not resurrect this key on the next launch. + runCatching { + database.publicKeyDao().getByPublicKeyData(publicKeyData).forEach { row -> + if (row.privateKeyKeychainIdentifier != storageIdentifier) { + database.publicKeyDao() + .update(row.copy(privateKeyKeychainIdentifier = storageIdentifier)) + } + } + } + markIdentityKeyRepaired(pubkeyHex) + return storageIdentifier + } + /** * Durable bookkeeping for a sign-time * `KeyPermanentlyInvalidatedException` (#4060 round-2 finding 3): null diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt index 5d680182ac..40c533647a 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt @@ -68,7 +68,7 @@ class IdentityKeyPrivateKeyDeriver( // routes through WalletStorage.replacePrivateKey instead: the // usability short-circuit is exactly what must NOT run when a // shape+fingerprint-valid but undecryptable blob is being repaired. - val deriveOnce: suspend () -> ByteArray = { + val deriveScalarOnly: suspend () -> ByteArray = { // Single Rust FFI call — the whole derivation, deadlock-safe — // runs OUTSIDE WalletStorage's private-key lock (the // storeIfAbsent/replacePrivateKey contract). @@ -80,13 +80,57 @@ class IdentityKeyPrivateKeyDeriver( keyIndex = keyIndex, ).also { scalar = it } } + // force = true is the repair path (dashpay/platform#4060 blocker 1): + // it must PROVE the derived key matches [publicKeyData] before any + // persistence. The caller only knows an (identityIndex, keyIndex) — + // a WRONG pair derives a DIFFERENT valid scalar that round-trips + // through encrypt/decrypt perfectly (probeIdentityKeyRecoverability + // only proves the blob decrypts, not that it is the RIGHT key), so + // without this check a mis-indexed repair would clear the pending + // state and persist an unusable key. Derive the KEYPAIR, compare its + // public half to [publicKeyData], and THROW before storing on + // mismatch — replacePrivateKey never runs, so nothing is persisted. + val deriveVerifiedForRepair: suspend () -> ByteArray = { + val pair = IdentityNative.deriveIdentityKeyPairWithResolver( + networkOrd = network.ffiValue, + walletId = walletId, + resolverHandle = mnemonicResolverHandle, + identityIndex = identityIndex, + keyIndex = keyIndex, + ) + check(pair.size == 2) { "keypair derive returned ${pair.size} elements" } + val derivedPrivate = pair[0] + val derivedPublic = pair[1] + scalar = derivedPrivate + if (!derivedPublicKeyMatches(derivedPublic, publicKeyData)) { + // Scrub the wrong scalar immediately; the finally scrubs + // again harmlessly (idempotent zero-fill). + derivedPrivate.fill(0) + throw IdentityKeyDerivationMismatchException( + "identity-key repair derived a key whose public half does " + + "not match the requested pubkey $pubkeyHex at slot " + + "$identityIndex/$keyIndex — refusing to persist an " + + "unusable key (the derivation breadcrumbs are wrong " + + "or corrupt); pending state left intact", + ) + } + derivedPrivate + } val wasNewlyCreated = try { runBlocking { if (force) { - walletStorage.replacePrivateKey(pubkeyHex, ownerWalletId = walletId, derive = deriveOnce) + walletStorage.replacePrivateKey( + pubkeyHex, + ownerWalletId = walletId, + derive = deriveVerifiedForRepair, + ) true } else { - walletStorage.storeIfAbsent(pubkeyHex, ownerWalletId = walletId, derive = deriveOnce) + walletStorage.storeIfAbsent( + pubkeyHex, + ownerWalletId = walletId, + derive = deriveScalarOnly, + ) } } } finally { @@ -116,8 +160,29 @@ class IdentityKeyPrivateKeyDeriver( // succeeds). runBlocking { walletStorage.deleteUnownedPrivateKeys(pubkeyHexes, excludingWalletId) } - private companion object { + internal companion object { /** Matches `WalletStorage`'s private `PRIVKEY_PREFIX`. */ const val PRIVKEY_IDENTIFIER_PREFIX = "privkey." + + /** + * Whether a freshly derived public key [derived] is byte-for-byte the + * key [expected] a repair was asked to restore. Pure and side-effect + * free so the repair's before-persistence identity check + * (dashpay/platform#4060 blocker 1) is unit-testable without the + * native derive. Both halves are the compressed public-key bytes Rust + * emits (identical encoding on both derive entry points), so a plain + * content comparison is the whole check. + */ + fun derivedPublicKeyMatches(derived: ByteArray, expected: ByteArray): Boolean = + derived.contentEquals(expected) } } + +/** + * The repair path derived a key whose PUBLIC half does not match the pubkey + * it was asked to restore — the requested (identityIndex, keyIndex) do not + * belong to [publicKeyData]. Thrown BEFORE any Keystore write so a + * mis-indexed repair persists nothing and never clears the pending-repair + * state (dashpay/platform#4060 blocker 1). + */ +class IdentityKeyDerivationMismatchException(message: String) : RuntimeException(message) 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 a517022cde..8aab9fd3f2 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 @@ -451,91 +451,75 @@ class PlatformWalletManager( val signerHandle: Long get() = signer.nativeHandle /** - * Re-derive the canonical identity-authentication private key at - * `(identityIndex, keyIndex)` from this wallet's mnemonic and re-encrypt - * it into [walletStorage] under [publicKeyData]'s hex — the repair action - * behind `WalletKeyHealthSheet` (port of the iOS re-derive path in - * `WalletKeyHealthSheet.swift`, which calls - * `deriveIdentityAuthKeyAtSlot`). + * Re-derive the canonical identity-authentication private key for + * [publicKeyData] from this wallet's mnemonic and re-encrypt it into + * [walletStorage] under its hex — the repair action behind + * `WalletKeyHealthSheet` (port of the iOS re-derive path in + * `WalletKeyHealthSheet.swift`, which calls `deriveIdentityAuthKeyAtSlot`). * - * The whole `mnemonic → seed → path → key` derivation runs in Rust via - * the resolver-keyed FFI ([IdentityKeyPrivateKeyDeriver], the CLAUDE.md - * "one allowed exception"); Kotlin only encrypts the returned scalar. - * Returns the recorded storage identifier (e.g. `privkey.`), - * or throws on a derivation / storage failure. + * The derivation slot is read from the PERSISTED `public_keys` row's + * derivation breadcrumbs — NEVER from a caller-supplied key id + * (dashpay/platform#4060 blocker 1): a wrong index derives a DIFFERENT + * valid scalar that round-trips through encrypt/decrypt fine and would + * persist an unusable key. The whole `mnemonic → seed → path → key` + * derivation runs in Rust via the resolver-keyed FFI + * ([IdentityKeyPrivateKeyDeriver], the CLAUDE.md "one allowed exception"); + * Kotlin only encrypts the returned scalar. Returns the recorded storage + * identifier (e.g. `privkey.`), or throws on a + * derivation / verification failure. * * FORCE-replaces the stored entry (never trusts the shape+fingerprint - * usability short-circuit) and then VERIFIES the write with the - * real-decrypt probe ([WalletStorage.probeIdentityKeyRecoverability]) - * before declaring success — a blob that does not actually decrypt fails - * the repair with a typed - * [DashSdkError.PlatformWallet.SigningKeyUnavailable] - * (dashpay/platform#4060 finding 6). + * usability short-circuit), but first VERIFIES the derived PUBLIC key + * equals [publicKeyData] BEFORE persisting — a mismatched slot fails the + * repair without storing anything or clearing pending. After the store it + * VERIFIES the blob with the real-decrypt probe + * ([WalletStorage.probeIdentityKeyRecoverability]); a blob that does not + * actually decrypt fails the repair with a typed + * [DashSdkError.PlatformWallet.SigningKeyUnavailable]. * - * Only after verification is the key 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), and the Room rows' `privateKeyKeychainIdentifier` updated so - * the durable pending-repair reconstruction does not resurrect the key. + * Only after both verifications is the key dropped from + * [pendingIdentityKeys] and the Room rows' `privateKeyKeychainIdentifier` + * updated so the durable pending-repair reconstruction does not resurrect + * the key. The full orchestration lives in + * [PlatformWalletPersistenceHandler.repairIdentityKeyDurably] (this + * manager cannot be constructed on the JVM; the handler is unit-testable). */ suspend fun repairIdentityKey( walletId: ByteArray, publicKeyData: ByteArray, - identityIndex: Int, - keyIndex: Int, ): String? = teardownGate.op { - require(identityIndex >= 0) { "identityIndex must be non-negative, got $identityIndex" } - require(keyIndex >= 0) { "keyIndex must be non-negative, got $keyIndex" } + // The whole repair — read the PERSISTED derivation breadcrumbs, + // force-re-derive, verify the derived public key matches + // [publicKeyData] before persisting, verify the stored blob decrypts, + // durably record the identifier, and only then clear pending — lives + // in the persistence handler ([repairIdentityKeyDurably]) so it is + // unit-testable (this manager cannot be constructed on the JVM) and + // shares the handler's authoritative pending-key state. The manager + // only supplies the wallet-scoped collaborators: the resolver-keyed + // deriver (already wired into the handler) and the real-decrypt probe. + // // deriveAndStore is a synchronous JNI call keyed on the manager's - // resolver handle — the gate keeps teardown from freeing it + // resolver handle — the teardownGate keeps teardown from freeing it // mid-derive (callers run on their own Compose scopes). // - // force = true (dashpay/platform#4060 finding 6): storeIfAbsent's - // shape+fingerprint usability short-circuit can be satisfied by a - // blob that does not actually decrypt, silently skipping the - // re-derive a repair exists to perform. The forced path routes - // through WalletStorage.replacePrivateKey, which unconditionally - // replaces blob + fingerprint + alias tag in one atomic edit. - val storageIdentifier = identityKeyDeriver.deriveAndStore( + // NB (dashpay/platform#4060 blocker 1): the derivation indices are NOT + // caller-supplied — a caller passing the DPP key id would derive a + // different valid scalar that round-trips fine and persists an + // unusable key. They come from the row's derivation breadcrumbs, and + // the derived public key is checked against [publicKeyData] before any + // store. + persistenceHandler.repairIdentityKeyDurably( walletId = walletId, publicKeyData = publicKeyData, - identityIndex = identityIndex, - keyIndex = keyIndex, - force = true, - )?.identifier - if (storageIdentifier != null) { - val pubkeyHex = publicKeyData.toHex() - // VERIFY with the real-decrypt probe before declaring success: a - // wrong-key crypto failure fails the repair with a typed error. - // UserNotAuthenticatedException counts as verified inside the - // probe (key present, opens after auth — this manager holds no - // BiometricGate on this path, and the just-written fingerprint - // rules out the wrong-key-behind-locked-gate ambiguity because - // we wrote the blob ourselves under the captured public key). - val verified = walletStorage.probeIdentityKeyRecoverability(pubkeyHex) - if (!verified) { - throw DashSdkError.PlatformWallet.SigningKeyUnavailable( - "identity-key repair stored a blob that does not decrypt " + - "for pubkey $pubkeyHex (identityIndex=$identityIndex, " + - "keyIndex=$keyIndex) — the key remains unusable; " + - "re-run the repair after resolving the Keystore state", - ) - } - // Record the identifier on the Room rows too, so the durable - // pending-repair reconstruction (finding 5) does not resurrect - // this key on the next launch. - runCatching { - database.publicKeyDao().getByPublicKeyData(publicKeyData).forEach { row -> - if (row.privateKeyKeychainIdentifier != storageIdentifier) { - database.publicKeyDao() - .update(row.copy(privateKeyKeychainIdentifier = storageIdentifier)) - } - } - } - persistenceHandler.markIdentityKeyRepaired(pubkeyHex) - } - storageIdentifier + verifyRecoverable = { pubkeyHex -> + // UserNotAuthenticatedException counts as verified inside the + // probe (key present, opens after auth — this manager holds no + // BiometricGate on this path, and the just-written fingerprint + // rules out the wrong-key-behind-locked-gate ambiguity because + // the blob was written under the captured public key). + walletStorage.probeIdentityKeyRecoverability(pubkeyHex) + }, + ) } /** 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 a63b6a121d..4ce590f445 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 @@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.dashfoundation.dashsdk.Network +import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.ffi.NativePersistenceBridge import org.dashfoundation.dashsdk.wallet.PlatformWalletPersistenceCapabilities import org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity @@ -1390,6 +1391,162 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(liveReason, handler.pendingIdentityKeys.value[pubkey.toHex()]!!.reason) } + // ── Durable, pubkey-verified repair (#4060 blockers 1 & 3) ───────── + + /** + * Models the production [org.dashfoundation.dashsdk.security.IdentityKeyPrivateKeyDeriver]: + * a persist-time derive fails (returns null → the key seeds as pending), + * while a repair (`force`) derives the KEYPAIR and verifies the derived + * PUBLIC key equals `publicKeyData` BEFORE storing — a wrong slot throws + * [IdentityKeyDerivationMismatchException] without persisting anything. + */ + private class VerifyingRepairDeriver : PrivateKeyDeriver { + var lastForceCall: Triple? = null + val storedFor = mutableSetOf() + + override fun deriveAndStore( + walletId: ByteArray, + publicKeyData: ByteArray, + identityIndex: Int, + keyIndex: Int, + force: Boolean, + ): DerivedKeyStoreResult? { + if (!force) { + // Persist-time failure → the key is recorded watch-only + + // pending (breadcrumbs still land on the row). + return null + } + lastForceCall = Triple(walletId, identityIndex, keyIndex) + val derivedPublic = fakePubkeyFor(identityIndex, keyIndex) + // BLOCKER 1: verify BEFORE persistence; wrong slot → no store. + if (!derivedPublic.contentEquals(publicKeyData)) { + throw org.dashfoundation.dashsdk.security.IdentityKeyDerivationMismatchException( + "derived pubkey for slot $identityIndex/$keyIndex does not match request", + ) + } + storedFor.add(publicKeyData.toHex()) + return DerivedKeyStoreResult("privkey." + publicKeyData.toHex(), wasNewlyCreated = true) + } + + override fun deleteUnownedStored( + pubkeyHexes: Collection, + excludingWalletId: ByteArray, + ): Set = emptySet() + + companion object { + /** Deterministic stand-in for the Rust keypair public half. */ + fun fakePubkeyFor(identityIndex: Int, keyIndex: Int): ByteArray = + ByteArray(33).also { it[0] = identityIndex.toByte(); it[1] = keyIndex.toByte() } + } + } + + @Test + fun repairWithCorrectBreadcrumbsDerivesVerifiesAndClearsPending() = runTest { + val deriver = VerifyingRepairDeriver() + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + val identityId = ByteArray(32) { 30 } + seedIdentity(identityId) + + // upsertIdentityKey records breadcrumbs 3/5; the pubkey MUST be the + // one those breadcrumbs derive, so the repair verification passes. + val pubkey = VerifyingRepairDeriver.fakePubkeyFor(3, 5) + upsertIdentityKey(pubkey, identityId) + assertNotNull("persist-time failure seeds pending", handler.pendingIdentityKeys.value[pubkey.toHex()]) + + var probed = false + val id = handler.repairIdentityKeyDurably( + walletId = walletId, + publicKeyData = pubkey, + verifyRecoverable = { probed = true; true }, + ) + + assertEquals("privkey." + pubkey.toHex(), id) + // Derived from the PERSISTED breadcrumbs (3/5), not any caller index. + assertEquals(Triple(walletId.toHex(), 3, 5), deriver.lastForceCall!!.let { + Triple(it.first.toHex(), it.second, it.third) + }) + assertTrue("blob decrypt verified", probed) + // Pending cleared and the row now carries the durable identifier. + assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + val row = db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0) + assertEquals("privkey." + pubkey.toHex(), row!!.privateKeyKeychainIdentifier) + } + + @Test + fun repairWithMismatchedBreadcrumbsIsRejectedAndLeavesPending() = runTest { + val deriver = VerifyingRepairDeriver() + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + val identityId = ByteArray(32) { 31 } + seedIdentity(identityId) + + // The row's breadcrumbs (3/5) derive fakePubkeyFor(3,5), which is NOT + // this pubkey — modelling wrong/corrupt breadcrumbs. The repair must + // reject rather than persist a different, unusable key. + val pubkey = ByteArray(33) { 77 } + upsertIdentityKey(pubkey, identityId) + assertNotNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + + var probed = false + var thrown: Throwable? = null + try { + handler.repairIdentityKeyDurably( + walletId = walletId, + publicKeyData = pubkey, + verifyRecoverable = { probed = true; true }, + ) + } catch (t: Throwable) { + thrown = t + } + assertTrue( + "wrong-slot repair must throw IdentityKeyDerivationMismatchException, got $thrown", + thrown is org.dashfoundation.dashsdk.security.IdentityKeyDerivationMismatchException, + ) + + // Nothing persisted, blob never even probed, pending intact. + assertFalse("verification must not run after a derive-mismatch", probed) + assertTrue(deriver.storedFor.isEmpty()) + assertNotNull( + "a rejected repair must NOT clear pending", + handler.pendingIdentityKeys.value[pubkey.toHex()], + ) + val row = db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0) + assertNull(row!!.privateKeyKeychainIdentifier) + } + + @Test + fun repairWithoutPersistedBreadcrumbsFailsAndLeavesPending() = runTest { + val deriver = VerifyingRepairDeriver() + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + val identityId = ByteArray(32) { 32 } + seedIdentity(identityId) + + // A key persisted WITHOUT derivation breadcrumbs (derivationIndicesIsSome + // = false) — the correct slot is unknown, so repair must fail closed. + val pubkey = ByteArray(33) { 44 } + handler.onChangesetBegin(walletId) + handler.onPersistIdentityKeyUpsert( + walletId, identityId, 0, 0, 0, 0, false, false, 0, + pubkey, ByteArray(20), true, walletId, + false, 0, 0, 0, ByteArray(32), null, + ) + handler.onChangesetEnd(walletId, success = true) + var thrown: Throwable? = null + try { + handler.repairIdentityKeyDurably( + walletId = walletId, + publicKeyData = pubkey, + verifyRecoverable = { true }, + ) + } catch (t: Throwable) { + thrown = t + } + assertTrue( + "repair without breadcrumbs must fail with SigningKeyUnavailable, got $thrown", + thrown is DashSdkError.PlatformWallet.SigningKeyUnavailable, + ) + assertNull(deriver.lastForceCall) + } + // ── Shielded load round-trip ────────────────────────────────────── @Test From 2a0f54d4d1bb90e7025812a9ace85ed858a320d9 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:34:01 -0400 Subject: [PATCH 19/26] fix(kotlin-sdk): make the durable identity-key repair write fail closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker 3 (shumkov): in the repair path the Room privateKeyKeychainIdentifier update was wrapped in runCatching (swallowed on failure) while the pending state was cleared regardless. A failed durable write then resurrected the repair after restart (the reconstruction reads the persisted rows) while the live session believed it was done — session and restart disagreed. Fix: the durable write in repairIdentityKeyDurably now fails CLOSED — it runs through an injectable persistDurableIdentifier seam (default: the production public_keys write) and its exception propagates; markIdentityKeyRepaired only runs after the write commits. A failed durable write leaves pending intact and the repair retryable, so the session and a restart agree. Test: a repair whose derive + verify succeed but whose durable write throws propagates the failure and leaves the pending entry in place. Co-Authored-By: Claude Opus 4.8 --- .../PlatformWalletPersistenceHandler.kt | 45 ++++++++++++++----- .../PlatformWalletPersistenceHandlerTest.kt | 33 ++++++++++++++ 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 8973d0e840..6fe49335c3 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 @@ -2647,18 +2647,41 @@ class PlatformWalletPersistenceHandler( * breadcrumbs cannot be safely repaired, so the repair fails without * clearing pending. * + * ## Durability (dashpay/platform#4060 blocker 3) + * + * The durable Room write (recording the storage identifier so the restart + * reconstruction does not resurrect the key) fails CLOSED: if it throws, + * the pending state is NOT cleared and the failure propagates, so the live + * session and a subsequent restart agree the repair is still pending. A + * swallowed durable-write failure that still cleared live pending state + * would let the session believe the repair was done while a restart's + * reconstruction resurrected it. Only after the blob is verified + * recoverable AND the durable write commits is the key dropped from + * [pendingIdentityKeys]. + * * @param verifyRecoverable the real-decrypt probe * (`WalletStorage.probeIdentityKeyRecoverability`) proving the just-written * blob actually opens; injected by the manager (this handler holds no * `WalletStorage`). + * @param persistDurableIdentifier the durable Room update (default: the + * production `public_keys` write); a seam so a failed durable write — + * which must NOT clear pending — is exercisable in tests. * @return the recorded storage identifier, or null when the deriver * declined to store (pending left intact). Throws (pending left intact) - * on a derivation/verification failure. + * on a derivation/verification/durable-write failure. */ internal suspend fun repairIdentityKeyDurably( walletId: ByteArray, publicKeyData: ByteArray, verifyRecoverable: suspend (pubkeyHex: String) -> Boolean, + persistDurableIdentifier: suspend (storageIdentifier: String) -> Unit = { storageIdentifier -> + database.publicKeyDao().getByPublicKeyData(publicKeyData).forEach { row -> + if (row.privateKeyKeychainIdentifier != storageIdentifier) { + database.publicKeyDao() + .update(row.copy(privateKeyKeychainIdentifier = storageIdentifier)) + } + } + }, ): String? { val pubkeyHex = publicKeyData.toHex() val deriver = privateKeyDeriver @@ -2704,16 +2727,16 @@ class PlatformWalletPersistenceHandler( ) } - // Record the identifier on the Room rows so the durable pending-repair - // reconstruction does not resurrect this key on the next launch. - runCatching { - database.publicKeyDao().getByPublicKeyData(publicKeyData).forEach { row -> - if (row.privateKeyKeychainIdentifier != storageIdentifier) { - database.publicKeyDao() - .update(row.copy(privateKeyKeychainIdentifier = storageIdentifier)) - } - } - } + // BLOCKER 3: the durable write fails CLOSED. Record the identifier on + // the Room rows so the restart reconstruction does not resurrect this + // key — but if that write throws, DO NOT clear pending. A swallowed + // failure that still cleared live state would resurrect the repair + // after restart while the session believed it was done. Let it + // propagate; pending stays intact and the repair is retryable. + persistDurableIdentifier(storageIdentifier) + + // Durable write committed and blob verified — now it is safe to drop + // the pending-repair signal. markIdentityKeyRepaired(pubkeyHex) return storageIdentifier } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 4ce590f445..0d703fa5c9 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 @@ -1547,6 +1547,39 @@ class PlatformWalletPersistenceHandlerTest { assertNull(deriver.lastForceCall) } + @Test + fun repairWithFailedDurableWriteLeavesPendingIntact() = runTest { + val deriver = VerifyingRepairDeriver() + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + val identityId = ByteArray(32) { 33 } + seedIdentity(identityId) + + val pubkey = VerifyingRepairDeriver.fakePubkeyFor(3, 5) + upsertIdentityKey(pubkey, identityId) + assertNotNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + + // BLOCKER 3: derive + verify succeed, but the durable Room write fails. + // Pending MUST stay so a restart and this session agree the repair is + // still outstanding — a swallowed failure would resurrect it after + // restart while the session believed it was done. + var thrown: Throwable? = null + try { + handler.repairIdentityKeyDurably( + walletId = walletId, + publicKeyData = pubkey, + verifyRecoverable = { true }, + persistDurableIdentifier = { throw java.io.IOException("durable write failed") }, + ) + } catch (t: Throwable) { + thrown = t + } + assertTrue("durable-write failure must propagate, got $thrown", thrown is java.io.IOException) + assertNotNull( + "a failed durable write must NOT clear pending", + handler.pendingIdentityKeys.value[pubkey.toHex()], + ) + } + // ── Shielded load round-trip ────────────────────────────────────── @Test From 033df44151e73f62e3ab7e8e51b621f1e5055de8 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:36:23 -0400 Subject: [PATCH 20/26] fix(kotlin-sdk): narrow the KeyMint lockless-keygen classifier to authoritative signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker 2 (shumkov): isNoSecureLockScreenKeyGenFailure classified any KeyStoreException whose message contained "generate_key" as a lock-screen rejection. But "generate_key" names the failing operation, not its cause — a transient KeyMint generation failure on a device WITH a lock screen carries it too, so a transient failure would silently and PERMANENTLY downgrade an AUTH_GATED key to DEVICE_BOUND instead of retrying. Fix: drop the bare "generate_key" substring match. Classification now rests only on the two authoritative signals — explicit lock-screen text, or the lock-screen rejection numeric code (internal Keystore 4 / KeyMint 10309, which the real android.security.KeyStoreException exposes via getNumericErrorCode()). Tests: the on-device shape now classifies via the numeric code (not the incidental generate_key text); two new negatives — a bare generate_key failure, and a transient generate_key failure under a key-gen ProviderException with a non-lock-screen numeric code — must NOT classify, so AUTH_GATED is retried, not downgraded. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/security/KeystoreManager.kt | 43 +++++++++++++------ .../security/KeystoreKeyGenPolicyTest.kt | 34 +++++++++++++-- 2 files changed, 59 insertions(+), 18 deletions(-) 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 9f47087290..31c477e7f5 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 @@ -850,20 +850,30 @@ open class KeystoreManager( internal fun lockBoundKeyParamsSupported(deviceSecure: Boolean): Boolean = deviceSecure /** - * Whether [t]'s cause chain is the KeyMint "generate_key needs a secure - * lock screen" rejection: an `android.security` `KeyStoreException` - * that either NAMES the failure (message references `generate_key` or a - * lock-screen requirement) or sits in the cause chain OF a - * key-generation `ProviderException` (message "Keystore key generation - * failed") AND carries the observed KeyMint rejection's numeric code - * (internal Keystore code 4 / KeyMint 10309, observed on-device after - * the lock screen was removed). + * Whether [t]'s cause chain is the KeyMint "needs a secure lock screen" + * rejection: an `android.security` `KeyStoreException` that either NAMES + * the lock-screen requirement (message references a lock screen) or sits + * in the cause chain OF a key-generation `ProviderException` (message + * "Keystore key generation failed") AND carries the observed KeyMint + * rejection's numeric code (internal Keystore code 4 / KeyMint 10309, + * observed on-device after the lock screen was removed). + * + * The classification is ONLY these two authoritative signals — the + * lock-screen numeric code, or explicit lock-screen text. A bare + * `generate_key` mention is deliberately NOT a signal + * (dashpay/platform#4060 blocker 2): `generate_key` appears in the + * message of every KeyMint generation failure, including transient ones + * on a device that DOES have a lock screen. Matching it would silently + * and permanently downgrade an AUTH_GATED key to DEVICE_BOUND on a + * transient failure instead of retrying — so only the lock-screen code + * or text is trusted. * * Deliberately narrow: a bare nested `KeyStoreException` with an - * unrelated message (e.g. a signature failure that happens to be - * wrapped by a key-gen ProviderException) does NOT classify — the - * consideration window opens only at the matched ProviderException and - * still requires a message or numeric-code match, so unrelated + * unrelated message (e.g. a signature failure, or a transient + * generate_key failure, that happens to be wrapped by a key-gen + * ProviderException) does NOT classify — the consideration window opens + * only at the matched ProviderException and still requires a + * lock-screen message or the rejection numeric code, so unrelated * Keystore failures never trigger the degraded retry. Used only as the * retry/redirect decision for [generateWithLockScreenDegradation] and * [resolveIdentityKeysWriteAlias]. Pure and JVM-testable — matches by @@ -906,8 +916,13 @@ open class KeystoreManager( } private fun keyStoreMessageNamesLockScreen(msg: String): Boolean = - msg.contains("generate_key", ignoreCase = true) || - msg.contains("lock screen", ignoreCase = true) + // Only the explicit lock-screen requirement is authoritative. A + // bare "generate_key" is NOT matched (dashpay/platform#4060 blocker + // 2) — it names the failing operation, not its cause, and a + // transient generation failure on a locked device carries it too. + // "lock screen" also covers "secure lock screen". + msg.contains("lock screen", ignoreCase = true) || + msg.contains("lockscreen", ignoreCase = true) /** * Reflectively read `getNumericErrorCode()` (API 33+ on the real 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 index 4c1dc503b8..c7beec6550 100644 --- 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 @@ -55,20 +55,46 @@ class KeystoreKeyGenPolicyTest { @Test fun classifiesWrappedKeystoreGenerationFailure() { // The exact on-device shape: a key-gen ProviderException wrapping a - // Keystore system error from generate_key. + // Keystore system error carrying the lock-screen rejection numeric code + // (the real android.security.KeyStoreException exposes it via + // getNumericErrorCode(), API 33+). It is the numeric code — not the + // incidental "generate_key" mention in the text — that classifies + // (dashpay/platform#4060 blocker 2). val failure = ProviderException( "Keystore key generation failed", - SimulatedKeyStoreException( + SimulatedNumericKeyStoreException( "System error (internal Keystore code: 4 message: In generate_key. 10309)", + numericErrorCode = 4, ), ) assertTrue(KeystoreManager.isNoSecureLockScreenKeyGenFailure(failure)) } @Test - fun classifiesDirectGenerateKeyKeystoreError() { + fun doesNotClassifyBareGenerateKeyWithoutLockScreenSignal() { + // Blocker 2: a bare "generate_key" mention is NOT a lock-screen signal. + // A transient KeyMint generation failure names generate_key too; if it + // classified, an AUTH_GATED key would be permanently downgraded to + // DEVICE_BOUND instead of being retried. val failure = SimulatedKeyStoreException("Keymint error In generate_key") - assertTrue(KeystoreManager.isNoSecureLockScreenKeyGenFailure(failure)) + assertFalse(KeystoreManager.isNoSecureLockScreenKeyGenFailure(failure)) + } + + @Test + fun doesNotClassifyTransientGenerateKeyUnderKeyGenProviderException() { + // Blocker 2, the dangerous shape: a key-gen ProviderException wrapping a + // transient generate_key Keystore failure on a device that HAS a lock + // screen — no lock-screen text, no rejection numeric code. It must NOT + // classify, so the AUTH_GATED write is retried, not silently and + // permanently degraded to DEVICE_BOUND. + val failure = ProviderException( + "Keystore key generation failed", + SimulatedNumericKeyStoreException( + "System error: In generate_key. Failed to generate key (transient)", + numericErrorCode = -8, // KeyMint SECURE_HW_COMMUNICATION_FAILED-style transient, not the lock-screen code + ), + ) + assertFalse(KeystoreManager.isNoSecureLockScreenKeyGenFailure(failure)) } @Test From 0621b90dd715e065174f24567a57a6a754145a8b Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:39:32 -0400 Subject: [PATCH 21/26] fix(kotlin-sdk): rethrow CancellationException from the best-effort policy-alias migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 4 (shumkov): migrateToPolicyAlias wrapped its encrypt + DataStore edit in runCatching, which swallows kotlin CancellationException along with genuine rewrite failures. A coroutine cancelled during the migration's suspend points was silently absorbed instead of unwinding — a structured-concurrency violation. Fix: replace runCatching with an explicit try/catch that rethrows CancellationException; only genuine rewrite failures stay best-effort (the recovered value is still returned; migration retries on the next read). Test: a cancellation landing at the migration encrypt (via the fake keystore's onNextPolicyEncrypt hook) now propagates out of retrievePrivateKey rather than being swallowed. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/security/WalletStorage.kt | 12 ++++++- .../WalletStorageUpgradeMatrixTest.kt | 31 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) 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 2ee6d8fd8a..40de259fdd 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 @@ -15,6 +15,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.security.GeneralSecurityException import java.util.Base64 +import kotlin.coroutines.cancellation.CancellationException private val Context.secretsStore: DataStore by preferencesDataStore( name = "org.dashfoundation.wallet.secrets", @@ -668,7 +669,7 @@ class WalletStorage( plain: ByteArray, sourceEncoded: String, ) { - runCatching { + try { val migrated = keystore.encryptForIdentityKeys(plain) store.edit { val key = privateKeyKey(pubkeyHex) @@ -682,6 +683,15 @@ class WalletStorage( it[privateKeyAliasKey(pubkeyHex)] = migrated.alias } } + } catch (cancellation: CancellationException) { + // NEVER swallow structured-concurrency cancellation: if the caller's + // coroutine was cancelled during the encrypt / store.edit suspend + // points, rethrow so the cancellation propagates. Only genuine + // rewrite failures below stay best-effort (retry on the next read). + throw cancellation + } catch (_: Throwable) { + // Best-effort: a rewrite failure must not lose the value the caller + // just recovered — migration retries on the next read. } } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt index bd1e76ffab..6201f10736 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt @@ -15,6 +15,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import javax.crypto.BadPaddingException +import kotlin.coroutines.cancellation.CancellationException /** * Full upgrade-matrix coverage for [WalletStorage]'s layered identity-key @@ -88,6 +89,36 @@ class WalletStorageUpgradeMatrixTest { assertArrayEquals(secret, storage.retrievePrivateKey(pub)) } + /** + * dashpay/platform#4060 finding 4: the best-effort policy-alias migration + * must NEVER swallow a coroutine cancellation. If the caller's coroutine is + * cancelled during the migration rewrite (the encrypt / DataStore edit + * suspend points), the [CancellationException] must propagate so structured + * concurrency unwinds — only genuine rewrite failures stay best-effort. + */ + @Test + fun migrationRethrowsCancellationInsteadOfSwallowingIt() = runBlocking { + fake.keysAliasKind = FakeKeystoreManager.KeysAliasKind.AES + fake.scheme = FakeKeystoreManager.Scheme.LEGACY_AES + storage.storePrivateKey(pub, secret) + + // The retrieve recovers the legacy value and then migrates it forward; + // model a cancellation landing exactly at the migration's encrypt. + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + fake.onNextPolicyEncrypt = { throw CancellationException("cancelled mid-migration") } + + var thrown: Throwable? = null + try { + storage.retrievePrivateKey(pub) + } catch (t: Throwable) { + thrown = t + } + assertTrue( + "migration must rethrow CancellationException, not swallow it (got $thrown)", + thrown is CancellationException, + ) + } + /** Legacy AES key already deleted by an older build → stranded, reported undecryptable. */ @Test fun legacyAesBlobWithDeletedKeyIsStranded() = runBlocking { From 48c4726bc8a6735676d5abbc30fe67c62afad862 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:40:30 -0400 Subject: [PATCH 22/26] docs(kotlin-sdk): align DEVICE_BOUND KDoc with the software-keystore fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 5 (shumkov): the DEVICE_BOUND documentation promised hardware-backed storage while the implementation permits a software AndroidKeyStore fallback (generation prefers StrongBox, falls back to the TEE, and finally to a software-backed AndroidKeyStore key on devices with no secure element — generateWithLockScreenDegradation never fails generation on a missing one). Align the docs: "device-bound" means non-exportable AndroidKeyStore, not a hardware-storage guarantee; backing is hardware-isolated only where the device provides it. AUTH_GATED shares the same backing characteristics. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/security/KeySecurityPolicy.kt | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt index 691d01371e..e2a7bc325e 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt @@ -11,8 +11,9 @@ package org.dashfoundation.dashsdk.security * 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. + * [DEVICE_BOUND] lets such hosts opt into a non-gated, non-exportable + * device-bound wrapping key instead (hardware-backed where the device + * provides it — see the storage-backing note below). * * ## Semantics * @@ -32,6 +33,17 @@ package org.dashfoundation.dashsdk.security * still applies) — the host app is responsible for gating *access* to * signing flows (PIN, biometrics, session policy) itself. * + * ## Storage backing (not guaranteed hardware) + * + * "Device-bound" here means the AndroidKeyStore, non-exportable — NOT a + * guarantee of hardware-backed storage. Generation prefers StrongBox, falls + * back to the TEE, and — on a device whose AndroidKeyStore has no hardware + * backing at all — falls back to a **software-backed** AndroidKeyStore key + * (`generateWithLockScreenDegradation` never fails generation on a missing + * secure element). The key is always non-exportable and bound to this + * device's Keystore; it is hardware-isolated only where the hardware exists. + * [AUTH_GATED] shares the same backing characteristics. + * * ## Choosing and switching * * The two policies use **distinct Keystore aliases**, and a blob written @@ -68,8 +80,11 @@ enum class KeySecurityPolicy { AUTH_GATED, /** - * Identity-key decrypts are hardware-backed but not auth-gated; the - * host app supplies its own authentication model. + * Identity-key decrypts are device-bound and non-exportable but not + * auth-gated; the host app supplies its own authentication model. Backing + * is hardware-isolated (StrongBox/TEE) where the device provides it, with + * a software-AndroidKeyStore fallback otherwise — see the KDoc "Storage + * backing" note; it is not a hardware-storage guarantee. */ DEVICE_BOUND, } From 9bf31e772b2fe612170c6fb80b2e1e9fde2a8b12 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:12:46 -0400 Subject: [PATCH 23/26] fix(kotlin-sdk): make identity-key repair ownership check HASH160-aware (#4183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repair path derived the KEYPAIR and required its public half to equal the key's stored on-chain data before persisting. For ECDSA_HASH160 / EDDSA_25519_HASH160 keys DPP stores the 20-byte HASH160 of the pubkey as that data, not the pubkey, so the raw 33-vs-20-byte contentEquals could never match and those key types were permanently un-repairable. derivedPublicKeyMatches now takes the DPP key-type discriminant and HASH160s the derived pubkey (RIPEMD160(SHA256)) before comparing for HASH160 types; every other type keeps the plain content comparison. keyType is threaded through PrivateKeyDeriver.deriveAndStore; the repair path reads it from the persisted row's breadcrumbs, the store path passes it from the persist callback. Adds a pure-Kotlin Hash160 helper (public bytes only — no derivation/secrets, within the CLAUDE.md doctrine) pinned to RIPEMD-160 reference vectors, and tests proving a HASH160-type key repairs. Co-Authored-By: Claude Fable 5 --- .../PlatformWalletPersistenceHandler.kt | 34 +++- .../dashsdk/security/Hash160.kt | 152 ++++++++++++++++++ .../security/IdentityKeyPrivateKeyDeriver.kt | 37 +++-- .../PlatformWalletPersistenceHandlerTest.kt | 5 + .../dashsdk/security/Hash160Test.kt | 58 +++++++ .../IdentityKeyPrivateKeyDeriverMatchTest.kt | 99 ++++++++++++ 6 files changed, 369 insertions(+), 16 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/Hash160.kt create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/Hash160Test.kt create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriverMatchTest.kt diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 6fe49335c3..ded0d6dccc 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 @@ -1112,6 +1112,7 @@ class PlatformWalletPersistenceHandler( publicKeyData = publicKeyData, identityIndex = identityIndex, keyIndex = keyIndex, + keyType = keyType.toInt() and 0xFF, ) } val id = outcome.getOrNull() @@ -2690,24 +2691,32 @@ class PlatformWalletPersistenceHandler( "the key remains unusable and pending state is left intact", ) - // BLOCKER 1: read the derivation indices from the persisted row, never - // from the caller. A row lacking breadcrumbs cannot be safely repaired - // (we would have to guess the slot), so fail WITHOUT clearing pending. + // BLOCKER 1: read the derivation indices (and the DPP key type, so the + // deriver's ownership check interprets publicKeyData correctly for + // HASH160-typed keys — dashpay/platform#4183 review) from the persisted + // row, never from the caller. A row lacking breadcrumbs cannot be + // safely repaired (we would have to guess the slot), so fail WITHOUT + // clearing pending. val breadcrumbs = database.publicKeyDao().getByPublicKeyData(publicKeyData) .firstNotNullOfOrNull { row -> val identityIndex = row.derivationIdentityIndex val keyIndex = row.derivationKeyIndex - if (identityIndex != null && keyIndex != null) identityIndex to keyIndex else null + if (identityIndex != null && keyIndex != null) { + Triple(identityIndex, keyIndex, row.keyType.toIntOrNull() ?: 0) + } else { + null + } } ?: throw DashSdkError.PlatformWallet.SigningKeyUnavailable( "cannot repair identity key $pubkeyHex: no derivation breadcrumbs are " + "persisted for it (derivationIdentityIndex/derivationKeyIndex are " + "null) — the correct slot is unknown; pending state left intact", ) - val (identityIndex, keyIndex) = breadcrumbs + val (identityIndex, keyIndex, keyType) = breadcrumbs // force = true routes through WalletStorage.replacePrivateKey and, in // the production deriver, derives the KEYPAIR and verifies the derived - // public key equals publicKeyData BEFORE any store — a mismatch throws + // public key equals publicKeyData (HASH160-hashed first for HASH160 key + // types) BEFORE any store — a mismatch throws // IdentityKeyDerivationMismatchException here, so nothing below runs // and pending is never cleared (BLOCKER 1). val storageIdentifier = deriver.deriveAndStore( @@ -2715,6 +2724,7 @@ class PlatformWalletPersistenceHandler( publicKeyData = publicKeyData, identityIndex = identityIndex, keyIndex = keyIndex, + keyType = keyType, force = true, )?.identifier ?: return null @@ -2928,8 +2938,17 @@ interface PrivateKeyDeriver { * Returns `null` if the key could not be derived/stored (leaving it * watch-only). * - * @param publicKeyData the compressed public-key bytes — used as the + * @param publicKeyData the on-chain public-key data — the compressed + * pubkey, or the 20-byte HASH160 for a HASH160 key type — used as the * storage key so the signer can locate the scalar. + * @param keyType the DPP `KeyType` discriminant of this key. Only the + * [force] repair path consults it: it tells the pubkey-ownership check + * whether [publicKeyData] is the raw derived pubkey or its HASH160, so a + * HASH160-type key (`ECDSA_HASH160` = 2, `EDDSA_25519_HASH160` = 4) is + * verified by hashing the derived pubkey rather than comparing raw bytes + * that can never match (dashpay/platform#4183 review). Defaults to + * `ECDSA_SECP256K1` (0) for the non-repair store path, which does no + * pubkey comparison. * @param force when true, skip the "already usable" short-circuit and * REPLACE the stored entry unconditionally — the repair path * (dashpay/platform#4060 finding 6), where a shape+fingerprint-valid @@ -2942,6 +2961,7 @@ interface PrivateKeyDeriver { publicKeyData: ByteArray, identityIndex: Int, keyIndex: Int, + keyType: Int = 0, force: Boolean = false, ): DerivedKeyStoreResult? diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/Hash160.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/Hash160.kt new file mode 100644 index 0000000000..aec89b2b08 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/Hash160.kt @@ -0,0 +1,152 @@ +package org.dashfoundation.dashsdk.security + +import java.security.MessageDigest + +/** + * Bitcoin/Dash **HASH160** — `RIPEMD160(SHA256(x))`, 20 bytes. + * + * ## Why this exists in the SDK + * + * A DPP identity key whose type is `ECDSA_HASH160` / `EDDSA_25519_HASH160` + * stores its on-chain "public key data" as the 20-byte HASH160 of the + * underlying public key, NOT the public key itself (rs-dpp `KeyType`; + * `IdentityPubkeyCodec` — "compressed pubkey, or 20-byte HASH160"). The + * identity-key repair path + * ([IdentityKeyPrivateKeyDeriver.derivedPublicKeyMatches]) must therefore + * hash the derived public key before comparing it to the stored value, or a + * HASH160-type key can NEVER be proven to match and repair fails forever + * (dashpay/platform#4183 review). This is the ownership check the reviewer + * asked for. + * + * ## Doctrine + * + * `packages/kotlin-sdk/CLAUDE.md` forbids **derivation**, **policy loops**, + * and **re-implementing protocol constants** in Kotlin. HASH160 is none of + * those: it hashes already-**public** bytes (no key material, no secret, no + * derivation-path logic) with two fully standardized, parameter-free digests. + * SHA-256 ships in the JDK; the JDK ships no RIPEMD-160 `MessageDigest`, so + * the standard public-domain RIPEMD-160 (Dobbertin/Bosselaers/Preneel, + * ISO/IEC 10118-3) is inlined below and pinned against reference vectors in + * `Hash160Test`. iOS computes the same value via the Rust + * `platform_wallet_hash160` FFI helper; swap this for a JNI-bridged call if + * one is ever exported. + * + * Pure + side-effect free so the repair's before-persistence identity check + * stays unit-testable without the native derive. + */ +internal object Hash160 { + + /** HASH160 = RIPEMD160(SHA256([input])) — 20 bytes. */ + fun hash160(input: ByteArray): ByteArray = + ripemd160(MessageDigest.getInstance("SHA-256").digest(input)) + + /** RIPEMD-160 of [input] — 20 bytes. */ + fun ripemd160(input: ByteArray): ByteArray { + var h0 = 0x67452301 + var h1 = 0xEFCDAB89.toInt() + var h2 = 0x98BADCFE.toInt() + var h3 = 0x10325476 + var h4 = 0xC3D2E1F0.toInt() + + // MD4-style padding: 0x80, zeros, then the 64-bit little-endian + // bit length, to a multiple of 64 bytes. + val bitLength = input.size.toLong() * 8 + val paddedLength = ((input.size + 8) / 64 + 1) * 64 + val padded = input.copyOf(paddedLength) + padded[input.size] = 0x80.toByte() + for (i in 0 until 8) { + padded[paddedLength - 8 + i] = (bitLength ushr (8 * i)).toByte() + } + + val x = IntArray(16) + var offset = 0 + while (offset < paddedLength) { + for (i in 0 until 16) { + val base = offset + 4 * i + x[i] = (padded[base].toInt() and 0xFF) or + ((padded[base + 1].toInt() and 0xFF) shl 8) or + ((padded[base + 2].toInt() and 0xFF) shl 16) or + ((padded[base + 3].toInt() and 0xFF) shl 24) + } + + var a = h0; var b = h1; var c = h2; var d = h3; var e = h4 + var ap = h0; var bp = h1; var cp = h2; var dp = h3; var ep = h4 + + for (j in 0 until 80) { + val round = j / 16 + var t = a + f(round, b, c, d) + x[R_LEFT[j]] + K_LEFT[round] + t = Integer.rotateLeft(t, S_LEFT[j]) + e + a = e; e = d; d = Integer.rotateLeft(c, 10); c = b; b = t + + var tp = ap + f(4 - round, bp, cp, dp) + x[R_RIGHT[j]] + K_RIGHT[round] + tp = Integer.rotateLeft(tp, S_RIGHT[j]) + ep + ap = ep; ep = dp; dp = Integer.rotateLeft(cp, 10); cp = bp; bp = tp + } + + val t = h1 + c + dp + h1 = h2 + d + ep + h2 = h3 + e + ap + h3 = h4 + a + bp + h4 = h0 + b + cp + h0 = t + + offset += 64 + } + + val out = ByteArray(20) + intArrayOf(h0, h1, h2, h3, h4).forEachIndexed { i, word -> + for (bIdx in 0 until 4) { + out[4 * i + bIdx] = (word ushr (8 * bIdx)).toByte() + } + } + return out + } + + /** The five round functions f1..f5, selected by round index 0..4. */ + private fun f(round: Int, x: Int, y: Int, z: Int): Int = when (round) { + 0 -> x xor y xor z + 1 -> (x and y) or (x.inv() and z) + 2 -> (x or y.inv()) xor z + 3 -> (x and z) or (y and z.inv()) + else -> x xor (y or z.inv()) + } + + private val K_LEFT = intArrayOf( + 0x00000000, 0x5A827999, 0x6ED9EBA1, 0x8F1BBCDC.toInt(), 0xA953FD4E.toInt(), + ) + private val K_RIGHT = intArrayOf( + 0x50A28BE6, 0x5C4DD124, 0x6D703EF3, 0x7A6D76E9, 0x00000000, + ) + + // Message-word selection order, left and right lines. + private val R_LEFT = intArrayOf( + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + 7, 4, 13, 1, 10, 6, 15, 3, 12, 0, 9, 5, 2, 14, 11, 8, + 3, 10, 14, 4, 9, 15, 8, 1, 2, 7, 0, 6, 13, 11, 5, 12, + 1, 9, 11, 10, 0, 8, 12, 4, 13, 3, 7, 15, 14, 5, 6, 2, + 4, 0, 5, 9, 7, 12, 2, 10, 14, 1, 3, 8, 11, 6, 15, 13, + ) + private val R_RIGHT = intArrayOf( + 5, 14, 7, 0, 9, 2, 11, 4, 13, 6, 15, 8, 1, 10, 3, 12, + 6, 11, 3, 7, 0, 13, 5, 10, 14, 15, 8, 12, 4, 9, 1, 2, + 15, 5, 1, 3, 7, 14, 6, 9, 11, 8, 12, 2, 10, 0, 4, 13, + 8, 6, 4, 1, 3, 11, 15, 0, 5, 12, 2, 13, 9, 7, 10, 14, + 12, 15, 10, 4, 1, 5, 8, 7, 6, 2, 13, 14, 0, 3, 9, 11, + ) + + // Per-step left-rotation amounts, left and right lines. + private val S_LEFT = intArrayOf( + 11, 14, 15, 12, 5, 8, 7, 9, 11, 13, 14, 15, 6, 7, 9, 8, + 7, 6, 8, 13, 11, 9, 7, 15, 7, 12, 15, 9, 11, 7, 13, 12, + 11, 13, 6, 7, 14, 9, 13, 15, 14, 8, 13, 6, 5, 12, 7, 5, + 11, 12, 14, 15, 14, 15, 9, 8, 9, 14, 5, 6, 8, 6, 5, 12, + 9, 15, 5, 11, 6, 8, 13, 12, 5, 12, 13, 14, 11, 8, 5, 6, + ) + private val S_RIGHT = intArrayOf( + 8, 9, 9, 11, 13, 15, 15, 5, 7, 7, 8, 11, 14, 14, 12, 6, + 9, 13, 15, 7, 12, 8, 9, 11, 7, 7, 12, 7, 6, 15, 13, 11, + 9, 7, 15, 11, 8, 6, 6, 14, 12, 13, 5, 14, 13, 13, 7, 5, + 15, 5, 8, 11, 14, 14, 6, 14, 6, 9, 12, 9, 12, 5, 15, 8, + 8, 5, 12, 9, 12, 5, 14, 6, 8, 13, 6, 5, 15, 13, 11, 11, + ) +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt index 40c533647a..b0864a46cd 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt @@ -50,6 +50,7 @@ class IdentityKeyPrivateKeyDeriver( publicKeyData: ByteArray, identityIndex: Int, keyIndex: Int, + keyType: Int, force: Boolean, ): DerivedKeyStoreResult { val pubkeyHex = publicKeyData.toHex() @@ -102,7 +103,7 @@ class IdentityKeyPrivateKeyDeriver( val derivedPrivate = pair[0] val derivedPublic = pair[1] scalar = derivedPrivate - if (!derivedPublicKeyMatches(derivedPublic, publicKeyData)) { + if (!derivedPublicKeyMatches(derivedPublic, publicKeyData, keyType)) { // Scrub the wrong scalar immediately; the finally scrubs // again harmlessly (idempotent zero-fill). derivedPrivate.fill(0) @@ -164,17 +165,35 @@ class IdentityKeyPrivateKeyDeriver( /** Matches `WalletStorage`'s private `PRIVKEY_PREFIX`. */ const val PRIVKEY_IDENTIFIER_PREFIX = "privkey." + /** DPP `KeyType` discriminants whose on-chain data is a HASH160. */ + private const val KEY_TYPE_ECDSA_HASH160 = 2 + private const val KEY_TYPE_EDDSA_25519_HASH160 = 4 + /** - * Whether a freshly derived public key [derived] is byte-for-byte the - * key [expected] a repair was asked to restore. Pure and side-effect - * free so the repair's before-persistence identity check + * Whether a freshly derived public key [derived] is the key [expected] + * a repair was asked to restore, interpreted per [keyType]. Pure and + * side-effect free so the repair's before-persistence identity check * (dashpay/platform#4060 blocker 1) is unit-testable without the - * native derive. Both halves are the compressed public-key bytes Rust - * emits (identical encoding on both derive entry points), so a plain - * content comparison is the whole check. + * native derive. + * + * [derived] is always the compressed public-key bytes Rust emits. What + * [expected] holds depends on the key type (dashpay/platform#4183 + * review): + * - For `ECDSA_HASH160` / `EDDSA_25519_HASH160`, DPP stores the 20-byte + * HASH160 of the public key as the key's on-chain data — NOT the key + * itself — so we must HASH160 the derived pubkey before comparing. + * A raw byte compare (33-byte pubkey vs 20-byte hash) can never + * match, which made these key types permanently un-repairable before + * this fix. + * - For every other key type the stored data IS the public key, so a + * plain content comparison is the whole check. */ - fun derivedPublicKeyMatches(derived: ByteArray, expected: ByteArray): Boolean = - derived.contentEquals(expected) + fun derivedPublicKeyMatches(derived: ByteArray, expected: ByteArray, keyType: Int): Boolean = + when (keyType) { + KEY_TYPE_ECDSA_HASH160, KEY_TYPE_EDDSA_25519_HASH160 -> + Hash160.hash160(derived).contentEquals(expected) + else -> derived.contentEquals(expected) + } } } 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 0d703fa5c9..05a44cdc63 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 @@ -779,6 +779,7 @@ class PlatformWalletPersistenceHandlerTest { publicKeyData: ByteArray, identityIndex: Int, keyIndex: Int, + keyType: Int, force: Boolean, ): DerivedKeyStoreResult? { calls.add(Triple(walletId, identityIndex, keyIndex)) @@ -1021,6 +1022,7 @@ class PlatformWalletPersistenceHandlerTest { publicKeyData: ByteArray, identityIndex: Int, keyIndex: Int, + keyType: Int, force: Boolean, ): DerivedKeyStoreResult = throw IllegalStateException("keystore unavailable") @@ -1092,6 +1094,7 @@ class PlatformWalletPersistenceHandlerTest { publicKeyData: ByteArray, identityIndex: Int, keyIndex: Int, + keyType: Int, force: Boolean, ): DerivedKeyStoreResult = if (boom) { @@ -1199,6 +1202,7 @@ class PlatformWalletPersistenceHandlerTest { publicKeyData: ByteArray, identityIndex: Int, keyIndex: Int, + keyType: Int, force: Boolean, ): DerivedKeyStoreResult = if (boom) { @@ -1409,6 +1413,7 @@ class PlatformWalletPersistenceHandlerTest { publicKeyData: ByteArray, identityIndex: Int, keyIndex: Int, + keyType: Int, force: Boolean, ): DerivedKeyStoreResult? { if (!force) { diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/Hash160Test.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/Hash160Test.kt new file mode 100644 index 0000000000..4469713947 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/Hash160Test.kt @@ -0,0 +1,58 @@ +package org.dashfoundation.dashsdk.security + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Pins [Hash160] against the RIPEMD-160 reference vectors + * (Dobbertin/Bosselaers/Preneel) and the canonical Bitcoin/Dash HASH160 = + * `RIPEMD160(SHA256(pubkey))` composition DPP uses for `ECDSA_HASH160` / + * `EDDSA_25519_HASH160` identity keys. If this drifts, the identity-key + * repair ownership check for HASH160-typed keys (dashpay/platform#4183) would + * compare against a wrong hash and either wrongly reject a valid repair or + * (worse) wrongly accept a mismatched key. + */ +class Hash160Test { + + private fun hex(bytes: ByteArray): String = + bytes.joinToString("") { "%02x".format(it) } + + private fun unhex(s: String): ByteArray = + s.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + @Test + fun ripemd160ReferenceVectors() { + assertEquals("9c1185a5c5e9fc54612808977ee8f548b2258d31", hex(Hash160.ripemd160(ByteArray(0)))) + assertEquals("8eb208f7e05d987a9b044a8e98c6b087f15a0bfc", hex(Hash160.ripemd160("abc".toByteArray()))) + assertEquals( + "5d0689ef49d2fae572b881b123a85ffa21595f36", + hex(Hash160.ripemd160("message digest".toByteArray())), + ) + assertEquals( + "12a053384a9c0c88e405a06c27dcf49ada62eb2b", + hex(Hash160.ripemd160("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq".toByteArray())), + ) + } + + @Test + fun ripemd160MultiBlockAndMillionA() { + // 80 bytes — crosses the 64-byte block boundary. + assertEquals( + "9b752e45573d4b39f4dbd3323cab82bf63326bfb", + hex(Hash160.ripemd160("1234567890".repeat(8).toByteArray())), + ) + assertEquals( + "52783243c1697bdbe16d37f97f68f08325dc1528", + hex(Hash160.ripemd160(ByteArray(1_000_000) { 'a'.code.toByte() })), + ) + } + + @Test + fun hash160OfKnownCompressedPubkey() { + // Canonical Bitcoin/Dash vector: the compressed secp256k1 pubkey for + // the classic sipa example private key. This is exactly the shape a + // HASH160-typed identity key stores on-chain as its "public key data". + val pubkey = unhex("0250863ad64a87ae8a2fe83c1af1a8403cb53f53e486d8511dad8a04887e5b2352") + assertEquals("f54a5851e9372b87810a8e60cdd2e7cfd80b6e31", hex(Hash160.hash160(pubkey))) + } +} diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriverMatchTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriverMatchTest.kt new file mode 100644 index 0000000000..1d07514cb9 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriverMatchTest.kt @@ -0,0 +1,99 @@ +package org.dashfoundation.dashsdk.security + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the key-type-aware ownership check used by the identity-key repair + * path ([IdentityKeyPrivateKeyDeriver.derivedPublicKeyMatches], + * dashpay/platform#4183 review). + * + * The repair derives the KEYPAIR and must prove its public half is the key it + * was asked to restore BEFORE persisting. For `ECDSA_HASH160` / + * `EDDSA_25519_HASH160` keys DPP stores the 20-byte HASH160 of the pubkey as + * the on-chain data, not the pubkey itself — so a raw byte compare (33-byte + * derived pubkey vs 20-byte stored hash) can NEVER match, and these keys were + * permanently un-repairable. The check now HASH160s the derived pubkey for + * those types. + */ +class IdentityKeyPrivateKeyDeriverMatchTest { + + private fun unhex(s: String): ByteArray = + s.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + // Canonical vector: a compressed secp256k1 pubkey and its HASH160. + private val compressedPubkey = + unhex("0250863ad64a87ae8a2fe83c1af1a8403cb53f53e486d8511dad8a04887e5b2352") + private val pubkeyHash160 = unhex("f54a5851e9372b87810a8e60cdd2e7cfd80b6e31") + + private val ecdsaSecp256k1 = 0 + private val ecdsaHash160 = 2 + private val eddsa25519Hash160 = 4 + + @Test + fun hash160KeyRepairs_derivedPubkeyMatchesStoredHash() { + // The regression the reviewer flagged: a HASH160-typed key whose + // stored data is the 20-byte hash of the derived pubkey MUST verify, + // so the repair proceeds instead of failing forever. + assertTrue( + IdentityKeyPrivateKeyDeriver.derivedPublicKeyMatches( + derived = compressedPubkey, + expected = pubkeyHash160, + keyType = ecdsaHash160, + ), + ) + assertTrue( + IdentityKeyPrivateKeyDeriver.derivedPublicKeyMatches( + derived = compressedPubkey, + expected = pubkeyHash160, + keyType = eddsa25519Hash160, + ), + ) + } + + @Test + fun hash160KeyRepair_rejectsWrongHash() { + val wrongHash = ByteArray(20) { 0x11 } + assertFalse( + IdentityKeyPrivateKeyDeriver.derivedPublicKeyMatches( + derived = compressedPubkey, + expected = wrongHash, + keyType = ecdsaHash160, + ), + ) + } + + @Test + fun hash160KeyRepair_rejectsRawPubkeyAsExpected() { + // The pre-fix bug shape: comparing the derived pubkey against a stored + // value that is the full pubkey (not its hash) must NOT be treated as a + // match for a HASH160 key type — the on-chain data is the hash. + assertFalse( + IdentityKeyPrivateKeyDeriver.derivedPublicKeyMatches( + derived = compressedPubkey, + expected = compressedPubkey, + keyType = ecdsaHash160, + ), + ) + } + + @Test + fun nonHash160KeyStillComparesRawBytes() { + // Every non-HASH160 key type keeps the plain content comparison. + assertTrue( + IdentityKeyPrivateKeyDeriver.derivedPublicKeyMatches( + derived = compressedPubkey, + expected = compressedPubkey, + keyType = ecdsaSecp256k1, + ), + ) + assertFalse( + IdentityKeyPrivateKeyDeriver.derivedPublicKeyMatches( + derived = compressedPubkey, + expected = pubkeyHash160, + keyType = ecdsaSecp256k1, + ), + ) + } +} From 60a84d39ce8945dc3393dda87edaf420a41382fc Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:12:57 -0400 Subject: [PATCH 24/26] fix(kotlin-sdk): stop treating Keystore error code 4 as no-lock-screen (#4183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LOCK_SCREEN_KEYGEN_REJECTION_CODES included android.security.KeyStoreException code 4, but 4 is ERROR_INTERNAL_SYSTEM_ERROR — a generic/transient fault, not the no-secure-lock-screen signal (code 3). Classifying it as no-lock-screen silently and permanently downgraded an AUTH_GATED identity key to the weaker DEVICE_BOUND alias on a transient error. Keep only the KeyMint-specific 10309 in the numeric set. A genuine transient internal error now falls through and is rethrown by resolveIdentityKeysWriteAlias as a retryable write failure instead of a security downgrade; the explicit lock-screen message path still classifies real no-LSKF rejections. Adds a negative test that code 4 does not classify, and repoints the existing numeric-code tests at 10309. Co-Authored-By: Claude Fable 5 --- .../dashsdk/security/KeystoreManager.kt | 25 ++++++-- .../security/KeystoreKeyGenPolicyTest.kt | 62 ++++++++++++------- 2 files changed, 60 insertions(+), 27 deletions(-) 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 31c477e7f5..5995ad58b7 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 @@ -855,8 +855,9 @@ open class KeystoreManager( * the lock-screen requirement (message references a lock screen) or sits * in the cause chain OF a key-generation `ProviderException` (message * "Keystore key generation failed") AND carries the observed KeyMint - * rejection's numeric code (internal Keystore code 4 / KeyMint 10309, - * observed on-device after the lock screen was removed). + * rejection's numeric code (KeyMint 10309, observed on-device after the + * lock screen was removed — the generic internal-error code 4 is NOT a + * lock-screen signal; see [LOCK_SCREEN_KEYGEN_REJECTION_CODES]). * * The classification is ONLY these two authoritative signals — the * lock-screen numeric code, or explicit lock-screen text. A bare @@ -939,11 +940,23 @@ open class KeystoreManager( } /** - * Numeric codes of the KeyMint "generate_key needs a secure lock - * screen" rejection: internal Keystore code 4 and KeyMint 10309 - * (both observed on-device, dashpay/platform#4060). + * Numeric codes that authoritatively mean "generate_key was rejected + * for want of a secure lock screen" — only the KeyMint-specific 10309 + * (observed on-device, dashpay/platform#4060). + * + * `android.security.KeyStoreException` code **4** is deliberately NOT + * here (dashpay/platform#4183 review): 4 is `ERROR_INTERNAL_SYSTEM_ERROR`, + * a GENERIC/transient Keystore fault, not the no-lock-screen signal + * (which is code 3). Treating a transient internal error as + * "no lock screen" silently and permanently downgraded an AUTH_GATED + * identity key to the weaker DEVICE_BOUND alias. A genuine transient + * internal error must instead surface as a write failure (rethrown by + * [resolveIdentityKeysWriteAlias]) so it can be retried — never a + * security downgrade. The explicit lock-screen *message* path + * ([keyStoreMessageNamesLockScreen]) still classifies real no-LSKF + * rejections regardless of numeric code. */ - private val LOCK_SCREEN_KEYGEN_REJECTION_CODES = setOf(4, 10309) + private val LOCK_SCREEN_KEYGEN_REJECTION_CODES = setOf(10309) private const val TAG = "KeystoreManager" 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 index c7beec6550..55d6b6675f 100644 --- 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 @@ -12,8 +12,10 @@ import java.security.ProviderException * (`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 + * "Keystore key generation failed" / KeyMint code 10309), which used to + * hard-crash wallet creation on a device with no screen lock. The generic + * internal-error code 4 is deliberately NOT treated as this signal + * (dashpay/platform#4183 review). 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]). @@ -57,19 +59,37 @@ class KeystoreKeyGenPolicyTest { // The exact on-device shape: a key-gen ProviderException wrapping a // Keystore system error carrying the lock-screen rejection numeric code // (the real android.security.KeyStoreException exposes it via - // getNumericErrorCode(), API 33+). It is the numeric code — not the - // incidental "generate_key" mention in the text — that classifies - // (dashpay/platform#4060 blocker 2). + // getNumericErrorCode(), API 33+). It is the KeyMint-specific numeric + // code 10309 — not the incidental "generate_key" mention in the text, + // nor the generic internal-error code 4 — that classifies + // (dashpay/platform#4060 blocker 2, dashpay/platform#4183 review). val failure = ProviderException( "Keystore key generation failed", SimulatedNumericKeyStoreException( "System error (internal Keystore code: 4 message: In generate_key. 10309)", - numericErrorCode = 4, + numericErrorCode = 10309, ), ) assertTrue(KeystoreManager.isNoSecureLockScreenKeyGenFailure(failure)) } + @Test + fun doesNotClassifyInternalSystemErrorCode4() { + // dashpay/platform#4183 review: code 4 is ERROR_INTERNAL_SYSTEM_ERROR, + // a GENERIC/transient Keystore fault — NOT the no-lock-screen signal + // (which is code 3). It must NOT classify, so a transient internal + // error surfaces as a retryable write failure instead of silently and + // permanently downgrading an AUTH_GATED identity key to DEVICE_BOUND. + val failure = ProviderException( + "Keystore key generation failed", + SimulatedNumericKeyStoreException( + "System error (internal Keystore code: 4 message: In generate_key.)", + numericErrorCode = 4, + ), + ) + assertFalse(KeystoreManager.isNoSecureLockScreenKeyGenFailure(failure)) + } + @Test fun doesNotClassifyBareGenerateKeyWithoutLockScreenSignal() { // Blocker 2: a bare "generate_key" mention is NOT a lock-screen signal. @@ -106,19 +126,18 @@ class KeystoreKeyGenPolicyTest { @Test fun classifiesNumericRejectionCodeUnderAKeyGenProviderException() { // Some OEM builds report the rejection with an opaque message; the - // structured numeric code (internal Keystore 4 / KeyMint 10309) is - // then the only evidence. It counts ONLY inside a key-gen - // ProviderException's cause chain. - listOf(4, 10309).forEach { code -> - val failure = ProviderException( - "Keystore key generation failed", - SimulatedNumericKeyStoreException("System error", code), - ) - assertTrue( - "numeric code $code must classify", - KeystoreManager.isNoSecureLockScreenKeyGenFailure(failure), - ) - } + // structured KeyMint-specific numeric code 10309 is then the only + // evidence. It counts ONLY inside a key-gen ProviderException's cause + // chain (the generic internal-error code 4 is excluded — + // dashpay/platform#4183 review). + val failure = ProviderException( + "Keystore key generation failed", + SimulatedNumericKeyStoreException("System error", 10309), + ) + assertTrue( + "numeric code 10309 must classify", + KeystoreManager.isNoSecureLockScreenKeyGenFailure(failure), + ) } @Test @@ -149,10 +168,11 @@ class KeystoreKeyGenPolicyTest { fun doesNotClassifyAKeystoreErrorOutsideTheKeyGenSubtree() { // The lock-screen-message match stands alone, but the bare-system- // error match requires the key-gen ProviderException ABOVE it in the - // cause chain — an unrelated wrapper does not open the window. + // cause chain — an unrelated wrapper does not open the window, even for + // the otherwise-classifying KeyMint code 10309. val failure = ProviderException( "unrelated provider issue", - SimulatedNumericKeyStoreException("System error", 4), + SimulatedNumericKeyStoreException("System error", 10309), ) assertFalse(KeystoreManager.isNoSecureLockScreenKeyGenFailure(failure)) } From d3ba9b6e359fd0aa4523af58af3db4cc9a4f931f Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:13:09 -0400 Subject: [PATCH 25/26] fix(kotlin-sdk): don't fail open on invalidation bookkeeping / load cancellation (#4183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onSigningKeyInvalidated wrapped the durable recordSigningKeyInvalidated write in a bare runCatching {} that swallowed a failed OR cancelled write while the sign still returned typed code 31 — the pending-repair signal was lost silently and a cancelled signer scope was masked. Replace with a try/catch that rethrows CancellationException and, on other failures, logs loudly and rethrows so the signer's own best-effort guard (not this lambda) is the single place that treats bookkeeping failure as non-fatal; the repair stays retryable (durable rows are untouched; the next sign attempt / next load reconstruction re-runs it). Also fix loadPersistedWallets: its bare runCatching around the suspend reconstructPendingIdentityKeysFromPersistence swallowed CancellationException — rethrow it (a best-effort reconstruction failure is still absorbed and logged). Co-Authored-By: Claude Fable 5 --- .../dashsdk/wallet/PlatformWalletManager.kt | 47 ++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) 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 8aab9fd3f2..79bf184d89 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 @@ -383,10 +383,39 @@ class PlatformWalletManager( // coreChildren AFTER construction completes — the lambda only // runs on later sign attempts, never during this block. onSigningKeyInvalidated = { pubkeyHex -> - runCatching { + try { persistenceHandler.recordSigningKeyInvalidated(pubkeyHex) { walletStorage.isPrivateKeyDecryptable(it) } + } catch (cancellation: kotlin.coroutines.cancellation.CancellationException) { + // NEVER swallow structured-concurrency cancellation — + // rethrow so a teardown of the signer's IO scope + // propagates instead of being masked as a benign + // bookkeeping miss. + throw cancellation + } catch (t: Throwable) { + // Do NOT fail open (dashpay/platform#4183 review): the + // durable invalidation bookkeeping (null the row's + // keychain identifier + re-seed pendingIdentityKeys) + // did NOT complete, so the repair signal is not yet + // persisted. The sign still fails with the typed code + // 31, but swallowing this silently would leave the key + // looking healthy on the next launch. Surface it loudly + // and rethrow so the signer's own best-effort guard — + // not this bookkeeping lambda — is the single place + // that decides bookkeeping failure is non-fatal to the + // completion; the repair stays retryable (the durable + // rows were not cleared, and the next sign attempt / the + // next loadPersistedWallets reconstruction re-runs it). + android.util.Log.e( + "PlatformWalletManager", + "durable sign-time invalidation bookkeeping FAILED for key " + + "${pubkeyHex.take(16)}… — the pending-repair signal is not yet " + + "persisted; it will be retried on the next sign attempt or the " + + "next launch's pending-key reconstruction", + t, + ) + throw t } }, ).also { signer = it } @@ -998,10 +1027,24 @@ class PlatformWalletManager( // CHEAP capability check re-seed pendingIdentityKeys, so a repair // signal recorded before a process death (or a blob stranded by a // Keystore keypair replacement) resurfaces on every launch. - runCatching { + try { persistenceHandler.reconstructPendingIdentityKeysFromPersistence( isPrivateKeyDecryptable = { walletStorage.isPrivateKeyDecryptable(it) }, ) + } catch (cancellation: kotlin.coroutines.cancellation.CancellationException) { + // NEVER swallow structured-concurrency cancellation from the + // suspend reconstruction — rethrow so a cancelled load propagates + // (dashpay/platform#4183 review). A best-effort reconstruction + // failure is fine to absorb (the repair signal reconstructs on the + // next launch), but cancellation must not be masked. + throw cancellation + } catch (t: Throwable) { + android.util.Log.w( + "PlatformWalletManager", + "pending-identity-key reconstruction failed on load; repair signals will be " + + "rebuilt on the next launch", + t, + ) } // Room is the source of truth for the restorable id list — the same From e9b76814b0a2c0139d05f71da0e2a6338ce5a016 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:13:18 -0400 Subject: [PATCH 26/26] fix(example): probe off-Main and label key-health by recoverability (#4183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WalletKeyHealthSheet recoverability probes (which decrypt each Keystore blob) ran inside produceState on the composition/Main thread; wrap the whole report build in withContext(Dispatchers.IO). The state was named hasPrivateKey and every failed probe was labeled "Missing — no Keystore entry", but the probe can't tell a truly-absent key from a present- but-stranded/undecryptable one. Rename to isRecoverable and relabel ("Unrecoverable — key material missing or stranded; re-derive to repair", summary "Unrecoverable key material") so the UI reflects recoverability, not presence. Co-Authored-By: Claude Fable 5 --- .../example/ui/wallet/WalletKeyHealthSheet.kt | 101 ++++++++++-------- 1 file changed, 59 insertions(+), 42 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/WalletKeyHealthSheet.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/WalletKeyHealthSheet.kt index b060d0a2d0..8a60ea7b3b 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/WalletKeyHealthSheet.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/WalletKeyHealthSheet.kt @@ -42,12 +42,13 @@ import org.dashfoundation.example.util.Base58 /** * Wallet key-health diagnostic — port of `WalletKeyHealthSheet.swift`, * scoped to what the Kotlin SDK bridges today: for every identity owned by - * this wallet (`IdentityDao`), walk its `PublicKeyDao` rows and verify the - * Keystore holds private-key material for each stored pubkey - * (`WalletStorage.hasPrivateKey`, the analogue of the iOS Keychain - * lookup). + * this wallet (`IdentityDao`), walk its `PublicKeyDao` rows and check whether + * the private key for each stored pubkey is actually RECOVERABLE — the probe + * (`WalletStorage.probeIdentityKeyRecoverability`, the analogue of the iOS + * Keychain lookup) opens the stored blob, so a present-but-stranded/ + * undecryptable key reads as unrecoverable, not just an absent one. * - * Missing rows offer a Repair action: it re-derives the canonical private + * Unrecoverable rows offer a Repair action: it re-derives the canonical private * key at `(identityIndex, keyId)` from the wallet mnemonic via the * resolver-keyed derive FFI (`dash_sdk_derive_identity_key_at_slot`, * surfaced as `PlatformWalletManager.repairIdentityKey`) and re-encrypts it @@ -79,34 +80,39 @@ fun WalletKeyHealthSheet( refreshTick, ) { val ids = identities ?: return@produceState - value = ids.map { identity -> - val base58Id = Base58.encode(identity.identityId) - val keys = container.database.publicKeyDao() - .observeByIdentityId(base58Id).first() - .map { row -> - val pubkeyHex = row.publicKeyData.joinToString("") { "%02x".format(it) } - KeyHealthRow( - keyId = row.keyId, - purpose = row.purpose, - securityLevel = row.securityLevel, - pubkeyHex = pubkeyHex, - publicKeyData = row.publicKeyData, - // Real recoverability, not mere presence — the - // PROBING check actually opens the blob with the - // candidate keys, so a stranded/sibling-alias blob is - // reported unhealthy and gets the same repair as a - // missing one (the cheap isPrivateKeyDecryptable is - // reserved for the signer's capability callback). - hasPrivateKey = runCatching { - container.walletStorage.probeIdentityKeyRecoverability(pubkeyHex) - }.getOrDefault(false), - ) - } - IdentityKeyReport( - identityIdBase58 = base58Id, - identityIndex = identity.identityIndex, - keys = keys, - ) + // The recoverability probe opens each Keystore blob (a decrypt), so it + // MUST run off the composition/Main thread — do the whole report build + // on IO (the Room reads suspend, the probes are blocking Keystore work). + value = withContext(Dispatchers.IO) { + ids.map { identity -> + val base58Id = Base58.encode(identity.identityId) + val keys = container.database.publicKeyDao() + .observeByIdentityId(base58Id).first() + .map { row -> + val pubkeyHex = row.publicKeyData.joinToString("") { "%02x".format(it) } + KeyHealthRow( + keyId = row.keyId, + purpose = row.purpose, + securityLevel = row.securityLevel, + pubkeyHex = pubkeyHex, + publicKeyData = row.publicKeyData, + // Real recoverability, not mere presence — the + // PROBING check actually opens the blob with the + // candidate keys, so a stranded/sibling-alias blob is + // reported unrecoverable and gets the same repair as a + // truly-absent one (the cheap isPrivateKeyDecryptable + // is reserved for the signer's capability callback). + isRecoverable = runCatching { + container.walletStorage.probeIdentityKeyRecoverability(pubkeyHex) + }.getOrDefault(false), + ) + } + IdentityKeyReport( + identityIdBase58 = base58Id, + identityIndex = identity.identityIndex, + keys = keys, + ) + } } } @@ -139,11 +145,11 @@ fun WalletKeyHealthSheet( else -> { // Summary (← summarySection). - val healthy = current.count { report -> report.keys.all { it.hasPrivateKey } } + val healthy = current.count { report -> report.keys.all { it.isRecoverable } } FormSection(title = "Summary") { SummaryRow("Identities checked", current.size) SummaryRow("Healthy", healthy) - SummaryRow("Missing key material", current.size - healthy) + SummaryRow("Unrecoverable key material", current.size - healthy) } current.forEach { report -> @@ -165,7 +171,7 @@ fun WalletKeyHealthSheet( key = key, // Repair is available only when a manager // is live (holds the resolver + storage). - onRepair = if (!key.hasPrivateKey && activeManager != null) { + onRepair = if (!key.isRecoverable && activeManager != null) { { scope.launch { runCatching { @@ -208,7 +214,15 @@ private data class KeyHealthRow( val securityLevel: String, val pubkeyHex: String, val publicKeyData: ByteArray, - val hasPrivateKey: Boolean, + /** + * Whether the private key actually opens on this device — the probe + * decrypts the stored blob. `false` covers BOTH a truly-absent key and a + * present-but-unrecoverable one (stranded ciphertext / sibling-alias blob + * that no longer decrypts); the probe can't tell them apart, so the row is + * labeled by recoverability, not presence, and both get the same re-derive + * repair (dashpay/platform#4183 review). + */ + val isRecoverable: Boolean, ) private data class IdentityKeyReport( @@ -242,9 +256,9 @@ private fun KeyRow(key: KeyHealthRow, onRepair: (() -> Unit)? = null) { horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( - imageVector = if (key.hasPrivateKey) Icons.Default.CheckCircle else Icons.Default.Cancel, + imageVector = if (key.isRecoverable) Icons.Default.CheckCircle else Icons.Default.Cancel, contentDescription = null, - tint = if (key.hasPrivateKey) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error, + tint = if (key.isRecoverable) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error, ) Text( "Key #${key.keyId} — purpose ${key.purpose}, level ${key.securityLevel}", @@ -258,10 +272,13 @@ private fun KeyRow(key: KeyHealthRow, onRepair: (() -> Unit)? = null) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - if (key.hasPrivateKey) { - "Healthy — private key material stored on this device" + if (key.isRecoverable) { + "Healthy — private key material recoverable on this device" } else { - "Missing — no Keystore entry for this public key" + // Not necessarily absent: the blob may be present but stranded/ + // undecryptable (sibling-alias or invalidated Keystore key). + // The probe can't distinguish, so don't claim "no entry". + "Unrecoverable — key material missing or stranded; re-derive to repair" }, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant,