Skip to content

Wallet password protection - #764

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

Wallet password protection#764
dkackman wants to merge 63 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
dkackman marked this pull request as ready for review March 20, 2026 22:29
@dkackman dkackman added the enhancement New feature or request label Aug 15, 2026
@dkackman

Copy link
Copy Markdown
Collaborator Author

@Hadamcik i merged 0.13 (ie main) into this PR. I'm not sure how (or if) key password protection interacts with apps or should interact with apps. Basically pre-apps, keys are encrypted at rest with a user secret and any operation that requires the keys, like singing, needs that password to retrieve the keys. Right now any app invocation back to sage that needs the keys will fail as it will have Password: none. So I think the question is how do apps solicit user input for the password or do they? Do they pass the password or expect sage to handle that prompting the user and the unlocking? I think I'd feel better about apps never prompting and that being the sage workflow. For now i put a placeholder in the brdging struct just to make it compile.

@Hadamcik

Copy link
Copy Markdown
Contributor

Any app action can be gated by user approval and you can make it also conditional so you can ask for password there and that is Sage's not App's. App doesn't ask for that password and it doesn't get it.

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Password protection

3 participants