Skip to content

feat(kotlin-sdk)!: keystore rework — policy-alias split, layered key recovery, durable repair, structured signer errors (stacked on #4191)#4183

Open
bfoss765 wants to merge 22 commits into
dashpay:v4.1-devfrom
bfoss765:port/v4.1/android-integration
Open

feat(kotlin-sdk)!: keystore rework — policy-alias split, layered key recovery, durable repair, structured signer errors (stacked on #4191)#4183
bfoss765 wants to merge 22 commits into
dashpay:v4.1-devfrom
bfoss765:port/v4.1/android-integration

Conversation

@bfoss765

@bfoss765 bfoss765 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Stacked on #4191 (port/v4.1/unmanaged-identity-reads) — review only the
commits above ce3a17d71f. Once #4191 merges,
git rebase --onto origin/v4.1-dev ce3a17d71f and force-push with
--force-with-lease.

This replaces the old single-squash port/v4.1/android-integration
(preserved at backup/port-4060-pre-rework). That squash predated #4172 and
silently rewrote the exact regions #4172 later hardened; this branch is a
rebuild on the current base with each review finding resolved explicitly.
The unmanaged-identity reads and the text-classified SigningKeyUnavailable
from the old squash shipped separately as #4191 and are NOT duplicated here.

Scope — Keystore only, one logical commit per concern:

  1. Alias split + policyKeySecurityPolicy (AUTH_GATED default /
    DEVICE_BOUND opt-in), two dedicated RSA aliases, legacy KEYS_ALIAS
    read-only; fix(kotlin-sdk): harden identity-key Keystore recovery and reconcile parity docs #4172's fingerprint capture + invalidation recovery survive
    alias-parameterized (finding 1).
  2. Lockless degradation, honest — AUTH_GATED writes on a lockless device
    redirect to the DEVICE_BOUND alias (never a silently gate-less key under
    the auth-gated alias); effectiveKeySecurityPolicy() +
    requireAuthGated strict mode; per-blob alias tag routes reads;
    tightened KeyMint failure classifier (finding 4).
  3. Layered retrieve ladder — legacy shape-dispatch FIRST (the fp gate
    never applies to legacy blobs, finding 2), fingerprint fast path with
    KPIE rethrow-after-generation-checked-cleanup (brick-loop fix, finding 1),
    mismatch/missing fp routes into former-RSA recovery, migration forward.
    Cheap canSignWith never decrypts; the real-decrypt probe moved to
    probeIdentityKeyRecoverability (finding 3).
  4. Pending keys — queryable pendingIdentityKeys StateFlow,
    round-transactional staging.
  5. Durable repair signal — Room 7→8 derivation-breadcrumb columns;
    restart reconstruction incl. blobs stranded by keypair replacement
    (finding 5).
  6. Forced, verified repairreplacePrivateKey + force derive path +
    real-decrypt verification before markIdentityKeyRepaired (finding 6).
  7. ⚠ BREAKING: 98 → PlatformWallet.NotFound (Swift parity; release
    note in the commit).
  8. Structured signer discriminator — typed error_code on the sign
    completion ABI, ErrorSigningKeyUnavailable = 31 (26-30 reserved for
    feat(kotlin-sdk): split build/broadcast with reservation release for BIP70-style deferred submission #4185/fix: shield asset-lock funding from all funds accounts incl. CoinJoin (#4073) #4184), Kotlin/Swift typed mappings; MESSAGE_MARKER sniff retained as a
    deprecated fallback for the fix(kotlin-sdk): unmanaged-identity reads return absence + typed SigningKeyUnavailable (split from #4183) #4191 merge-order transition (mixed
    old-native/new-Kotlin artifacts are unsupported — the completion JNI
    arity changed) (finding 7). Known residual: the
    Rust-internal segment rides a stable machine prefix through
    ProtocolError::Generic — a typed rs-dpp variant was deliberately
    avoided (serialization blast radius).
  9. Instrumented tests adapted to the policy alias (finding 8; the five
    InvalidKeyException failures were the pre-fix(kotlin-sdk): harden identity-key Keystore recovery and reconcile parity docs #4172 emulator-relock flake —
    inherited workflow guard covers them).
  10. Docs — parity manifest/spec entries (alias split N/A for iOS, 98 now
    convergent, pending-repair gap recorded for Swift, code-31 capability),
    migration ladder v8 note.

CI expectations: emulator job runs :sdk:connectedDebugAndroidTest (watch
the deviceLocked=0 guard); swift job rebuilds the xcframework so the
5-arg completion ABI and code 31 compile against fresh headers.


Round-3 addendum: verification passes found and fixed three repair-routing defects (health-probe fingerprint disproof for replaced aliases, typed first-attempt invalidation errors, durable repair seeding for legacy and former-RSA keys — commits 6d1fa5be83, f6fe865e56, 7ee6e4b7f9, ce3490a3a0). Unit suite: 249 passing; instrumented suite runs on this repo's emulator job.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced selectable identity-key security policies (authentication-gated vs device-bound).
    • Added durable “pending identity key” repair tracking and a repair eligibility probe that survives restarts.
    • Added typed “signing key unavailable” reporting for clearer signer failures.
  • Bug Fixes
    • Correctly differentiates missing wallets from unmanaged identity situations.
    • DashPay “managed identity not found” is now treated as an absence (zero-handle) instead of an error.
  • Security
    • Improved key generation/recovery behavior on devices without secure lock-screen support, with safer alias handling.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@bfoss765, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6124ea77-088a-48c6-8d33-202f8a5a955d

📥 Commits

Reviewing files that changed from the base of the PR and between ce3490a and 8313192.

📒 Files selected for processing (10)
  • packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/WalletKeyHealthSheet.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreManager.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt
  • packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt
  • packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeystoreKeyGenPolicyTest.kt
  • packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt
📝 Walkthrough

Walkthrough

Adds policy-based keystore aliases and migration handling, transactional pending identity-key tracking, structured signer errors, typed platform-wallet errors, and managed-identity lookup classification across Kotlin, Rust, and Swift.

Changes

Keystore policy and identity-key recovery

Layer / File(s) Summary
Policy-driven keystore aliases and generation
packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/*, packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyTest.kt, KeystoreKeyGenPolicyTest.kt
Adds AUTH_GATED and DEVICE_BOUND policies, separate RSA aliases, alias-aware cryptography, and lock-screen degradation handling.
Wallet storage recovery and migration
packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt, packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt, packages/kotlin-sdk/KotlinExampleApp/.../WalletKeyHealthSheet.kt
Adds policy wiring, alias-tagged persistence, legacy AES/RSA recovery, conditional migration, and recoverability probing.

Pending identity-key repair state

Layer / File(s) Summary
Transactional pending-key tracking
packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/*, packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt
Records failed derivations, persists derivation breadcrumbs, stages pending-key changes through commits, preserves state on rollback, and reconstructs state after restart.
Pending-key repair exposure
packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt
Exposes pending identity keys and clears repaired entries after forced derivation and recoverability verification.

Signer and platform-wallet error flows

Layer / File(s) Summary
Structured signer completion errors
packages/rs-sdk-ffi/src/signer.rs, packages/rs-unified-sdk-jni/src/signer.rs, packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/{ffi,security}/*, packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift
Adds explicit signer error codes to completion callbacks and propagates signing-key-unavailable classifications across FFI boundaries.
Typed platform-wallet errors
packages/rs-platform-wallet-ffi/src/error.rs, packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt, packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift
Adds typed signing-key-unavailable errors and maps platform-wallet code 98 to wallet-level NotFound.
Managed-identity outcome classification
packages/rs-platform-wallet-ffi/src/dashpay.rs
Separates invalid handles, removed wallets, unmanaged identities, and successful lookups.
DashPay not-found translation
packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/{tokens,wallet,identity}/*, packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/tokens/ManagedIdentityNotFoundTranslationTest.kt
Translates managed-identity not-found results to zero handles for local operations and rethrows unrelated errors.

Schema and parity artifacts

Layer / File(s) Summary
Room schema migration
packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt, packages/kotlin-sdk/sdk/schemas/.../8.json, packages/kotlin-sdk/sdk/src/androidTest/.../DashDatabaseMigrationTest.kt
Moves Room to schema version 8 and adds nullable derivation breadcrumb columns to public_keys.
Parity and migration documentation
docs/sdk/*, docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md, packages/kotlin-sdk/PARITY_SUMMARY.md
Documents the new capability coverage, host differences, schema rollout, and deferred fallback removal.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Signer
  participant KotlinJNI
  participant RustFFI
  participant PlatformWallet
  Signer->>KotlinJNI: completeSign(errorCode, errorMessage)
  KotlinJNI->>RustFFI: forward structured signer error
  RustFFI->>PlatformWallet: map signing-key-unavailable prefix to code 31
  PlatformWallet-->>Signer: return typed platform-wallet error
Loading

Possibly related PRs

  • dashpay/platform#3861: Uses the same identity derivation-index semantics that support pending-repair reconstruction.

Suggested labels: Client Only

Suggested reviewers: quantumexplorer, lklimek, shumkov, llbartekll

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 93.39% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: keystore policy split, key recovery, durable repair, and signer error handling.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added this to the v4.1.0 milestone Jul 21, 2026
@thepastaclaw

thepastaclaw commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

🕓 Ready for review — 15 ahead in queue (commit 8313192)
Queue position: 16/32 · 2 reviews active
ETA: start ~01:01 UTC · complete ~01:25 UTC (median 23m across 30 recent reviews; 2 slots)
Queued 8h ago · Last checked: 2026-07-22 22:10 UTC

@bfoss765

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt`:
- Around line 549-562: Update the migration block around store.edit in
WalletStorage so CancellationException is not swallowed by runCatching; rethrow
cancellation while retaining the existing handling for non-cancellation failures
and successful migration behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 987bcd6c-12ec-4a70-8f81-bdad970986df

📥 Commits

Reviewing files that changed from the base of the PR and between 8b466ab and 00804cd.

📒 Files selected for processing (15)
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicy.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreManager.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt
  • packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt
  • packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt
  • packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeySecurityPolicyTest.kt
  • packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeystoreKeyGenPolicyTest.kt
  • packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt
  • packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/tokens/ManagedIdentityNotFoundTranslationTest.kt
  • packages/rs-platform-wallet-ffi/src/dashpay.rs

@shumkov

shumkov commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

This needs reconciliation with #4172 before it can merge — the branch is CONFLICTING, and the conflicts are semantic, not textual. Concretely:

  • fix(kotlin-sdk): harden identity-key Keystore recovery and reconcile parity docs #4172's KeyPermanentlyInvalidatedException recovery (generation-checked alias deletion) must survive: this PR's decrypt() rewrite lacks it, and with the alias split, repair would re-encrypt under the invalidated pair's still-present cert → permanent brick loop. (Your generateWithLockScreenDegradation actually complements it — both need to survive.)
  • fix(kotlin-sdk): harden identity-key Keystore recovery and reconcile parity docs #4172's fingerprint gate in retrievePrivateKey and this PR's legacy-blob recovery are mutually exclusive as written (legacy blobs by definition fail the fingerprint check). Needs a layered resolution: legacy shape-detect → recovery first; fingerprint fast-path for current blobs.
  • Auto-merge trap: v4.1-dev's canSignWith is contractually cheap/non-mutating on the Rust callback thread; this PR redefines isPrivateKeyDecryptable into a real decrypt probe (RSA op + runBlocking). Keep the cheap check on canSignWith; move the probing version to a key-health method.
  • Instrumented CI is red: storeIfAbsentRederivesWhenTheKeysAliasKeypairWasReplaced still deletes legacy KEYS_ALIAS but writes moved to KEYS_ALIAS_AUTH_GATED; five more failures need triage.
  • Sweep incomplete: ManagedPlatformWallet.kt:549 and IdentityRegistration.kt:93 keep the dead == 0L check — one unmanaged id now throws through the whole listing.

Suggestion: split the unmanaged-identity-reads + typed SigningKeyUnavailable changes (both good, conflict-free, wanted) into their own PR so they aren't blocked on the Keystore rework. Also: the 98→PlatformWallet.NotFound remap is host-breaking and needs a release-note flag; parity docs (#4172's manifest/spec) need updating for the new deliberate divergences.

@shumkov

shumkov commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Additional findings after checking the existing threads and the current #4172 base:

  1. AUTH_GATED silently loses authentication on lockless devices. KeystoreManager drops both lock-bound parameters but continues to use the AUTH_GATED alias/policy. The fallback classifier also accepts any nested KeyStoreException after the generic "key generation failed" marker. Either fail with a typed policy error or explicitly select DEVICE_BOUND; do not describe the resulting key as auth-gated.
  2. The repair signal is not durable. pendingIdentityKeys starts as an empty in-memory MutableStateFlow and is not reconstructed from Room. A normal restart loses the key slot needed for repair while the durable identity remains watch-only.
  3. Repair can report success without replacing the bad blob. storeIfAbsent treats RSA shape plus alias fingerprint as usable without performing a real decrypt. repairIdentityKey can then skip derivation and still call markIdentityKeyRepaired. Repair needs a forced atomic replacement path, followed by a real-decrypt check before clearing pending state.
  4. SigningKeyUnavailable is still text-classified. KeystoreSigner emits a marker and DashSdkError uses message.contains. Please carry a structured signer/native discriminator through Rust/C/JNI and map it in Kotlin and Swift.

I am not repeating the existing rebase/invalidation-recovery and cancellation comments; those remain valid. The earlier canSignWith decrypt concern is resolved at this head, and the legacy-before-fingerprint recovery is already present, so both should simply be preserved during conflict resolution.

bfoss765 and others added 2 commits July 21, 2026 15:04
…otFound

Split out of dashpay#4183 (port of dashpay#4060) per review: the
unmanaged-identity read fixes are conflict-free on v4.1-dev and should
not be blocked on the Keystore rework.

The FFI's blanket Option -> result conversion reports an identity the
wallet does not manage as PlatformWalletFFIResultCode::NotFound (98),
so the Kotlin callers' zero-handle checks were dead and every local
read over an unmanaged identity threw instead of returning absence.

- Dashpay: route getManagedIdentity through
  translateManagedIdentityNotFoundToZero so contacts()/syncState()/
  payments()/sendContactRequest() treat "not managed" as null/empty/
  false; ErrorInvalidHandle still propagates.
- Sweep the remaining dead `== 0L` sites: ManagedPlatformWallet
  .inMemoryIdentityStates (one unmanaged/just-removed id no longer
  throws through the whole listing) and IdentityRegistration
  .contestedDpnsNames (the intended "identity is not managed by this
  wallet" NotFound now actually surfaces).
- platform-wallet-ffi: platform_wallet_get_managed_identity keeps the
  three outcomes distinct (classify_managed_identity_outcome) so a
  stale/removed wallet surfaces ErrorInvalidHandle and never
  masquerades as an unmanaged identity; unit tests pin both arms.
- DashSdkError: name the code (PLATFORM_WALLET_NOT_FOUND_CODE = 98);
  the 98 -> DashSdkError.NotFound mapping is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Split out of dashpay#4183 (port of dashpay#4060) per review: the
typed signing error is conflict-free on v4.1-dev and should not be
blocked on the Keystore rework.

The KeystoreSigner "missing key" completion error travels as free text
through Rust and used to come back as an opaque PlatformWallet.Generic
(or WalletOperation) failure. It is now built from a shared
MESSAGE_MARKER constant and recognized on the Kotlin boundary as the
typed DashSdkError.PlatformWallet.SigningKeyUnavailable, so hosts can
route users to key repair instead of showing a generic error. The
marker is only consulted on the catch-all codes (6 / else), so the
dedicated retry-semantics types are never overridden.

Known limitation (kept as-is from dashpay#4183 by request): the discriminator
is message-text-based (message.contains on the marker); a structured
error code across the FFI boundary is follow-up work in the parent PR
line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bfoss765

Copy link
Copy Markdown
Contributor Author

Took your suggestion: the conflict-free slices are split out as #4191 — unmanaged-identity reads (including both == 0L sites you caught, so one unmanaged id no longer fails a whole listing) plus the typed SigningKeyUnavailable, applied clean on current v4.1-dev with the Keystore rework and the host-breaking 98-remap deliberately excluded. This PR then becomes the Keystore rework only, properly reconciled with #4172's invalidation recovery and fingerprint gate; your four second-pass findings (lockless-device policy, repair durability, forced re-encrypt, structured discriminator) will be addressed there, with the 98→NotFound remap release-note-flagged and the instrumented failures triaged as part of the reconciliation.

bfoss765 and others added 12 commits July 21, 2026 18:02
Introduce KeySecurityPolicy (AUTH_GATED default / DEVICE_BOUND opt-in) and
move new identity-key wrapping off the single KEYS_ALIAS onto two dedicated
RSA aliases, one per policy — Keystore auth parameters are fixed at key
generation, so the policies can never share an alias. The legacy KEYS_ALIAS
becomes read-only: it retains whichever superseded key (auth-gated AES-GCM,
or the pre-split RSA pair) an upgraded install still holds, reachable only
through the decryptLegacy* migration fallbacks.

The dashpay#4172 hardening survives the split alias-parameterized: write-time
fingerprint capture (encryptForIdentityKeysAlias — one lookup for blob +
fingerprint + producing alias), the non-generating fingerprint read, and the
KeyPermanentlyInvalidatedException recovery with its generation-checked,
per-alias deletion (deleteIdentityKeysAliasIfCurrentGeneration, which
refuses the legacy alias by contract).

WalletStorage grows the policy-taking constructor and routes identity-key
writes/reads through the policy alias.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…und alias, honestly

The wallet must work without a screen lock (dashpay#4060), but the
AUTH_GATED authentication gate cannot exist without one — KeyMint rejects
generate_key. Instead of silently generating a gate-less key under the
auth-gated alias (a policy lie), identity-key writes on a lockless device
are redirected to KEYS_ALIAS_DEVICE_BOUND, whose gate-less parameters are
inherent:

- encryptForIdentityKeys resolves the effective write alias (probe first,
  KeyMint safety-net rejection second) and returns the producing alias with
  the blob; WalletStorage records it as a per-blob privkeyalias. tag written
  atomically with the blob + fingerprint, and reads decrypt under the
  RECORDED alias — keys written while lockless stay readable after a lock
  screen is enrolled and new writes move to the gated alias. A missing tag
  means the policy alias (backward compatible with earlier data).
- effectiveKeySecurityPolicy() (KeystoreManager + WalletStorage) surfaces
  the degradation; PlatformWalletManager logs it once at construction.
- Strict mode: KeystoreManager(requireAuthGated = true) throws the new
  KeySecurityPolicyUnavailableException instead of degrading.
- MASTER_ALIAS (AES) and the DEVICE_BOUND alias keep the in-place
  setUnlockedDeviceRequired degradation (no auth gate to lie about); the
  auth-gated alias is never routed through it.
- isNoSecureLockScreenKeyGenFailure is tightened: a nested KeyStoreException
  classifies only inside the key-gen ProviderException's cause chain AND
  with a lock-screen message or the observed rejection's numeric code
  (internal Keystore 4 / KeyMint 10309, read reflectively) — a bare nested
  Keystore error with an unrelated message no longer triggers the retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bing capability split

retrievePrivateKey becomes an explicit three-rung ladder that composes the
dashpay#4172 fingerprint gate with the legacy-blob recovery paths:

1. Legacy AES-GCM blobs shape-dispatch FIRST — the write-time fingerprint
   gate never applies to them (they predate it or carry a superseded key's
   fingerprint); the retained legacy KEYS_ALIAS AES key recovers the value,
   which is migrated to the policy alias.
2. Empty-IV RSA blobs whose stored fingerprint matches the RECORDED alias's
   current key (non-generating read, alias-tag routed) decrypt on the fast
   path. KeyPermanentlyInvalidatedException rethrows after KeystoreManager's
   generation-checked cleanup — after the deletion the fingerprint can no
   longer match, so reads and repair re-DERIVE rather than trusting a stale
   certificate (closes the invalidation brick loop). Other crypto failures
   fall through (defense in depth).
3. A mismatching or MISSING fingerprint is a routing signal into the
   former-KEYS_ALIAS-RSA recovery ladder, not an immediate null — pre-split
   blobs carry the former key's fingerprint and must reach
   decryptLegacyRsaKeysBlob. The policy alias is never touched here, and a
   read never provisions a keypair.

Capability is split per finding 3: isPrivateKeyDecryptable stays the CHEAP
check (presence + fingerprint only — the Rust canSignWith callback thread
must never decrypt, prompt, or generate), extended with presence rungs for
the legacy schemes; the real-decrypt health check moves to the new
probeIdentityKeyRecoverability (DEVICE_BOUND-sibling disproof included,
skipped for blobs the sibling legitimately owns by tag), which the example
app's WalletKeyHealthSheet now calls. UserNotAuthenticatedException
propagates from every rung — a closed auth window is never a wrong key.

WalletStorageUpgradeMatrixTest pins every ladder row through the fake
Keystore seam, including the new invalidation-cleanup, alias-tag-routing,
fast-path-failure, and never-decrypts-on-the-cheap-path rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…key failures

Port of the dashpay#4053 pending-keys machinery: when the identity-key persist
callback's derive/store fails, the key lands in Room watch-only and every
signature with it will fail — previously a single Log.w was the only trace.
PlatformWalletPersistenceHandler now records a PendingIdentityKey entry
(wallet/identity/keyId, derivation breadcrumbs, reason, timestamp) in a
StateFlow keyed by public-key hex, exposed through
PlatformWalletManager.pendingIdentityKeys so hosts can surface a repair path.

Transactional with the changeset round (finding de3cf44a71fc): while a store
round is open, record/clear mutations are staged as pure map deltas in the
round's ChangesetBuffer and published in ONE atomic MutableStateFlow.update
only after the Room transaction commits; a rolled-back or aborted round
discards them with the buffer, leaving the pre-round map untouched.
Standalone upserts publish immediately, as does markIdentityKeyRepaired —
which repairIdentityKey now calls on success, since the repair stores the
scalar through the deriver and bypasses the only persist path that clears
pending entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rt (Room 7→8)

pendingIdentityKeys was process-lifetime only: the durable public_keys row
kept just privateKeyKeychainIdentifier = null, dropping the derivation
breadcrumbs a repair needs — kill the app and the repair signal was gone.

- Room migration 7→8 adds nullable public_keys.derivationIdentityIndex /
  derivationKeyIndex. Room is the durability substrate deliberately: the
  wallet-deletion cascade removes these rows, so pending entries die with
  their wallet automatically (a DataStore side-table would leak).
- onPersistIdentityKeyUpsert records the breadcrumbs whenever Rust supplied
  derivation indices — success AND failure paths (the breadcrumb is not a
  failure marker; the null identifier is).
- reconstructPendingIdentityKeysFromPersistence rebuilds the map on launch:
  a breadcrumbed, non-read-only row re-seeds pending when its identifier is
  null OR the stored blob fails the CHEAP capability check — the second
  disjunct resurrects repair slots for blobs stranded by a Keystore keypair
  replacement, not just never-derived keys. One atomic update, live entries
  never overwritten, reason = "reconstructed from persistence after
  restart". PlatformWalletManager.loadPersistedWallets runs it before the
  manager is handed to the host.
- DashDatabaseMigrationTest gains the 7→8 case (NULL for pre-existing rows,
  explicit values accepted); the 4→latest and 1→latest chains extend to 8;
  schema 8.json exported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
repairIdentityKey previously routed through storeIfAbsent, whose
shape+fingerprint usability short-circuit can be satisfied by a blob that
does not actually decrypt — the derive was skipped and
markIdentityKeyRepaired fired anyway, clearing the pending signal while the
key stayed broken (dashpay#4060 finding 6).

- WalletStorage.replacePrivateKey: forced store with the same
  outside-the-lock derive discipline (and tombstone checks) as storeIfAbsent,
  but on re-entry it UNCONDITIONALLY replaces blob + fingerprint + alias tag
  in one atomic edit — never the addOwnerIfUsableLocked short-circuit.
- PrivateKeyDeriver.deriveAndStore gains force: Boolean = false;
  IdentityKeyPrivateKeyDeriver routes force → replacePrivateKey. The
  persistence-callback call site keeps force = false (idempotent upserts
  must not re-derive on every sync).
- PlatformWalletManager.repairIdentityKey: deriveAndStore(force = true),
  then VERIFY with the real-decrypt probeIdentityKeyRecoverability before
  clearing pending state — a wrong-key crypto failure fails the repair with
  a typed PlatformWallet.SigningKeyUnavailable
  (UserNotAuthenticatedException counts as verified: the key is present and
  opens after auth, and the just-written fingerprint rules out the
  wrong-key-behind-locked-gate ambiguity). Only after verification:
  markIdentityKeyRepaired + the Room rows' privateKeyKeychainIdentifier
  update, so the durable pending-repair reconstruction does not resurrect
  the repaired key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t.NotFound

PlatformWalletFFIResultCode::NotFound (98) — the blanket Option→result miss
(unknown wallet id, unmanaged identity, …) — previously collapsed into the
top-level DashSdkError.NotFound shared with rs-sdk-ffi codes 7/8. It now
maps to the new DashSdkError.PlatformWallet.NotFound, keeping 98 inside the
wallet-error family in exact parity with Swift's
PlatformWalletError.notFound, so callers can match a wallet-level absence
without sniffing Generic codes.

BREAKING: Kotlin hosts catching DashSdkError.NotFound from platform-wallet
operations (e.g. rescanSpvFilters with an unknown wallet id) now receive
DashSdkError.PlatformWallet.NotFound; rs-sdk-ffi codes 7/8 still map to the
top-level NotFound. Dashpay's managed-identity local reads are unaffected —
translateManagedIdentityNotFoundToZero intercepts the RAW code (dashpay#4051)
before this mapping ever runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…the signer wire

Replace end-to-end message sniffing for the signer's "missing key" failure
with a typed discriminator (dashpay#4060 finding 7):

- rs-sdk-ffi: DashSDKSignerErrorCode { Generic = 0, SigningKeyUnavailable =
  1, AuthenticationFailed = 2 (reserved) }; SignCompletionCallback and
  dash_sdk_sign_async_completion gain error_code: i32 (before
  error_message). SignResult stays Result<Vec<u8>, ProtocolError> (a new
  rs-dpp ProtocolError variant would carry serialization blast radius), so
  code 1 rides the single Rust-owned machine prefix
  DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX through
  ProtocolError::Generic — typed at both ABI edges, one constant bridging
  the string segment. This is an internal coordinated ABI change: every
  piece versions together in this monorepo.
- rs-platform-wallet-ffi: PlatformWalletFFIResultCode::
  ErrorSigningKeyUnavailable = 31 (codes 26-28 are reserved for dashpay#4185's
  reservation-token errors and 29/30 for dashpay#4184's asset-lock errors on
  sibling branches — documented in the enum as dashpay#4184 does). The
  From<dpp::ProtocolError> conversion restores the typed code from the
  prefix FIRST (before the loose keyword sniffs), and the
  From<PlatformWalletError> blanket impl restores it on the catch-all only
  (dedicated retry-semantics codes are never overridden) — covering the
  Sdk(dash_sdk::Error::Protocol(..)) wrapping path.
- JNI/Kotlin: SignerNative.completeSign(token, signature, errorCode,
  errorMessage); KeystoreSigner passes SIGNER_ERROR_CODE_KEY_UNAVAILABLE on
  the null-key branch (keeping the MESSAGE_MARKER text for the transition
  window) and Generic everywhere else. DashSdkError maps 31 →
  PlatformWallet.SigningKeyUnavailable; the dashpay#4191 marker sniff on the
  catch-all codes remains as a deprecated old-native fallback with a
  removal note tied to the next minor release.
- Swift: KeychainSigner trampolines forward the code (missing-row /
  missing-scalar outcomes classify as 1); PlatformWalletResultCode gains
  errorSigningKeyUnavailable = 31 → PlatformWalletError
  .signingKeyUnavailable (Kotlin parity).
- Tests: rs-sdk-ffi completion-code tests (prefix present for code 1,
  absent for generic), platform-wallet-ffi prefix→31 tests on both
  conversion points, Kotlin code-31 + fallback-marker tests, Swift mapping
  and trampoline-classifier tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…as split

The inherited dashpay#4172 instrumented tests hardcode the legacy KEYS_ALIAS and
the single-alias API; after the alias split the default-policy storage
writes under KEYS_ALIAS_AUTH_GATED, so simulating keypair replacement by
deleting the legacy alias no longer invalidates anything (the CI failure
shumkov diagnosed on storeIfAbsentRederivesWhenTheKeysAliasKeypairWasReplaced).

- WalletStorageOwnershipTest: the replaced-keypair, rejected-blob, and
  no-regeneration probes delete/inspect KEYS_ALIAS_AUTH_GATED; the
  fingerprint-capture test uses encryptForIdentityKeysAlias (and pins the
  captured producing alias); the stale-invalidation-cleanup test uses the
  alias-parameterized deleteIdentityKeysAliasIfCurrentGeneration.
- KeystoreSignerInstrumentedTest: canSignWith rejection simulates
  replacement of the policy alias.

The five DashPayUnlockAndSyncTest / WalletManagerRoundTripTest failures
from PR dashpay#4183's CI run were NOT test or SDK defects: the branch predated
dashpay#4172's workflow hardening (screen_off_timeout / stayon / dismiss-keyguard
+ the hard deviceLocked=0 guard), so the emulator re-locked mid-run and
every MASTER_ALIAS operation threw InvalidKeyException. Rebuilding on the
current base inherits the fixed workflow; those tests are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ity, and Room v8 shift

- sdk-parity-manifest.json (+ regenerated PARITY_SUMMARY.md): new capability
  entries for the KeySecurityPolicy alias split (Swift not-applicable — iOS
  Keychain has no per-alias auth-parameter analog), the now-CONVERGENT
  platform-wallet code-98 typed mapping (was a divergence), the Kotlin-only
  durable pending-repair surface (Swift gap recorded), and the structured
  SigningKeyUnavailable discriminator (both hosts, unit-verification
  pointers on each). shared_symbols gains dash_sdk_sign_async_completion.
- KOTLIN_SWIFT_SHARED_PARITY_SPEC.md: rationale for each divergence /
  convergence, the deprecated MESSAGE_MARKER fallback with its
  next-minor-release removal horizon, and the migration ladder update (v8 =
  derivation breadcrumbs; invitations shift to v9).
- KOTLIN_MIGRATION_LEFTOVERS.md: marker-fallback removal and the on-device
  KeyPermanentlyInvalidatedException residual (unit-tier only via the fake
  seam, same as dashpay#4172).
- Swift: testPlatformWalletNotFoundFFIResultMapping pins the convergent 98
  mapping the manifest references.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…closed auth window as recoverable

probeIdentityKeyRecoverability treated UserNotAuthenticatedException as
"recoverable" unconditionally. UNAE is thrown at cipher.init — before the
ciphertext is examined — so after a Keystore loss + regeneration the fresh
auth-gated key (whose window is closed in the steady state; it is only open
~30 s after an auth) reported a blob it can never open as healthy: the
key-health sheet offered no repair while pendingIdentityKeys simultaneously
listed the same key (dashpay#4060 round-2 finding 1).

Prompt-free fix: UNAE counts as recoverable ONLY while the blob's stored
write-time fingerprint still matches the recorded alias's CURRENT key
(non-generating read). A mismatch disproves ownership → not recoverable →
repair is offered; a fingerprint-matched blob behind a closed window stays
recoverable (genuinely just needs auth). The legacy former-RSA rung keeps
the documented residual (no fingerprint surface for the retained legacy
key). New matrix rows pin both directions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ped signer code on the first attempt

A KPIE escaping the sign path (retrieveKeyWithAuth — the biometric-gate
retry only handles UserNotAuthenticatedException) previously fell into the
signAsync catch-all and completed with SIGNER_ERROR_CODE_GENERIC: the host
saw an opaque wallet-operation failure on the FIRST post-invalidation sign
and only got the typed SigningKeyUnavailable on subsequent attempts (once
the fingerprint gate reported the blob non-current). dashpay#4060 round-2
finding 2.

- signWithStoredKey catches KPIE explicitly and completes with
  SIGNER_ERROR_CODE_KEY_UNAVAILABLE (marker text + invalidation reason kept
  in the message), so the typed code rides the wire immediately.
- The signAsync catch-all classifies via the new pure
  completionErrorCodeFor(t) (KPIE → key-unavailable, everything else
  generic) as belt-and-braces for KPIE thrown outside the retrieval call.
- KeystoreSignerCompletionCodeTest pins the classification (the full signer
  cannot be constructed on the JVM — its constructor creates a native
  handle — so the classification is a pure companion function).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bfoss765 and others added 3 commits July 21, 2026 19:09
… for legacy-alias keys

A KeyPermanentlyInvalidatedException on a LEGACY-alias-backed key was
invisible everywhere: the legacy Keystore aliases are read-only (no
deletion boundary), so hasLegacyKeysKey() stays true forever — canSignWith
keeps offering the key, the cheap capability check keeps reporting the blob
usable, the restart reconstruction never seeds a repair slot, and every
sign fails opaquely (dashpay#4060 round-2 finding 3).

Fix without making the cheap check expensive (it is untouched): the sign
path's KPIE classification now invokes an onSigningKeyInvalidated hook,
wired by PlatformWalletManager to
PlatformWalletPersistenceHandler.recordSigningKeyInvalidated, which nulls
privateKeyKeychainIdentifier on the key's Room rows — the same durable
signal the restart reconstruction reads — and re-runs the reconstruction so
pendingIdentityKeys seeds after the FIRST failed sign (reason: "signing key
permanently invalidated"), making repair reachable outside the health
sheet. Best-effort: bookkeeping failure never eats the typed completion.
Harmless for policy-alias keys (their generation-checked deletion already
flips the fingerprint gate; this just accelerates the in-process seed).
Pre-v8 rows without breadcrumbs still get the identifier null-out and seed
once the next persist round back-fills their breadcrumbs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g-2 TOCTOU window

Round-2 nits (dashpay#4060):

- The MESSAGE_MARKER fallback's stated rationale — "old native library +
  new Kotlin partial builds" — was not viable: the sign-completion JNI
  arity change (3→4 args) makes that pairing a type-confused native call on
  every completion, so mixed artifacts are unsupported outright. The
  fallback's real purpose is the dashpay#4191 merge-order transition (whose
  marker-based classification predates the typed code 31) plus defense in
  depth for conversion paths that lose the machine prefix. KDoc/comments in
  DashSdkError, KeystoreSigner, DashSdkErrorTest, and the parity/leftovers
  docs now say so; removal horizon unchanged (next minor release).
- retrievePrivateKey rung 2 gains a comment naming the accepted
  fingerprint-read→decrypt TOCTOU window (pre-existing dashpay#4172 parity): a
  concurrent rotation fails as a wrong-key crypto error into the recovery
  ladder — never stale plaintext. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…former-RSA rung

Round-3 verification finding: tryFormerRsaRecovery collapsed every
GeneralSecurityException to null ('not this key'), which swallowed a
KPIE from an invalidated pre-alias-split key — the signer's classifier
never saw it, so the typed completion and the durable repair seeding
(finding-3 hook) never fired for legacy blobs. KPIE now propagates like
UserNotAuthenticatedException, with a matrix row pinning the rethrow and
the post-regen recovery path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bfoss765
bfoss765 force-pushed the port/v4.1/android-integration branch from 00804cd to ce3490a Compare July 21, 2026 23:26
@bfoss765 bfoss765 changed the title fix(kotlin-sdk): android host-app integration fixes (key security policy, unmanaged-identity reads, typed signing error) feat(kotlin-sdk)!: Keystore rework — policy-alias split, layered key recovery, durable repair, structured signer errors (stacked on #4191) Jul 21, 2026
@bfoss765 bfoss765 changed the title feat(kotlin-sdk)!: Keystore rework — policy-alias split, layered key recovery, durable repair, structured signer errors (stacked on #4191) feat(kotlin-sdk)!: keystore rework — policy-alias split, layered key recovery, durable repair, structured signer errors (stacked on #4191) Jul 21, 2026
@shumkov

shumkov commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Consolidated re-review of the rework (two independent passes). First the good news: all seven previously-blocking items are genuinely resolved — invalidated-key cleanup survives with generation-checked deletion, the retrieve ladder layers legacy shape-dispatch before the fingerprint gate, canSignWith is back to presence/fingerprint-only, the instrumented suite is retargeted and green, the #4191 stacking is clean, lockless devices get an honest effectiveKeySecurityPolicy(), and the typed code-31 signer error is a real upgrade.

However, the rework surfaces new blocking findings:

  1. Repair can persist the wrong private key. The repair path derives from the caller-supplied indices, and the example app passes keyIndex = key.keyId (the DPP key id) — even though PublicKeyEntity carries derivationIdentityIndex/derivationKeyIndex breadcrumbs for exactly this purpose. repairIdentityKey's verification is probeIdentityKeyRecoverability, which proves the stored blob decrypts — not that the derived key matches publicKeyData. A wrong index derives a different valid scalar that round-trips fine, clears pending state, and persists an unusable key. Fix: repair from the persisted derivation indices and verify derived-public-key equality against publicKeyData before persistence and state-clear. (WalletKeyHealthSheet.kt:173-178, PlatformWalletManager.kt:482-540, IdentityKeyPrivateKeyDeriver.kt:48-100)

  2. KeyMint failure classifier is too broad. isNoSecureLockScreenKeyGenFailure path A classifies any KeyStoreException whose message contains generate_key (KeystoreManager.kt:874-910) — a transient KeyMint generation failure on a device with a lock screen silently and permanently downgrades AUTH_GATED to DEVICE_BOUND. Narrow to the authoritative lock-screen signal (numeric code / "lock screen" text) and drop the bare generate_key match.

  3. Durable repair state fails open. In repairIdentityKey, the Room privateKeyKeychainIdentifier update is wrapped in runCatching (swallowed on failure) while markIdentityKeyRepaired clears live pending state regardless — a failed durable write resurrects the repair after restart while the session believes it's done. Make the durable update fail closed.

  4. The existing unresolved thread on CancellationException being caught and suppressed in the WalletStorage migration remains valid.

  5. Minor: DEVICE_BOUND documentation promises hardware-backed storage while the implementation permits the software AndroidKeyStore fallback — align the docs.

bfoss765 and others added 5 commits July 22, 2026 09:32
…s and verify the pubkey before persisting

Blocker 1 (shumkov): the repair path derived from caller-supplied indices
(the example app passed the DPP key id as keyIndex), and its only check was
probeIdentityKeyRecoverability — which proves the stored blob DECRYPTS, not
that the derived key is the RIGHT one. A wrong index derives a different
valid scalar that round-trips fine, clears pending state, and persists an
unusable key.

Fix:
- IdentityKeyPrivateKeyDeriver: on the force (repair) path derive the KEYPAIR
  and verify the derived public key equals publicKeyData BEFORE any store;
  a mismatch throws IdentityKeyDerivationMismatchException without persisting.
- PlatformWalletPersistenceHandler.repairIdentityKeyDurably: new orchestration
  that reads the derivation slot from the PERSISTED public_keys breadcrumbs
  (derivationIdentityIndex/derivationKeyIndex), never a caller index; a row
  without breadcrumbs fails the repair without clearing pending. Hoisted here
  (not the JVM-unconstructable manager) so it is unit-testable and shares the
  authoritative pendingIdentityKeys state.
- PlatformWalletManager.repairIdentityKey: drop the identityIndex/keyIndex
  params and delegate; supply only the wallet-scoped probe.
- WalletKeyHealthSheet: stop passing report.identityIndex / key.keyId.

Tests (PlatformWalletPersistenceHandlerTest): correct breadcrumbs derive,
verify, clear pending, and record the durable identifier (deriver called with
the persisted 3/5, not a caller index); mismatched breadcrumbs are rejected
(mismatch exception), nothing persisted, blob never probed, pending intact;
a row without breadcrumbs fails with SigningKeyUnavailable and never derives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Blocker 3 (shumkov): in the repair path the Room privateKeyKeychainIdentifier
update was wrapped in runCatching (swallowed on failure) while the pending
state was cleared regardless. A failed durable write then resurrected the
repair after restart (the reconstruction reads the persisted rows) while the
live session believed it was done — session and restart disagreed.

Fix: the durable write in repairIdentityKeyDurably now fails CLOSED — it runs
through an injectable persistDurableIdentifier seam (default: the production
public_keys write) and its exception propagates; markIdentityKeyRepaired only
runs after the write commits. A failed durable write leaves pending intact and
the repair retryable, so the session and a restart agree.

Test: a repair whose derive + verify succeed but whose durable write throws
propagates the failure and leaves the pending entry in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…horitative signals

Blocker 2 (shumkov): isNoSecureLockScreenKeyGenFailure classified any
KeyStoreException whose message contained "generate_key" as a lock-screen
rejection. But "generate_key" names the failing operation, not its cause — a
transient KeyMint generation failure on a device WITH a lock screen carries it
too, so a transient failure would silently and PERMANENTLY downgrade an
AUTH_GATED key to DEVICE_BOUND instead of retrying.

Fix: drop the bare "generate_key" substring match. Classification now rests
only on the two authoritative signals — explicit lock-screen text, or the
lock-screen rejection numeric code (internal Keystore 4 / KeyMint 10309, which
the real android.security.KeyStoreException exposes via getNumericErrorCode()).

Tests: the on-device shape now classifies via the numeric code (not the
incidental generate_key text); two new negatives — a bare generate_key failure,
and a transient generate_key failure under a key-gen ProviderException with a
non-lock-screen numeric code — must NOT classify, so AUTH_GATED is retried, not
downgraded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…olicy-alias migration

Finding 4 (shumkov): migrateToPolicyAlias wrapped its encrypt + DataStore edit
in runCatching, which swallows kotlin CancellationException along with genuine
rewrite failures. A coroutine cancelled during the migration's suspend points
was silently absorbed instead of unwinding — a structured-concurrency
violation.

Fix: replace runCatching with an explicit try/catch that rethrows
CancellationException; only genuine rewrite failures stay best-effort (the
recovered value is still returned; migration retries on the next read).

Test: a cancellation landing at the migration encrypt (via the fake keystore's
onNextPolicyEncrypt hook) now propagates out of retrievePrivateKey rather than
being swallowed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fallback

Finding 5 (shumkov): the DEVICE_BOUND documentation promised hardware-backed
storage while the implementation permits a software AndroidKeyStore fallback
(generation prefers StrongBox, falls back to the TEE, and finally to a
software-backed AndroidKeyStore key on devices with no secure element —
generateWithLockScreenDegradation never fails generation on a missing one).

Align the docs: "device-bound" means non-exportable AndroidKeyStore, not a
hardware-storage guarantee; backing is hardware-isolated only where the device
provides it. AUTH_GATED shares the same backing characteristics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bfoss765

Copy link
Copy Markdown
Contributor Author

All five new findings addressed (255 unit tests, 0 failures):

  1. Repair-key correctness — repair now derives from the persisted derivationIdentityIndex/derivationKeyIndex breadcrumbs (never caller-supplied indices) and verifies the derived public key equals publicKeyData before any store or state-clear; a mismatch throws IdentityKeyDerivationMismatchException and leaves pending intact. The example app no longer passes key.keyId as the index.
  2. Classifier — dropped the bare generate_key substring match; lockless classification now rests only on the authoritative lock-screen signal (text or numeric code 4/10309), so a transient KeyMint failure on a lock-screen device no longer permanently downgrades AUTH_GATED→DEVICE_BOUND.
  3. Durable repair fails closed — the privateKeyKeychainIdentifier write propagates on failure and markIdentityKeyRepaired runs only after it commits, so a failed write leaves pending intact and self-heals on restart.
  4. Cancellation — the WalletStorage migration rethrows CancellationException; only genuine rewrite failures stay best-effort.
  5. DEVICE_BOUND docs — corrected to document the StrongBox→TEE→software-AndroidKeyStore fallback rather than promising hardware backing.

Two honest residuals, both fail-closed (neither can persist a wrong key): the real deriver's FFI pubkey-equality is exercised on-device rather than in a JVM unit test (AndroidKeyStore crypto can't run on the JVM) — it's in our device test plan; and on API 29–32 the numeric error code isn't available, so an exotic "reports-secure-but-rejects" OEM quirk on old Android wouldn't be text-classified, though genuine lockless devices are still caught by the primary isDeviceSecure() probe.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants