Skip to content

Wallet password protection - #764

Open
dkackman wants to merge 59 commits into
xch-dev:mainfrom
dkackman:password
Open

Wallet password protection#764
dkackman wants to merge 59 commits into
xch-dev:mainfrom
dkackman:password

Conversation

@dkackman

Copy link
Copy Markdown
Collaborator

fix #206

dkackman and others added 27 commits March 15, 2026 13:21
Design for issue xch-dev#206: opt-in per-operation password protection
for wallet secret access, transaction signing, and hardened key
generation. Includes biometric convenience layer design.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
10-task plan covering keychain core changes, API layer modifications,
backend endpoint threading, integration tests, and Tauri/frontend
skeleton. Includes bincode backward compatibility handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add password_protected bool field to the Secret variant of KeyData,
with LegacyKeyData enum and backward-compatible bincode deserialization
in from_bytes(). Update all construction sites to set the flag based on
whether a non-empty password was provided. Add is_password_protected()
accessor to Keychain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add change_password() method to Keychain that decrypts with the old
password and re-encrypts with a new one, updating the password_protected
flag. Add KeyNotFound and NoSecretKey error variants. Include tests for
password changing, wrong password rejection, public key rejection,
flag-on-import behavior, serialization roundtrip, and legacy format
backward compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add password parameter to sign(), transact(), and transact_with() and
thread it from every endpoint request struct through to the keychain.
Add change_password endpoint and populate has_password on KeyInfo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace two-step promptIfEnabled + requestPassword auth with unified
requestPassword returning string | null | undefined, where undefined
means cancelled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…bels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Biometric authentication is a global setting (not per-wallet), so it
belongs in the Preferences section of GlobalSettings rather than the
per-wallet Security section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dkackman
dkackman marked this pull request as draft March 16, 2026 13:58
@dkackman

Copy link
Copy Markdown
Collaborator Author

Code review

Found 2 issues:

  1. Biometric unlock breaks after the first successful useskipKeychainRef.current.add(fingerprint) is called every time the OS keychain returns a stored password (as a "pending validation" marker), but is never cleared when the backend accepts that password. It is only cleared in handleSubmit — the manual-entry path. In practice: the first biometric-authenticated operation succeeds, the flag stays set, and every subsequent operation in the same session skips the keychain and falls back to the password dialog. Biometric effectively works only once per session.

const stored = await keychainGet(keychainKey(fingerprint));
if (stored !== null) {
// Mark as pending validation — if backend rejects, next call skips keychain
skipKeychainRef.current.add(fingerprint);
return stored;
}
// Fall through to password dialog if keychain retrieval fails

  1. Delete confirmation dialog stays open when the password prompt is cancelledsetIsDeleteOpen(false) is only called at the bottom of deleteSelf, after the backend call. The early return on password === undefined (user cancels the password dialog) exits before that line, leaving the delete dialog stuck open. The Details dialog handles this correctly by calling setIsDetailsOpen(false) before the early return.

const deleteSelf = async () => {
const password = await requestPassword(info.has_password);
if (password === undefined) return;
await commands
.deleteKey({ fingerprint: info.fingerprint })
.then(async () => {
await clearKeychainEntry(info.fingerprint);
setKeys(keys.filter((key) => key.fingerprint !== info.fingerprint));
})
.catch(addError);
setIsDeleteOpen(false);
};

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

1. Biometric unlock now works across multiple operations. The skip flag
   is cleared at the start of requestPassword when the previous keychain
   password was accepted (i.e., we're called again without a manual
   dialog entry in between).

2. Delete confirmation dialog now closes when the password prompt is
   cancelled, matching the Details dialog behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dkackman and others added 3 commits March 16, 2026 12:50
requestPassword now takes an optional fingerprint parameter for
operations on non-active wallets (e.g. WalletCard view details /
delete). This ensures keychain lookup and storage use the correct
wallet's fingerprint instead of the currently active one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The skip/lastKeychainFingerprint mechanism had a bug where stale
keychain passwords would loop indefinitely (skip cleared on every
call, so stale entry retried each time).

Removed both refs entirely. The recovery path for stale keychain
entries is now: cancel the OS biometric prompt → keychain retrieval
fails → password dialog appears → type correct password → handleSubmit
updates keychain. Simpler and correct.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When the backend rejects a keychain-retrieved password (decrypt error),
ErrorContext calls notifyDecryptError() which marks the fingerprint as
stale in PasswordContext. The next requestPassword call skips keychain
and shows the dialog directly. When the user types the correct password
via the dialog, handleSubmit clears the stale flag and updates keychain.

This gives the user a recoverable path without needing to know they
should cancel the biometric prompt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dkackman

Copy link
Copy Markdown
Collaborator Author

Code review

Found 1 issue:

  1. Concurrent requestPassword calls corrupt pendingRef — first caller's promise hangs indefinitelypendingRef is a single useRef<PasswordRequest | null>. If two operations simultaneously reach the password dialog path (Case 4), the second write to pendingRef.current silently overwrites the first. handleSubmit only resolves the second caller. The first caller's Promise is permanently unresolved — that operation hangs with no error, no timeout, and no way to recover. A realistic trigger: a WalletConnect request arrives while the user is also initiating a UI signing operation.

// Case 4: Has password → show dialog (fallback or no biometric)
if (hasPassword) {
return new Promise<string | null | undefined>((resolve) => {
pendingRef.current = { resolve, fingerprint };
setOpen(true);
});
}

The fix is to either (a) queue pending requests instead of replacing them, or (b) reject the in-flight request when a new one arrives (so at least the caller fails fast rather than hanging).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

dkackman and others added 17 commits March 16, 2026 16:24
Remove all keychain password storage — password wallets always use the
dialog, biometric is a standalone gate for no-password wallets only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dkackman
dkackman marked this pull request as ready for review March 20, 2026 22:29
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.

Password protection

2 participants