Skip to content

fix(kotlin-sdk): degrade mnemonic storage off lock binding on false-locked devices - #4643

Open
HashEngineering wants to merge 1 commit into
v4.2-devfrom
fix/kotlin-sdk-false-locked-degradation
Open

fix(kotlin-sdk): degrade mnemonic storage off lock binding on false-locked devices#4643
HashEngineering wants to merge 1 commit into
v4.2-devfrom
fix/kotlin-sdk-false-locked-degradation

Conversation

@HashEngineering

@HashEngineering HashEngineering commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Issue being fixed or feature implemented

Wallet creation fails permanently on some OEM Android builds, and no amount of retrying fixes it.

Those builds perform unlocks that never satisfy the Keystore's UNLOCKED_DEVICE_REQUIRED gate, so a key created with setUnlockedDeviceRequired(true) stays denied for the entire unlock session — while KeyguardManager cheerfully reports the device unlocked. WalletStorage.storeMnemonic's bounded false-locked retry was built for the transient Keystore2 blip and cannot outwait a device that is simply never going to satisfy the gate, so createWallet fails and the wallet is unusable.

Same mechanism as Google Issue Tracker 506989112 (reported there on Fairphone). Related: #4060, which added a no-lock-screen downgrade — this is the same downgrade, driven by operational evidence rather than by the absence of a lock screen.

Observed in the field on a HONOR PTP-N49 (MagicOS, Android 16, security patch 2026-07-01): 23 consecutive KeystoreDeviceLockedException denials on 'encrypt'/'createWallet' against the lock-bound master alias across a full day, and not one successful bind.

What was done?

Added a last rung to the existing retry ladder in WalletStorage: when the false-locked retry schedule exhausts and the device is still false-locked, treat that device's UNLOCKED_DEVICE_REQUIRED implementation as defective and stop relying on it.

  • KeystoreManager.MASTER_ALIAS_UNBOUND (org.dashfoundation.wallet.master.unbound) — same hardware-backed, non-auth AES-256-GCM key as the master alias, but setUnlockedDeviceRequired is never applied to it.
  • WalletStorage.healFalseLockedMnemonicStore / storeMnemonicUnbound — on exhaustion, store under the unbound alias and record the defect durably in the same atomic edit, so a crash cannot leave the two disagreeing.
  • Reads route per blob, by a recorded mnemonicalias.<walletIdHex> tag, following the existing privkeyalias discipline. No global "which alias are we on" flag to get out of step.
  • WalletStorage.rewrapMnemonicUnbound — pre-existing lock-bound blobs are re-wrapped onto the unbound alias best-effort on their first successful read. A failed re-wrap leaves the blob readable where it is.
  • PlatformWalletManager.ensureMasterKeyNotLockBlocked became suspend and stops probing once the defect is on record; isMasterKeyLockBindingDefectObserved() exposes the state.

What this deliberately does not do:

  • Nothing is ever deleted or re-keyed — no seed is at risk on the degradation path.
  • Genuinely-locked denials still fail fast. The degradation is reached only from the false-locked classification, after the retry schedule is spent.
  • The auth-gated identity aliases are untouched; this concerns the mnemonic master alias only.
  • Healthy devices never provision the unbound alias, so there is no security regression for the overwhelming majority of users.

How Has This Been Tested?

packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageDeviceLockedRetryTest.kt — extended to 17 tests covering the pre-check matrix (genuinely locked, unlocked, keyguard-showing-but-not-secure, locked-but-not-lock-bound, locked-with-defect-on-record), the retry path, and the new degradation: exhaustion → unbound alias, straight-to-unbound once recorded, original denial propagated when the degradation encrypt also fails, re-wrap on first successful read, and blob still readable when re-wrap fails. Four tests assert the mnemonic buffer is scrubbed on every exit — success, degraded store, final denial, and cancellation during retry backoff.

cd packages/kotlin-sdk && ./gradlew :sdk:testDebugUnitTest

