diff --git a/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md b/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md index dd4904bc396..7113bb97c58 100644 --- a/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md +++ b/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md @@ -54,6 +54,26 @@ 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 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). + +- **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 3ca47b7ff7a..25aed864dd6 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,41 @@ 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 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 - 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 6d4f698373d..3fff995f3b1 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/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 2111c9dc05f..8a60ea7b3be 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,31 +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, - // Decryptability, not mere existence — a blob from the - // pre-RSA scheme is present but unusable and needs the - // same repair as a missing one. - hasPrivateKey = runCatching { - container.walletStorage.isPrivateKeyDecryptable(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, + ) + } } } @@ -136,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 -> @@ -162,16 +171,22 @@ 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 { 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, ) } } @@ -199,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( @@ -233,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}", @@ -249,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, diff --git a/packages/kotlin-sdk/PARITY_SUMMARY.md b/packages/kotlin-sdk/PARITY_SUMMARY.md index 46a78ad4276..129cb4081f4 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/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 00000000000..e8cb0819dfb --- /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 9ba7c7b1da7..ddb55ed666d 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/androidTest/kotlin/org/dashfoundation/dashsdk/security/KeystoreSignerInstrumentedTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/security/KeystoreSignerInstrumentedTest.kt index 1106824c26a..0b1e7bc1fc4 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 b5dc510b5a5..fd2c262f45a 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 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 de41a05412a..9c57d763b10 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,69 @@ 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. + * + * 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. + */ + class SigningKeyUnavailable(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) { + companion object { + /** + * Stable prefix of the `KeystoreSigner` "missing key" + * 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 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" + } + } + + /** + * `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 @@ -197,6 +260,14 @@ 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 the typed [PlatformWallet.NotFound]. + */ + 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" @@ -223,7 +294,14 @@ 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]. 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 fallback for the #4191 merge-order transition (never + * applied to the dedicated retry-semantics types, so those are + * never overridden; mixed old-native/new-Kotlin artifacts are + * unsupported — see [PlatformWallet.SigningKeyUnavailable.MESSAGE_MARKER]). */ private fun fromPlatformWalletNative( code: Int, @@ -232,11 +310,33 @@ 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 + // @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 { + PlatformWallet.WalletOperation(message, cause) + } 7, // ErrorIdentityNotFound 8, // ErrorContactNotFound - 98, // NotFound (Option returned as an error) -> NotFound(message, cause) + // 98 (PlatformWalletFFIResultCode::NotFound, the blanket Option → + // result miss) stays inside the wallet-error family as the typed + // PlatformWallet.NotFound — exact Swift parity + // (PlatformWalletError.notFound) — rather than collapsing into the + // top-level NotFound that rs-sdk-ffi codes 7/8 map to. Dashpay's + // managed-identity local reads are unaffected: they intercept the + // RAW code via translateManagedIdentityNotFoundToZero (#4051) + // before this mapping ever runs. 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 @@ -245,8 +345,24 @@ 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) + // 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 { + 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/ffi/SignerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/SignerNative.kt index a39a2d307b8..40c032b1b9f 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/identity/IdentityRegistration.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt index 0aef05ce725..2f92c1497a4 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/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index 95074cb0e68..7dc3dfa525a 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 9dce9df0798..ded0d6dcccd 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,10 +5,15 @@ 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 +import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.ffi.AccountSpecData import org.dashfoundation.dashsdk.ffi.ContactProfileRestoreData import org.dashfoundation.dashsdk.ffi.ContactRequestRestoreData @@ -162,6 +167,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 +314,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 +384,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 +400,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 +414,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 +1105,62 @@ 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, + keyType = keyType.toInt() and 0xFF, ) - }.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 } @@ -1078,6 +1199,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(), @@ -2425,6 +2555,301 @@ 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)) + } + + /** + * 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. + * + * ## 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/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 + ?: 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 (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) { + 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, keyType) = breadcrumbs + + // force = true routes through WalletStorage.replacePrivateKey and, in + // the production deriver, derives the KEYPAIR and verifies the derived + // 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( + walletId = walletId, + publicKeyData = publicKeyData, + identityIndex = identityIndex, + keyIndex = keyIndex, + keyType = keyType, + 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", + ) + } + + // 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 + } + + /** + * 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- + * 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(), + reason: String = "reconstructed from persistence after restart", + ) { + 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 = reason, + 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 ────────────────────────────────────── /** @@ -2513,14 +2938,31 @@ 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 + * 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, + keyType: Int = 0, + force: Boolean = false, ): DerivedKeyStoreResult? /** 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 cf92f41e24e..a0a92134aea 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 7680a76cf8c..8ac11a37624 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/security/Hash160.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/Hash160.kt new file mode 100644 index 00000000000..aec89b2b08c --- /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 20ca9b96e03..b0864a46cd4 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,8 @@ class IdentityKeyPrivateKeyDeriver( publicKeyData: ByteArray, identityIndex: Int, keyIndex: Int, + keyType: Int, + force: Boolean, ): DerivedKeyStoreResult { val pubkeyHex = publicKeyData.toHex() var scalar: ByteArray? = null @@ -62,19 +64,74 @@ 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 deriveScalarOnly: 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 } + } + // 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, keyType)) { + // 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 { - 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 = deriveVerifiedForRepair, + ) + true + } else { + walletStorage.storeIfAbsent( + pubkeyHex, + ownerWalletId = walletId, + derive = deriveScalarOnly, + ) } } } finally { @@ -104,8 +161,47 @@ 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." + + /** 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 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. + * + * [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, keyType: Int): Boolean = + when (keyType) { + KEY_TYPE_ECDSA_HASH160, KEY_TYPE_EDDSA_25519_HASH160 -> + Hash160.hash160(derived).contentEquals(expected) + else -> 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/security/KeySecurityPolicy.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt new file mode 100644 index 00000000000..e2a7bc325ef --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt @@ -0,0 +1,90 @@ +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, non-exportable + * device-bound wrapping key instead (hardware-backed where the device + * provides it — see the storage-backing note below). + * + * ## 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. + * + * ## 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 + * 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. + * + * ## 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 { + /** + * Identity-key decrypts require Android user authentication within + * [KeystoreManager.AUTH_VALIDITY_SECONDS] (the historical default). + */ + AUTH_GATED, + + /** + * 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, +} 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 00000000000..68a5f961f95 --- /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 cb3b017cec7..5995ad58b76 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,11 +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 @@ -31,9 +34,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 +46,184 @@ 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, + /** + * 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, +) { + + /** + * 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 + } + + /** + * 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 [plaintext] under [alias]. + * 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. * - * [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. + * 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). */ - fun encrypt(plaintext: ByteArray, alias: String = MASTER_ALIAS): EncryptedBlob { - if (alias == KEYS_ALIAS) { - return encryptForKeysAlias(plaintext).blob + 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 + * 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. + */ + 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 +231,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 +240,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 +256,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 +282,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 +299,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 +313,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 +503,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( @@ -210,7 +532,7 @@ class KeystoreManager { } private fun generateAesKey(alias: String): SecretKey { - fun spec(strongBox: Boolean): KeyGenParameterSpec { + fun spec(strongBox: Boolean, lockBound: Boolean): KeyGenParameterSpec { val builder = KeyGenParameterSpec.Builder( alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, @@ -218,7 +540,12 @@ class KeystoreManager { .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(256) - .setUnlockedDeviceRequired(true) + // `setUnlockedDeviceRequired(true)` binds the key to a screen-lock + // (an "unlocked device" is only meaningful when a secure lock screen + // exists); KeyMint rejects generate_key for it on a lockless device. + // Dropped when no secure lock screen is configured (see + // [generateWithLockScreenDegradation]). + if (lockBound) builder.setUnlockedDeviceRequired(true) if (strongBox) builder.setIsStrongBoxBacked(true) return builder.build() } @@ -227,24 +554,89 @@ class KeystoreManager { KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE, ) - return try { - generator.init(spec(strongBox = true)) - generator.generateKey() - } catch (_: StrongBoxUnavailableException) { - generator.init(spec(strongBox = false)) + return generateWithLockScreenDegradation(alias) { strongBox, lockBound -> + generator.init(spec(strongBox, lockBound)) generator.generateKey() } } - // ── KEYS_ALIAS: RSA-2048 OAEP keypair (identity private keys) ── - // Public key encrypts (never auth-gated → unprompted writes); private key - // decrypts under user auth within AUTH_VALIDITY_SECONDS (signing prompts). + /** + * 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) + } - private fun keysPublicKey(): PublicKey = - androidKeyStore().getCertificate(KEYS_ALIAS)?.publicKey ?: ensureKeysKeyPair().public + 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 + // AUTH_VALIDITY_SECONDS (signing prompts), while the + // KEYS_ALIAS_DEVICE_BOUND private key decrypts without a gate + // (KeySecurityPolicy.DEVICE_BOUND). - private fun keysPrivateKeyGeneration(): KeysAliasPrivateKeyGeneration { - val keyPair = ensureKeysKeyPair() + private fun keysPublicKey(alias: String): PublicKey = + androidKeyStore().getCertificate(alias)?.publicKey ?: ensureKeysKeyPair(alias).public + + private fun keysPrivateKeyGeneration(alias: String): KeysAliasPrivateKeyGeneration { + val keyPair = ensureKeysKeyPair(alias) return KeysAliasPrivateKeyGeneration( privateKey = keyPair.private, fingerprint = fingerprint(keyPair.public), @@ -252,25 +644,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 +686,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) } - - fun spec(strongBox: Boolean): KeyGenParameterSpec { + // 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, lockBound: Boolean): KeyGenParameterSpec { val builder = KeyGenParameterSpec.Builder( - KEYS_ALIAS, + alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, ) .setKeySize(RSA_KEY_SIZE) @@ -307,21 +715,29 @@ class KeystoreManager { // [oaepSpec] — both encrypt and decrypt pin MGF1 = SHA-1. .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA1) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) - .setUnlockedDeviceRequired(true) - .setUserAuthenticationRequired(true) - // setUserAuthenticationParameters is API 30+ (Android 11); on the - // minSdk-29 (Android 10) floor fall back to the deprecated pre-30 - // time-bound API. Pre-30 the key accepts any enrolled authenticator - // for the window; the STRONG|DEVICE_CREDENTIAL restriction (and the - // AuthPrompt that requests it) still applies on 30+. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - builder.setUserAuthenticationParameters( - AUTH_VALIDITY_SECONDS, - KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL, - ) - } else { - @Suppress("DEPRECATION") - builder.setUserAuthenticationValidityDurationSeconds(AUTH_VALIDITY_SECONDS) + // Both lock-screen-bound parameters — `setUnlockedDeviceRequired` + // and (auth-gated only) `setUserAuthenticationRequired` — require a + // secure lock screen to exist; KeyMint rejects generate_key for + // them otherwise. + if (lockBound) builder.setUnlockedDeviceRequired(true) + if (authGated) { + builder.setUserAuthenticationRequired(true) + // setUserAuthenticationParameters is API 30+ (Android 11); on + // the minSdk-29 (Android 10) floor fall back to the deprecated + // pre-30 time-bound API. Pre-30 the key accepts any enrolled + // authenticator for the window; the STRONG|DEVICE_CREDENTIAL + // restriction (and the AuthPrompt that requests it) still + // applies on 30+. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setUserAuthenticationParameters( + AUTH_VALIDITY_SECONDS, + KeyProperties.AUTH_BIOMETRIC_STRONG or + KeyProperties.AUTH_DEVICE_CREDENTIAL, + ) + } else { + @Suppress("DEPRECATION") + builder.setUserAuthenticationValidityDurationSeconds(AUTH_VALIDITY_SECONDS) + } } if (strongBox) builder.setIsStrongBoxBacked(true) return builder.build() @@ -331,15 +747,34 @@ 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() + } } } + 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 +786,182 @@ 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 + + /** + * 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 "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 (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 + * `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, 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 + * 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 = + // 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 + * `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 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(10309) + + private const val TAG = "KeystoreManager" + // Guards first-use creation/migration of the process-global - // KEYS_ALIAS entry across concurrent callers (and across the + // identity-keys entries across concurrent callers (and across the // per-WalletStorage KeystoreManager instances). private val KEYS_ALIAS_LOCK = Any() diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt index 7258a77247c..825f89d19e4 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 @@ -8,6 +9,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 @@ -42,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 = @@ -80,9 +96,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, + completionErrorCodeFor(e), e.message ?: "signing failed", ) } @@ -98,20 +120,65 @@ 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). 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, + 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 → + // DashSdkError.PlatformWallet.SigningKeyUnavailable + // (dashpay/platform#4060 finding 7). The MESSAGE_MARKER text + // 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, - "no private key stored for ${storageKey.take(16)}…", + SignerNative.SIGNER_ERROR_CODE_KEY_UNAVAILABLE, + "${DashSdkError.PlatformWallet.SigningKeyUnavailable.MESSAGE_MARKER} " + + "${storageKey.take(16)}…", ) return } 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) @@ -140,6 +207,7 @@ class KeystoreSigner( SignerNative.completeSign( completionToken, null, + SignerNative.SIGNER_ERROR_CODE_GENERIC, "no platform address row for $hashHex", ) return @@ -151,6 +219,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)", ) @@ -164,6 +233,7 @@ class KeystoreSigner( SignerNative.completeSign( completionToken, null, + SignerNative.SIGNER_ERROR_CODE_GENERIC, "no mnemonic stored for wallet of platform address $hashHex", ) return @@ -177,9 +247,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", + ) } } @@ -229,7 +309,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 @@ -237,7 +317,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/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt index f6e9fd96edb..40de259fdd1 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt @@ -1,6 +1,9 @@ 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 @@ -10,7 +13,9 @@ 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 +import kotlin.coroutines.cancellation.CancellationException private val Context.secretsStore: DataStore by preferencesDataStore( name = "org.dashfoundation.wallet.secrets", @@ -25,21 +30,53 @@ 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(), + private val keystore: KeystoreManager = + KeystoreManager(deviceSecureProbe = deviceSecureProbe(context)), ) { + /** + * Construct with an explicit identity-key [keySecurityPolicy] — + * convenience for host apps that don't otherwise need to touch + * [KeystoreManager]. `WalletStorage(context)` keeps the + * [KeySecurityPolicy.AUTH_GATED] default. + */ + constructor(context: Context, keySecurityPolicy: KeySecurityPolicy) : + this(context, KeystoreManager(keySecurityPolicy, deviceSecureProbe(context))) + private val store = context.secretsStore + /** The identity-key security policy this storage was constructed with. */ + val keySecurityPolicy: KeySecurityPolicy get() = keystore.keySecurityPolicy + + /** + * 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 @@ -208,14 +245,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 @@ -342,6 +380,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`; @@ -358,18 +428,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.encryptForKeysAlias(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() @@ -377,9 +456,26 @@ 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.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 +492,8 @@ class WalletStorage( ): Boolean { if (!keystore.isKeysBlobDecryptable(decode(encoded))) return false val fingerprint = prefs[privateKeyFingerprintKey(pubkeyHex)] ?: return false - val current = keystore.keysAliasFingerprintOrNull() ?: return false + val alias = recordedKeysAliasFor(pubkeyHex, prefs) + val current = keystore.keysAliasFingerprintOrNull(alias) ?: return false return fingerprint == current } @@ -410,23 +507,192 @@ 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.KEYS_ALIAS] - * 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 - * `UserNotAuthenticatedException` when the auth window expired — the - * caller (KeystoreSigner) routes through [BiometricGate] and retries. + * 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 - return keystore.decrypt(decode(encoded), alias = KeystoreManager.KEYS_ALIAS) + 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. + // + // 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) { + 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 — + * 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 + } + + /** + * 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, + ) { + try { + 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 + } + } + } 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. + } } suspend fun deletePrivateKey(pubkeyHex: String) { @@ -434,6 +700,7 @@ class WalletStorage( store.edit { it.remove(privateKeyKey(pubkeyHex)) it.remove(privateKeyFingerprintKey(pubkeyHex)) + it.remove(privateKeyAliasKey(pubkeyHex)) } } } @@ -456,6 +723,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 @@ -477,21 +745,161 @@ class WalletStorage( store.data.first().contains(privateKeyKey(pubkeyHex)) /** - * Whether the blob stored for [pubkeyHex] is decryptable under the - * current [KeystoreManager.KEYS_ALIAS] RSA scheme. Blobs written by the - * pre-RSA AES-GCM scheme survive in the DataStore but lost their key - * when the RSA pair replaced it, so signing with them can only fail — - * key-health treats them as missing and offers a re-derive. 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 + } + // 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(unaeProvesRecoverable) { 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`) 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( + unaeProvesRecoverable: Boolean = true, + decrypt: () -> ByteArray?, + ): Boolean = + try { + val plain = decrypt() + if (plain != null) { + plain.fill(0) + true + } else { + false + } + } catch (e: UserNotAuthenticatedException) { + unaeProvesRecoverable + } 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() @@ -513,6 +921,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()) @@ -529,9 +940,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/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index 26f7025cc00..c43a4a2475e 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,29 @@ 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.PlatformWallet.NotFound("…ManagedIdentity not + * found")` instead of returning null. Any other error is rethrown + * untouched. + */ +internal inline fun translateManagedIdentityNotFoundToZero(getHandle: () -> Long): Long = + try { + getHandle() + } catch (e: DashSDKException) { + val notFound = DashSdkError.PLATFORM_WALLET_CODE_OFFSET + + DashSdkError.PLATFORM_WALLET_NOT_FOUND_CODE + if (e.code == notFound) 0L else throw e + } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index ba05a9ff7ff..27c846cd3b9 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/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 48eca431f63..79bf184d89c 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 ───────────────────────────────────────────── @@ -356,6 +374,50 @@ 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 -> + 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 } val deriver = IdentityKeyPrivateKeyDeriver( network = network, @@ -400,6 +462,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 @@ -407,39 +480,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 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), 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]. * - * 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. + * 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 - // 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. - identityKeyDeriver.deriveAndStore( + // resolver handle — the teardownGate keeps teardown from freeing it + // mid-derive (callers run on their own Compose scopes). + // + // 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, - )?.identifier + 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) + }, + ) } /** @@ -912,6 +1021,32 @@ 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. + 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 // rows the load callback just fed to Rust. Scope to THIS network so // a per-network manager only restores its own wallets (matching the @@ -1638,7 +1773,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 f8e397cade5..8879712206c 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) @@ -116,6 +128,77 @@ 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 + // 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). 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…"), + ) + assertTrue( + "code $code with marker → SigningKeyUnavailable", + mapped is DashSdkError.PlatformWallet.SigningKeyUnavailable, + ) + assertFalse(mapped.isRetryable) + } + // Without the marker the catch-all mappings are untouched. + val walletOp = DashSdkError.fromNative(DashSDKException(offset + 6, "op failed")) + assertTrue(walletOp is DashSdkError.PlatformWallet.WalletOperation) + val generic = DashSdkError.fromNative(DashSDKException(offset + 99, "boom")) + assertTrue(generic is DashSdkError.PlatformWallet.Generic) + } + + @Test + fun signingKeyMarkerNeverOverridesRetrySemanticsCodes() { + val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET + val marker = DashSdkError.PlatformWallet.SigningKeyUnavailable.MESSAGE_MARKER + // A dedicated retry-semantics code keeps its type even if the Rust + // message happens to embed the marker text. + val mapped = DashSdkError.fromNative( + DashSDKException(offset + 19, "anchor missing; $marker something"), + ) + assertTrue(mapped is DashSdkError.PlatformWallet.ShieldedNoRecordedAnchor) + } + + @Test + fun platformWalletNotFoundCodeMapsToTypedWalletNotFound() { + // PlatformWalletFFIResultCode::NotFound (98) — the code the Option → + // result conversion emits for "requested not found". Maps to + // the typed PlatformWallet.NotFound (Swift parity: + // PlatformWalletError.notFound); Dashpay's managed-identity reads + // translate the raw code to null before it ever escapes (#4051). + val mapped = DashSdkError.fromNative( + DashSDKException( + DashSdkError.PLATFORM_WALLET_CODE_OFFSET + + DashSdkError.PLATFORM_WALLET_NOT_FOUND_CODE, + "requested platform_wallet::identity::ManagedIdentity not found", + ), + ) + assertTrue(mapped is DashSdkError.PlatformWallet.NotFound) + } + @Test fun mapNativeErrorsConvertsAtTheBoundary() { try { @@ -138,7 +221,14 @@ class DashSdkErrorTest { } }.exceptionOrNull() - assertTrue(error is DashSdkError.NotFound) - assertEquals("wallet not found", error?.message) + // Code 98 surfaces (through the public mapNativeErrors boundary) as the + // typed PlatformWallet.NotFound — the wallet-family NotFound, exactly + // how Swift surfaces PlatformWalletError.notFound, and NOT the + // top-level NotFound reserved for rs-sdk-ffi codes 7/8 — so #4051's + // raw-code translation stays the single place that turns an + // unmanaged-identity miss into an absence. + assertTrue(error is DashSdkError.PlatformWallet.NotFound) + assertFalse(error is DashSdkError.NotFound) + assertEquals("wallet not found", (error as DashSdkError).message) } } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 2db5e488deb..05a44cdc635 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 @@ -778,6 +779,8 @@ class PlatformWalletPersistenceHandlerTest { publicKeyData: ByteArray, identityIndex: Int, keyIndex: Int, + keyType: Int, + force: Boolean, ): DerivedKeyStoreResult? { calls.add(Triple(walletId, identityIndex, keyIndex)) lastPublicKey = publicKeyData @@ -1010,6 +1013,578 @@ 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, + keyType: Int, + force: Boolean, + ): 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, + keyType: Int, + force: Boolean, + ): 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, + keyType: Int, + force: Boolean, + ): 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()]) + } + + // ── 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()) + } + + /** + * #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()) + 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) + } + + // ── 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, + keyType: 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) + } + + @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 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 00000000000..44697139478 --- /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 00000000000..1d07514cb91 --- /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, + ), + ) + } +} 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 00000000000..e36092414e0 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyTest.kt @@ -0,0 +1,168 @@ +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) + } + + // ── 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 00000000000..55d6b6675f3 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeystoreKeyGenPolicyTest.kt @@ -0,0 +1,202 @@ +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" / 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]). + */ +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 carrying the lock-screen rejection numeric code + // (the real android.security.KeyStoreException exposes it via + // 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 = 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. + // 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") + 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 + 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 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 + 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, even for + // the otherwise-classifying KeyMint code 10309. + val failure = ProviderException( + "unrelated provider issue", + SimulatedNumericKeyStoreException("System error", 10309), + ) + 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"), + ), + ) + } +} 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 00000000000..b75de81a712 --- /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()), + ) + } +} 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 00000000000..6201f107360 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt @@ -0,0 +1,813 @@ +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 +import kotlin.coroutines.cancellation.CancellationException + +/** + * 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)) + } + + /** + * 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 { + 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)) + } + + /** + * #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 + * 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)) + } + + /** + * 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 / + * 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)) + } + + /** + * 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, + * 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 + + /** + * 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 throwInvalidatedOnLegacyRsaDecrypt: 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) + policyFingerprintSuffix 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() + // 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") + } + + 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" + } +} 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 00000000000..dc860ce9c50 --- /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 85553aa1bb5..5c40361e4ef 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) + )); + } } diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 44532de8638..8264048c530 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 1f355e8ef65..b25862f38e8 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 bcd4444c5b7..7118a4624be 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 6eb5dec4277..b32d3d23732 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 7c1fca2a734..4e173a784e6 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 24e42ddf5a2..627de80cf26 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 6e4b7f3adc2..1cf00009b07 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 2bd16e076ae..d53f21dcdd2 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 0eaaf989639..bc955541c76 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 7227fdb2b56..14e3847234e 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 4f469973d0a..fd527b10cb8 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 da1b40b94f5..8a240b10c83 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 f20dba4b560..fb20cf1cda5 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 d010cc1a938..d21a8c1ccf0 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 7cf64398021..4850c08a80d 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 3834ca71b41..3ca87b12fed 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 11705201309..1001224cdd7 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -33,6 +33,49 @@ 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 + // (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() {