824 tests, 0 failures (JDK 17 required; the module does not build on 11).

Device-verified on the affected hardware. Same HONOR PTP-N49, same starting state (restored 0 wallet(s), so createWalletencrypt on the lock-bound master alias):

build denials bind
without this change 23 KeystoreDeviceLockedException, 07:07→18:43 never succeeded — no bound to line in the log
with this change 0 app wallet bound to new SDK wallet ~1 s after SDK start

Two honest limits on that evidence:

  • The denial is lock-state dependent, and it is not known whether the screen was off during the verification run. The pre-change 23 denials spanned a whole day, so this is strong but not airtight; a deliberate screen-off run would close it.
  • Only the create/encrypt half is proven on real hardware. That device had never managed to persist an SDK wallet (its report lists no dash-sdk.db, because the pre-change runs never got far enough to create the store), so the decrypt path on an existing lock-bound alias — and therefore the re-wrap-on-first-read path — is covered by unit tests only.

Emulators cannot reproduce the defect at all: AOSP classifies every denial as genuinely locked, so the false-locked branch is unreachable there.

Breaking Changes

None. PlatformWalletManager.ensureMasterKeyNotLockBlocked became suspend, but it is internal to the SDK and has no external callers.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

Summary by CodeRabbit

  • New Features

    • Wallet mnemonic storage now recovers from persistent device-locking defects by using a fallback encryption path.
    • Existing encrypted mnemonics can be read and upgraded automatically to the fallback protection.
    • Wallet operations continue normally after a device-locking defect has been detected.
  • Documentation

    • Added guidance describing fallback behavior and device-specific false-locking scenarios.
  • Tests

    • Expanded coverage for fallback encryption, recovery, migration, retries, and failure handling.

…ocked devices

Some OEM builds (HONOR/MagicOS Android 16 in the field; same mechanism as
Google Issue Tracker 506989112 on Fairphone) perform unlocks that never
satisfy the Keystore's UNLOCKED_DEVICE_REQUIRED gate, so the lock-bound
master-alias key stays denied for the whole unlock session while
KeyguardManager reports the device unlocked. storeMnemonic's bounded
false-locked retry (built for the transient Keystore2 blip) can never
outwait that, so wallet creation was unfixably failing on those devices.

Add a last rung to the ladder: when the retry schedule exhausts still
false-locked, treat the device's UNLOCKED_DEVICE_REQUIRED implementation
as defective and store under a new never-lock-bound alias
(MASTER_ALIAS_UNBOUND — same hardware-backed non-auth AES-256-GCM, no
setUnlockedDeviceRequired ever), recording the defect durably in the same
atomic edit. From then on mnemonic writes go straight to the unbound
alias, the createWallet preflight stops probing, reads route by the
blob's recorded alias (mnemonicalias.<walletIdHex>, the privkeyalias
discipline), and pre-existing lock-bound blobs are re-wrapped
best-effort on their first successful read. Nothing is ever deleted or
re-keyed, genuinely-locked denials keep failing fast, the auth-gated
identity aliases are untouched, and healthy devices never provision the
new alias — this is the #4060 no-lock-screen downgrade
driven by operational evidence instead of a missing lock screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added this to the v4.2.0 milestone Sep 9, 2026
@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 6d71f6a3-2da1-43a6-8916-3ccdb359f618

📥 Commits

Reviewing files that changed from the base of the PR and between a4bb6a6 and c574e29.

📒 Files selected for processing (5)
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreDeviceLockedException.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/security/WalletStorageDeviceLockedRetryTest.kt

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Wallet mnemonic storage now detects persistent false-locked Keystore failures, stores affected mnemonics with a never-lock-bound alias, records the producing alias, and rewraps legacy blobs when possible. Tests cover retries, fallback behavior, reads, failures, and buffer scrubbing.

Changes

False-locked Keystore handling

Layer / File(s) Summary
Unbound master alias
packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreManager.kt, packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreDeviceLockedException.kt
Defines MASTER_ALIAS_UNBOUND and provisions it without setUnlockedDeviceRequired, while documenting the persistent false-locked failure path.
Mnemonic degradation and alias routing
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
Records the device defect, retries false-locked writes, falls back to the unbound alias, routes reads by stored alias, rewraps legacy blobs, and updates wallet-creation documentation.
Degradation behavior validation
packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageDeviceLockedRetryTest.kt
Tests retry limits, fallback writes, durable defect state, alias-routed reads, rewrapping, failure propagation, and plaintext scrubbing.

Priority: ⬆️ High

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: ⚪ Minimal · up to c574e

The change restores wallet creation on affected OEM devices while preserving locked-device failures and healthy-device behavior. The expanded Kotlin tests pass, and no merge-blocking risk is identified.

Suggested reviewers: bfoss765

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.89% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 36 functions across 5 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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 and concisely describes the main change: degrading mnemonic storage when devices report a false-locked Keystore state.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/kotlin-sdk-false-locked-degradation

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.

@HashEngineering HashEngineering changed the title fix(sdk): degrade mnemonic storage off lock binding on false-locked devices fix(kotlin-sdk): degrade mnemonic storage off lock binding on false-locked devices Sep 9, 2026
@thepastaclaw

thepastaclaw commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

⛔ Final review complete — 1 blocking finding(s) (commit c574e29) · triage: critical · Phase 2 only (queue backlog)

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Final validation — Phase 2 only (queue backlog)

The fallback alias and durable defect marker are implemented coherently, and the retry/degradation paths have substantial coverage. However, the opportunistic re-wrap adds an unsynchronized write to the read path, allowing a deleted or concurrently replaced mnemonic to be restored. The change also modifies a public non-suspending method into a suspending method despite the PR declaring that there are no breaking changes; cancellation during re-wrap additionally leaves plaintext unsanitized.

Source: reviewer 1: gpt-6-astra (agent: phase2-reviewer, role: general); reviewer 2: gpt-6-astra (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6-astra (agent: astra-verifier, role: final-verifier)

Review provenance

  • Triage: critical by gpt-6-astra (effort low) — This is a substantial security-sensitive storage and cryptographic key-management change that alters mnemonic encryption, alias selection, durable migration state, atomic persistence, and retry behavior, where regressions could affect wallet availability or seed protection.
  • Phase 1 reviewers: not run (skipped for throughput: 17 PRs queued, above the 10 limit)
  • Fresh verifier: gpt-6-astra — final-verifier; agent astra-verifier
  • Phase 2 reviewers: gpt-6-astra — general (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — security-auditor (completed, effort xhigh); agent phase2-reviewer

🔴 1 blocking | 🟡 2 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt`:
- [BLOCKING] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt:481-483: Re-wrap can resurrect a mnemonic after deletion
  `retrieveMnemonicUtf8` reads the mnemonic and alias from one DataStore snapshot, decrypts the plaintext, and then performs a separate `store.edit` through `rewrapMnemonicUnbound`. If `deleteMnemonic(walletId)` completes after the snapshot/decrypt but before that edit, the re-wrap writes the old ciphertext and alias back into the store, resurrecting a mnemonic that was just deleted. The same race can overwrite a newer mnemonic written concurrently. The atomicity of the re-wrap edit does not protect the read-to-write interval because the edit is not conditional on the original blob and alias still being present. Serialize re-wraps with mnemonic writes/deletes or perform a compare-and-set edit that only replaces the entry when the original encoded blob and alias still match.
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt:481-484: Cancellation during re-wrap can leave decrypted mnemonic bytes unsanitized
  On the defect path, `plain` is decrypted and passed to `rewrapMnemonicUnbound` before ownership is returned to the caller. That function deliberately rethrows `CancellationException`; if cancellation occurs during the suspending `store.edit`, `retrieveMnemonicUtf8` exits without returning `plain`, so the caller cannot scrub it. The same ownership problem applies to an unexpected throwable from the re-wrap. Clear the plaintext buffer before propagating any re-wrap failure that prevents returning it, while preserving the existing best-effort behavior for ordinary re-wrap errors.
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt:250: Changing this public method to suspend is a breaking API change
  `WalletStorage` is a public class and `ensureMasterKeyNotLockBlocked` is declared as a public method without an `internal` modifier. The base API exposed a regular `fun`; changing it to `suspend fun` requires source callers to be inside a coroutine and changes the generated JVM-facing method shape. The PR's assertion that the change is internal and has no external callers is not sufficient to preserve compatibility for consumers that access the public `WalletStorage` API. Preserve the existing public method with a blocking-free wrapper/alternative, introduce a separate suspending API while retaining the old signature, or explicitly treat and document this as a breaking API change.

Comment on lines +481 to +483
if (alias == KeystoreManager.MASTER_ALIAS && prefs[MASTER_LOCK_DEFECT_KEY] == true) {
rewrapMnemonicUnbound(walletId, plain)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Re-wrap can resurrect a mnemonic after deletion

retrieveMnemonicUtf8 reads the mnemonic and alias from one DataStore snapshot, decrypts the plaintext, and then performs a separate store.edit through rewrapMnemonicUnbound. If deleteMnemonic(walletId) completes after the snapshot/decrypt but before that edit, the re-wrap writes the old ciphertext and alias back into the store, resurrecting a mnemonic that was just deleted. The same race can overwrite a newer mnemonic written concurrently. The atomicity of the re-wrap edit does not protect the read-to-write interval because the edit is not conditional on the original blob and alias still being present. Serialize re-wraps with mnemonic writes/deletes or perform a compare-and-set edit that only replaces the entry when the original encoded blob and alias still match.

source: ['claude']

Comment on lines +481 to +484
if (alias == KeystoreManager.MASTER_ALIAS && prefs[MASTER_LOCK_DEFECT_KEY] == true) {
rewrapMnemonicUnbound(walletId, plain)
}
return plain

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Cancellation during re-wrap can leave decrypted mnemonic bytes unsanitized

On the defect path, plain is decrypted and passed to rewrapMnemonicUnbound before ownership is returned to the caller. That function deliberately rethrows CancellationException; if cancellation occurs during the suspending store.edit, retrieveMnemonicUtf8 exits without returning plain, so the caller cannot scrub it. The same ownership problem applies to an unexpected throwable from the re-wrap. Clear the plaintext buffer before propagating any re-wrap failure that prevents returning it, while preserving the existing best-effort behavior for ordinary re-wrap errors.

Suggested change
if (alias == KeystoreManager.MASTER_ALIAS && prefs[MASTER_LOCK_DEFECT_KEY] == true) {
rewrapMnemonicUnbound(walletId, plain)
}
return plain
if (alias == KeystoreManager.MASTER_ALIAS && prefs[MASTER_LOCK_DEFECT_KEY] == true) {
try {
rewrapMnemonicUnbound(walletId, plain)
} catch (t: Throwable) {
plain.fill(0)
throw t
}
}

source: ['claude']

* no lock state can deny, so there is nothing to preflight.
*/
fun ensureMasterKeyNotLockBlocked(operation: String) {
suspend fun ensureMasterKeyNotLockBlocked(operation: String) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Changing this public method to suspend is a breaking API change

WalletStorage is a public class and ensureMasterKeyNotLockBlocked is declared as a public method without an internal modifier. The base API exposed a regular fun; changing it to suspend fun requires source callers to be inside a coroutine and changes the generated JVM-facing method shape. The PR's assertion that the change is internal and has no external callers is not sufficient to preserve compatibility for consumers that access the public WalletStorage API. Preserve the existing public method with a blocking-free wrapper/alternative, introduce a separate suspending API while retaining the old signature, or explicitly treat and document this as a breaking API change.

source: ['claude']

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.

2 participants