From 52ac8c2b84df90ab829407344245a749b96c2673 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 13:21:34 -0500 Subject: [PATCH 01/53] Add password protection design spec Design for issue #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 --- .../2026-03-15-password-protection-design.md | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-15-password-protection-design.md diff --git a/docs/superpowers/specs/2026-03-15-password-protection-design.md b/docs/superpowers/specs/2026-03-15-password-protection-design.md new file mode 100644 index 000000000..91d163324 --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-password-protection-design.md @@ -0,0 +1,208 @@ +# Password Protection for Sage Wallet + +**Issue:** [xch-dev/sage#206](https://github.com/xch-dev/sage/issues/206) +**Date:** 2026-03-15 +**Status:** Design + +## Overview + +Add opt-in password protection to Sage wallet, requiring authentication for three categories of sensitive operations: displaying secrets, signing transactions/offers, and generating hardened keys. Optionally, users can enable biometric unlock (Touch ID, Face ID, Windows Hello) as a convenience layer that stores the password in the OS keychain. + +## Design Decisions + +- **Per-operation authentication** — every protected operation prompts for the password. No session caching. +- **Opt-in** — existing wallets continue working without a password. Users can enable protection via "Set Password." +- **Per-key passwords** — each key in the keychain has its own password (or no password). This follows from the existing data model where each `KeyData::Secret` has its own `Encrypted` struct with its own salt. The frontend should use the active wallet's fingerprint to determine which key's password to prompt for. +- **Biometric included in design** — designed as a frontend-only convenience layer on top of the password system. + +## Architecture + +### Password Is Never Stored (Backend) + +The password is a transient input, not persisted state. The existing encryption infrastructure in `sage-keychain` handles everything: + +1. At key import (or password set): Argon2 derives a 256-bit AES key from `password + random 32-byte salt` +2. The wallet secret (mnemonic entropy or raw secret key) is encrypted with AES-256-GCM +3. `keys.bin` stores only `{ciphertext, nonce, salt}` — no password, no derived key +4. On each protected operation: user provides password, Argon2 re-derives the key, AES-GCM decrypts. Wrong password fails AES-GCM authentication. +5. Argon2 default parameters provide computational cost that mitigates brute-force attempts against the encrypted data at rest. + +### Password Sentinel Convention + +The empty byte string `b""` is the "no password" sentinel. This is what existing keys are encrypted with today. The convention is: + +- `Option` in request structs: `None` and `Some("")` both map to `b""` at the backend via `req.password.unwrap_or_default().into_bytes()` +- `ChangePassword` uses `String` (not `Option`): an empty `old_password` means the key currently has no password; an empty `new_password` removes password protection + +### Has-Password Indicator + +Add a `has_password: bool` field to the `KeyInfo` struct returned by `get_key()` / `get_keys()`. Determined by attempting a trial decryption with `b""` at key load time and caching the result, or by adding a `password_protected: bool` field to `KeyData::Secret`. + +Preferred approach: add `password_hint: Option` or a simple `password_protected: bool` to `KeyData::Secret`. This avoids trial decryption and is serialized into `keys.bin`. Set to `true` when a non-empty password is used at encryption time. Exposed via `KeyInfo` to the frontend. + +### Biometric Layer + +Biometric is a frontend-only concern. The backend never knows whether a password came from typing or biometric retrieval. + +1. User enables biometric after setting a password +2. Frontend stores the password in the OS keychain with biometric access control (via a Tauri keyring/biometric plugin), keyed by wallet fingerprint +3. On protected operations: frontend tries biometric retrieval first, falls back to manual entry +4. Backend receives the password in the request struct either way + +The OS keychain uses the platform's secure element under the hood (Secure Enclave on macOS/iOS, TEE on Android, TPM on Windows) without Sage needing to manage SE keys directly. + +## Protected Operations + +There are 7 code points where `b""` is passed to `extract_secrets` or `add_mnemonic`/`add_secret_key`, plus 2 encrypt-at-creation sites. However, because `sign()` is called through `transact()` and `transact_with()`, the password must flow through a much larger API surface. + +### 1. Display mnemonic/secret key (1 site) + +| Call site | Function | +|-----------|----------| +| `crates/sage/src/endpoints/keys.rs:353` | `get_secret_key()` | + +### 2. Sign transactions and offers + +The central signing path is: + +``` +endpoint method → transact() / transact_with() → sign() → extract_secrets() +``` + +**Direct `extract_secrets` call sites** (5 sites): + +| Call site | Function | +|-----------|----------| +| `crates/sage/src/utils/spends.rs:15` | `sign()` — called by `transact_with()` and `sign_coin_spends()` | +| `crates/sage/src/endpoints/offers.rs:170` | `make_offer()` — calls `extract_secrets` directly | +| `crates/sage/src/endpoints/offers.rs:208` | `take_offer()` — calls `extract_secrets` directly | +| `crates/sage/src/endpoints/wallet_connect.rs:181` | `sign_message_with_public_key()` | +| `crates/sage/src/endpoints/wallet_connect.rs:220` | `sign_message_by_address()` | + +**Transaction endpoints that flow through `transact()` → `sign()`** (21 endpoints): + +`send_xch`, `bulk_send_xch`, `combine`, `auto_combine_xch`, `split`, `auto_combine_cat`, `issue_cat`, `send_cat`, `bulk_send_cat`, `multi_send`, `create_did`, `bulk_mint_nfts`, `transfer_nfts`, `add_nft_uri`, `assign_nfts_to_did`, `transfer_dids`, `normalize_dids`, `mint_option`, `transfer_options`, `exercise_options`, `finalize_clawback` + +Plus `cancel_offer`, `cancel_offers`, and `create_transaction` (action system) which also flow through `transact()` / `transact_with()`. + +### 3. Generate hardened keys (1 site) + +| Call site | Function | +|-----------|----------| +| `crates/sage/src/endpoints/actions.rs:201` | `increase_derivation_index()` | + +### 4. Key import — encrypt at creation (2 sites) + +| Call site | Function | +|-----------|----------| +| `crates/sage/src/endpoints/keys.rs:141` | `import_key()` — secret key path | +| `crates/sage/src/endpoints/keys.rs:178` | `import_key()` — mnemonic path | + +Note: `import_key()` also generates hardened derivations using the in-memory master key during import. This does NOT need the password since the key is already decrypted at that point. + +## Changes + +### `sage-keychain` crate + +**`keychain.rs`** — Add one new method: + +```rust +pub fn change_password( + &mut self, + fingerprint: u32, + old_password: &[u8], + new_password: &[u8], +) -> Result<(), KeychainError> +``` + +Decrypts with old password, re-encrypts with new password, replaces the `KeyData::Secret` entry. + +**`key_data.rs`** — Add `password_protected: bool` to `KeyData::Secret`: + +```rust +Secret { + master_pk: [u8; 48], + entropy: bool, + encrypted: Encrypted, + password_protected: bool, // new +} +``` + +Note: this changes the `keys.bin` serialization format. Existing files will fail to deserialize. Handle with a versioned deserialization fallback: try deserializing the new format first, fall back to the old format (defaulting `password_protected` to `false`). + +### `sage-api` crate (request structs) + +Add `password: Option` to **all request structs that trigger signing, secret access, or key import**: + +**Direct secret access:** +- `ImportKey` +- `GetSecretKey` + +**Signing via `transact()` path — all transaction request structs:** +- `SendXch`, `BulkSendXch`, `Combine`, `AutoCombineXch`, `Split`, `AutoCombineCat`, `IssueCat`, `SendCat`, `BulkSendCat`, `MultiSend`, `CreateDid`, `BulkMintNfts`, `TransferNfts`, `AddNftUri`, `AssignNftsToDid`, `TransferDids`, `NormalizeDids`, `MintOption`, `TransferOptions`, `ExerciseOptions`, `FinalizeClawback` + +**Signing via direct `extract_secrets` or `sign()`:** +- `SignCoinSpends`, `MakeOffer`, `TakeOffer`, `CancelOffer`, `CancelOffers`, `CreateTransaction` + +**Hardened derivation:** +- `IncreaseDerivationIndex` + +**WalletConnect signing:** +- `SignMessageWithPublicKey`, `SignMessageByAddress` + +**New request/response pair:** +- `ChangePassword { fingerprint: u32, old_password: String, new_password: String }` +- `ChangePasswordResponse {}` + +**`KeyInfo`** — add `has_password: bool` field. + +### `sage` crate (endpoints) + +**`spends.rs`**: `sign()` takes `password: &[u8]` parameter, passes to `extract_secrets`. + +**`transactions.rs`**: `transact()` and `transact_with()` take `password: &[u8]` parameter, pass to `sign()`. Every transaction endpoint extracts password from its request struct via `req.password.unwrap_or_default().into_bytes()` and passes to `transact()`. + +**`keys.rs`**: `import_key()` passes password to `add_mnemonic()`/`add_secret_key()`. `get_secret_key()` passes password to `extract_secrets()`. `get_key()`/`get_keys()` populate `has_password` from `KeyData`. + +**`offers.rs`**: `make_offer()`, `take_offer()` pass password to `extract_secrets()`. `cancel_offer()`, `cancel_offers()` pass password to `transact()`. + +**`actions.rs`**: `increase_derivation_index()` passes password to `extract_secrets()`. + +**`wallet_connect.rs`**: Both signing methods pass password to `extract_secrets()`. + +New `change_password()` endpoint. + +### Frontend (TypeScript/React) + +- Reusable password prompt dialog component +- Prompt shown before each protected operation (only when `has_password` is true for the active wallet) +- "Set Password" in wallet settings (calls `change_password` with empty old password) +- "Change Password" in wallet settings +- "Remove Password" in wallet settings (calls `change_password` with empty new password, with confirmation dialog warning about reduced security) +- Biometric opt-in toggle (calls keyring/biometric plugin) +- Biometric retrieval logic with fallback to manual entry +- Password change also updates the biometric keychain entry + +### New dependency + +- A Tauri keyring or biometric plugin (e.g., `tauri-plugin-biometry`) for the optional biometric layer + +## Error Handling + +- **Wrong password**: AES-GCM authentication fails → `KeychainError::Decrypt` → frontend shows "Incorrect password" +- **Public-key-only wallets**: `extract_secrets` returns `(None, None)` — no prompt needed. Frontend checks `has_secret_key` and `has_password` to decide. +- **Lost password**: No recovery. AES-256-GCM + Argon2 is irreversible without the password. UI warns at password-set time. Matches industry standard (Chia reference wallet, MetaMask). +- **Biometric invalidation**: OS invalidates keychain items when biometric enrollment changes. Falls back to manual password entry. User re-enables biometric by entering password again. + +## Migration + +Existing keys encrypted with `b""` continue to work — the user simply never gets prompted. To add protection, the user triggers "Set Password" which calls `change_password(fingerprint, b"", new_password)`. + +The `keys.bin` format change (adding `password_protected` to `KeyData::Secret`) requires a deserialization fallback: try new format first, fall back to old format with `password_protected: false`. On next save, the file is written in the new format. + +## What's NOT Changing + +- `encrypt.rs` — AES-256-GCM + Argon2 implementation is already correct +- `keys.bin` encryption scheme — same Argon2 + AES-256-GCM, just with real passwords instead of `b""` +- Any sync, peer, or database logic +- `SendTransactionImmediately`, `SubmitTransaction`, `ViewCoinSpends` — these operate on pre-signed spend bundles or read-only data and do not call `extract_secrets()` or `sign()` From c56b9f20adc86f3165e5fa05c09486df3a6389b8 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 13:36:52 -0500 Subject: [PATCH 02/53] Add password protection implementation plan 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 --- .../plans/2026-03-15-password-protection.md | 972 ++++++++++++++++++ 1 file changed, 972 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-15-password-protection.md diff --git a/docs/superpowers/plans/2026-03-15-password-protection.md b/docs/superpowers/plans/2026-03-15-password-protection.md new file mode 100644 index 000000000..a442ae385 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-password-protection.md @@ -0,0 +1,972 @@ +# Password Protection Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add opt-in per-operation password protection to Sage wallet, gating secret key access, transaction signing, and hardened key generation behind user-provided passwords. + +**Architecture:** Layer real passwords onto the existing Argon2 + AES-256-GCM encryption in `sage-keychain`. Add `password: Option` to all API request structs that trigger signing or secret access. Thread the password through `transact()` → `sign()` → `extract_secrets()`. Add `password_protected: bool` to `KeyData::Secret` and expose via `KeyInfo`. Frontend prompts per-operation when the active wallet has a password set. + +**Tech Stack:** Rust (sage-keychain, sage-api, sage crates), TypeScript/React (frontend), Tauri IPC + +**Spec:** `docs/superpowers/specs/2026-03-15-password-protection-design.md` + +--- + +## Chunk 1: Keychain Core — `password_protected` flag and `change_password` + +### Task 1: Add `password_protected` to `KeyData::Secret` + +**Files:** +- Modify: `crates/sage-keychain/src/key_data.rs:9-20` + +- [ ] **Step 1: Add `password_protected` field to `KeyData::Secret`** + +In `crates/sage-keychain/src/key_data.rs`, add the field to the `Secret` variant: + +```rust +Secret { + #[serde_as(as = "Bytes")] + master_pk: [u8; 48], + entropy: bool, + encrypted: Encrypted, + password_protected: bool, +} +``` + +**IMPORTANT:** `keys.bin` uses `bincode` serialization, NOT JSON. `#[serde(default)]` does NOT work with bincode — adding a field changes the binary layout and breaks deserialization of existing files. We must handle backward compatibility in `from_bytes()`. + +- [ ] **Step 1b: Add backward-compatible deserialization to `from_bytes()`** + +In `crates/sage-keychain/src/keychain.rs`, update `from_bytes()` to try the new format first, then fall back to the old format: + +```rust +/// Legacy KeyData without password_protected field, for backward compat +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[repr(u8)] +enum LegacyKeyData { + Public { + #[serde_as(as = "Bytes")] + master_pk: [u8; 48], + }, + Secret { + #[serde_as(as = "Bytes")] + master_pk: [u8; 48], + entropy: bool, + encrypted: Encrypted, + }, +} + +impl From for KeyData { + fn from(legacy: LegacyKeyData) -> Self { + match legacy { + LegacyKeyData::Public { master_pk } => KeyData::Public { master_pk }, + LegacyKeyData::Secret { master_pk, entropy, encrypted } => KeyData::Secret { + master_pk, + entropy, + encrypted, + password_protected: false, + }, + } + } +} +``` + +Then update `from_bytes()`: + +```rust +pub fn from_bytes(data: &[u8]) -> Result { + let keys: HashMap = bincode::deserialize(data) + .or_else(|_| { + // Fall back to legacy format without password_protected + let legacy: HashMap = bincode::deserialize(data)?; + Ok(legacy.into_iter().map(|(k, v)| (k, v.into())).collect()) + })?; + Ok(Self { + rng: ChaCha20Rng::from_entropy(), + keys, + }) +} +``` + +Add the necessary imports for `LegacyKeyData` (`serde_with::serde_as`, `Encrypted` etc.). + +- [ ] **Step 2: Verify the project compiles** + +Run: `cargo check -p sage-keychain` +Expected: compilation errors in `keychain.rs` where `KeyData::Secret` is constructed without the new field. + +- [ ] **Step 3: Fix all `KeyData::Secret` construction sites in `keychain.rs`** + +In `crates/sage-keychain/src/keychain.rs`, update `add_secret_key()` (line ~138) and `add_mnemonic()` (line ~166) to include `password_protected`: + +For `add_secret_key()`: +```rust +self.keys.insert( + fingerprint, + KeyData::Secret { + master_pk: master_pk.to_bytes(), + entropy: false, + encrypted, + password_protected: !password.is_empty(), + }, +); +``` + +For `add_mnemonic()`: +```rust +self.keys.insert( + fingerprint, + KeyData::Secret { + master_pk: master_pk.to_bytes(), + entropy: true, + encrypted, + password_protected: !password.is_empty(), + }, +); +``` + +- [ ] **Step 4: Add `is_password_protected()` accessor to `Keychain`** + +In `crates/sage-keychain/src/keychain.rs`, add: + +```rust +pub fn is_password_protected(&self, fingerprint: u32) -> bool { + matches!( + self.keys.get(&fingerprint), + Some(KeyData::Secret { password_protected: true, .. }) + ) +} +``` + +- [ ] **Step 5: Verify compilation** + +Run: `cargo check -p sage-keychain` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add crates/sage-keychain/src/key_data.rs crates/sage-keychain/src/keychain.rs +git commit -m "feat: add password_protected flag to KeyData::Secret" +``` + +### Task 2: Add `change_password` to `Keychain` + +**Files:** +- Modify: `crates/sage-keychain/src/keychain.rs` + +- [ ] **Step 1: Write a test for `change_password`** + +Add at the bottom of `crates/sage-keychain/src/keychain.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use bip39::Mnemonic; + + #[test] + fn test_change_password() { + let mut keychain = Keychain::default(); + let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); + + let fingerprint = keychain.add_mnemonic(&mnemonic, b"").unwrap(); + assert!(!keychain.is_password_protected(fingerprint)); + + // Set a password + keychain.change_password(fingerprint, b"", b"secret123").unwrap(); + assert!(keychain.is_password_protected(fingerprint)); + + // Old password should fail + assert!(keychain.extract_secrets(fingerprint, b"").is_err()); + + // New password should work + let (mnemonic_out, Some(_sk)) = keychain.extract_secrets(fingerprint, b"secret123").unwrap() else { + panic!("expected secret key"); + }; + assert!(mnemonic_out.is_some()); + + // Change to another password + keychain.change_password(fingerprint, b"secret123", b"newpass").unwrap(); + assert!(keychain.extract_secrets(fingerprint, b"secret123").is_err()); + let (_m, Some(_sk)) = keychain.extract_secrets(fingerprint, b"newpass").unwrap() else { + panic!("expected secret key"); + }; + + // Remove password + keychain.change_password(fingerprint, b"newpass", b"").unwrap(); + assert!(!keychain.is_password_protected(fingerprint)); + let (_m, Some(_sk)) = keychain.extract_secrets(fingerprint, b"").unwrap() else { + panic!("expected secret key"); + }; + } + + #[test] + fn test_change_password_wrong_old_password() { + let mut keychain = Keychain::default(); + let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); + + let fingerprint = keychain.add_mnemonic(&mnemonic, b"correct").unwrap(); + + assert!(keychain.change_password(fingerprint, b"wrong", b"newpass").is_err()); + // Original password still works + let (_m, Some(_sk)) = keychain.extract_secrets(fingerprint, b"correct").unwrap() else { + panic!("expected secret key"); + }; + } + + #[test] + fn test_change_password_public_key_fails() { + let mut keychain = Keychain::default(); + let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); + let master_sk = SecretKey::from_seed(&mnemonic.to_seed("")); + let master_pk = master_sk.public_key(); + + let fingerprint = keychain.add_public_key(&master_pk).unwrap(); + + assert!(keychain.change_password(fingerprint, b"", b"pass").is_err()); + } + + #[test] + fn test_password_protected_flag_on_import() { + let mut keychain = Keychain::default(); + let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); + + let fp_no_pass = keychain.add_mnemonic(&mnemonic, b"").unwrap(); + assert!(!keychain.is_password_protected(fp_no_pass)); + + // Need a different mnemonic to avoid KeyExists + let mut keychain2 = Keychain::default(); + let fp_with_pass = keychain2.add_mnemonic(&mnemonic, b"secret").unwrap(); + assert!(keychain2.is_password_protected(fp_with_pass)); + } + + #[test] + fn test_serialization_roundtrip_with_password() { + let mut keychain = Keychain::default(); + let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); + let fingerprint = keychain.add_mnemonic(&mnemonic, b"pass123").unwrap(); + + let bytes = keychain.to_bytes().unwrap(); + let keychain2 = Keychain::from_bytes(&bytes).unwrap(); + + assert!(keychain2.is_password_protected(fingerprint)); + let (_m, Some(_sk)) = keychain2.extract_secrets(fingerprint, b"pass123").unwrap() else { + panic!("expected secret key"); + }; + } + + #[test] + fn test_legacy_format_backward_compat() { + // Simulate a legacy keys.bin by serializing with LegacyKeyData + use std::collections::HashMap; + + let mut keychain = Keychain::default(); + let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); + let fingerprint = keychain.add_mnemonic(&mnemonic, b"").unwrap(); + + // Serialize, then deserialize into legacy format, re-serialize as legacy, + // then verify new from_bytes can read it. + // Alternatively: construct a HashMap manually, + // serialize with bincode, and verify Keychain::from_bytes reads it. + let mut legacy_map: HashMap = HashMap::new(); + if let Some(KeyData::Secret { master_pk, entropy, encrypted, .. }) = keychain.keys.get(&fingerprint) { + legacy_map.insert(fingerprint, LegacyKeyData::Secret { + master_pk: *master_pk, + entropy: *entropy, + encrypted: encrypted.clone(), + }); + } + let legacy_bytes = bincode::serialize(&legacy_map).unwrap(); + + // This should succeed via the fallback path + let restored = Keychain::from_bytes(&legacy_bytes).unwrap(); + assert!(!restored.is_password_protected(fingerprint)); + let (_m, Some(_sk)) = restored.extract_secrets(fingerprint, b"").unwrap() else { + panic!("expected secret key"); + }; + } +} +``` + +- [ ] **Step 2: Run the tests to see them fail** + +Run: `cargo test -p sage-keychain` +Expected: FAIL — `change_password` method does not exist. + +- [ ] **Step 3: Implement `change_password`** + +In `crates/sage-keychain/src/keychain.rs`, add the method to `impl Keychain`: + +```rust +pub fn change_password( + &mut self, + fingerprint: u32, + old_password: &[u8], + new_password: &[u8], +) -> Result<(), KeychainError> { + let key_data = self.keys.get(&fingerprint).ok_or(KeychainError::KeyNotFound)?; + + let (entropy, master_pk, secret_data) = match key_data { + KeyData::Public { .. } => return Err(KeychainError::NoSecretKey), + KeyData::Secret { + entropy, + master_pk, + encrypted, + .. + } => { + // Decrypt once with old password — this verifies the password + // and gives us the raw secret data to re-encrypt + let data = decrypt::(encrypted, old_password)?; + (*entropy, *master_pk, data) + } + }; + + // Re-encrypt the same secret data with new password + let encrypted = encrypt(new_password, &mut self.rng, &secret_data)?; + + self.keys.insert( + fingerprint, + KeyData::Secret { + master_pk, + entropy, + encrypted, + password_protected: !new_password.is_empty(), + }, + ); + + Ok(()) +} +``` + +Also add these error variants to `crates/sage-keychain/src/error.rs` (they do not currently exist): + +```rust +#[error("Key not found")] +KeyNotFound, + +#[error("No secret key")] +NoSecretKey, +``` + +- [ ] **Step 4: Run the tests** + +Run: `cargo test -p sage-keychain` +Expected: All tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/sage-keychain/ +git commit -m "feat: add change_password and keychain tests" +``` + +--- + +## Chunk 2: API Layer — Request struct changes and new endpoint types + +### Task 3: Add `password` field to all protected request structs + +**Files:** +- Modify: `crates/sage-api/src/requests/keys.rs` +- Modify: `crates/sage-api/src/requests/transactions.rs` +- Modify: `crates/sage-api/src/requests/offers.rs` +- Modify: `crates/sage-api/src/requests/actions.rs` +- Modify: `crates/sage-api/src/requests/wallet_connect.rs` +- Modify: `crates/sage-api/src/requests/action_system.rs` + +- [ ] **Step 1: Add `password: Option` to `GetSecretKey` and `ImportKey`** + +In `crates/sage-api/src/requests/keys.rs`: + +`GetSecretKey` — change from `Copy + Serialize, Deserialize` to just `Clone + Serialize, Deserialize` (since `Option` is not `Copy`), add: +```rust +pub struct GetSecretKey { + pub fingerprint: u32, + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, +} +``` + +`ImportKey` — add: +```rust + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, +``` + +- [ ] **Step 2: Add `password` to all transaction request structs** + +In `crates/sage-api/src/requests/transactions.rs`, add to every struct that has `auto_submit: bool` (these all flow through `transact()`): + +```rust + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, +``` + +Structs to modify: `SendXch`, `BulkSendXch`, `Combine`, `AutoCombineXch`, `Split`, `AutoCombineCat`, `IssueCat`, `SendCat`, `BulkSendCat`, `MultiSend`, `CreateDid`, `BulkMintNfts`, `TransferNfts`, `AddNftUri`, `AssignNftsToDid`, `TransferDids`, `NormalizeDids`, `MintOption`, `TransferOptions`, `ExerciseOptions`, `FinalizeClawback`, `SignCoinSpends`. + +- [ ] **Step 3: Add `password` to offer request structs** + +In `crates/sage-api/src/requests/offers.rs`, add `password: Option` (same pattern) to: `MakeOffer`, `TakeOffer`, `CancelOffer`, `CancelOffers`. + +- [ ] **Step 4: Add `password` to action request structs** + +In `crates/sage-api/src/requests/actions.rs`, add to `IncreaseDerivationIndex` (search for the struct — it contains `index: u32`). + +- [ ] **Step 5: Add `password` to WalletConnect signing structs** + +In `crates/sage-api/src/requests/wallet_connect.rs`, add `password: Option` to `SignMessageWithPublicKey` and `SignMessageByAddress`. Note these use `#[serde(rename_all = "camelCase")]` so the field will serialize as `password` (single word, no rename needed). + +- [ ] **Step 6: Add `password` to `CreateTransaction`** + +In `crates/sage-api/src/requests/action_system.rs`, add to `CreateTransaction`: + +```rust + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, +``` + +- [ ] **Step 7: Verify compilation** + +Run: `cargo check -p sage-api` +Expected: PASS (these are just data structs, no logic changes). + +- [ ] **Step 8: Commit** + +```bash +git add crates/sage-api/src/requests/ +git commit -m "feat: add password field to all protected request structs" +``` + +### Task 4: Add `ChangePassword` request/response and `has_password` to `KeyInfo` + +**Files:** +- Modify: `crates/sage-api/src/requests/keys.rs` +- Modify: `crates/sage-api/src/types/key_info.rs` + +- [ ] **Step 1: Add `ChangePassword` / `ChangePasswordResponse` to keys.rs** + +In `crates/sage-api/src/requests/keys.rs`, add (following the existing struct pattern with openapi/tauri derives): + +```rust +/// Change the password for a wallet's secret key +#[cfg_attr( + feature = "openapi", + crate::openapi_attr( + tag = "Authentication & Keys", + description = "Change the password used to encrypt a wallet's secret key." + ) +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct ChangePassword { + /// Wallet fingerprint + pub fingerprint: u32, + /// Current password (empty string if no password is set) + pub old_password: String, + /// New password (empty string to remove password protection) + pub new_password: String, +} + +/// Response after changing the password +#[cfg_attr(feature = "openapi", crate::openapi_attr(tag = "Authentication & Keys"))] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct ChangePasswordResponse {} +``` + +- [ ] **Step 2: Add `has_password` to `KeyInfo`** + +In `crates/sage-api/src/types/key_info.rs`, add to `KeyInfo`: + +```rust +pub struct KeyInfo { + pub name: String, + pub fingerprint: u32, + pub public_key: String, + pub kind: KeyKind, + pub has_secrets: bool, + pub has_password: bool, + pub network_id: String, + pub emoji: Option, +} +``` + +- [ ] **Step 3: Register `ChangePassword` in the endpoint macro system** + +The endpoint macro system reads from `crates/sage-api/endpoints.json`. Each entry maps an endpoint name to a boolean indicating whether it is async (`true`) or sync (`false`). + +Add `change_password` to `crates/sage-api/endpoints.json`: + +```json + "is_asset_owned": true, + "change_password": false +``` + +`change_password` is `false` (sync) because it only calls `keychain.change_password()` and `save_keychain()` — no async operations. + +Also ensure `ChangePassword` and `ChangePasswordResponse` are re-exported through the `sage_api` module hierarchy. The types in `crates/sage-api/src/requests/keys.rs` should be auto-exported via `pub use requests::*` in `crates/sage-api/src/lib.rs`. Verify by checking how `GetSecretKey` and `GetSecretKeyResponse` are exported. + +- [ ] **Step 4: Verify compilation** + +Run: `cargo check -p sage-api` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add crates/sage-api/ +git commit -m "feat: add ChangePassword endpoint and has_password to KeyInfo" +``` + +--- + +## Chunk 3: Backend Endpoints — Thread passwords through the call chain + +### Task 5: Update `sign()` and `transact()` to accept passwords + +**Files:** +- Modify: `crates/sage/src/utils/spends.rs` +- Modify: `crates/sage/src/endpoints/transactions.rs` + +- [ ] **Step 1: Add `password` parameter to `sign()`** + +In `crates/sage/src/utils/spends.rs`, change the signature: + +```rust +pub(crate) async fn sign( + &self, + coin_spends: Vec, + partial: bool, + password: &[u8], +) -> Result { + let wallet = self.wallet()?; + + let (_mnemonic, Some(master_sk)) = + self.keychain.extract_secrets(wallet.fingerprint, password)? + else { + return Err(Error::NoSigningKey); + }; + // ... rest unchanged +``` + +- [ ] **Step 2: Add `password` parameter to `transact()` and `transact_with()`** + +In `crates/sage/src/endpoints/transactions.rs`, update: + +```rust +pub(crate) async fn transact( + &self, + coin_spends: Vec, + auto_submit: bool, + password: &[u8], +) -> Result { + self.transact_with(coin_spends, auto_submit, ConfirmationInfo::default(), password) + .await +} + +pub(crate) async fn transact_with( + &self, + coin_spends: Vec, + auto_submit: bool, + info: ConfirmationInfo, + password: &[u8], +) -> Result { + if auto_submit { + let spend_bundle = self.sign(coin_spends.clone(), false, password).await?; + self.submit(spend_bundle).await?; + } + // ... rest unchanged +``` + +- [ ] **Step 3: Extract password and pass it in all transaction endpoints (same file)** + +Do NOT commit yet — complete all call site fixes in one go to keep the branch compilable. + +- [ ] **Step 4: Define the password extraction pattern** + +At the top of `crates/sage/src/endpoints/transactions.rs` (or in a shared util), the pattern for every endpoint is the same: + +```rust +let password = req.password.unwrap_or_default().into_bytes(); +``` + +Use this inline in each endpoint. No helper function needed — it's one line. + +- [ ] **Step 5: Update every transaction endpoint** + +For each of the 21 transaction endpoint methods, add the password extraction and pass it to `transact()` or `transact_with()`. The pattern for every endpoint is the same. Example for `send_xch`: + +```rust +pub async fn send_xch(&self, req: SendXch) -> Result { + let wallet = self.wallet()?; + let password = req.password.unwrap_or_default().into_bytes(); + let puzzle_hash = self.parse_address(req.address)?; + let amount = parse_amount(req.amount)?; + let fee = parse_amount(req.fee)?; + let memos = parse_memos(req.memos)?; + + let coin_spends = wallet + .send_xch(vec![(puzzle_hash, amount)], fee, memos, req.clawback) + .await?; + self.transact(coin_spends, req.auto_submit, &password).await +} +``` + +Apply the same pattern to all 21 endpoints: extract `password` from `req`, pass `&password` as the last arg to `self.transact()` or `self.transact_with()`. + +Also update `sign_coin_spends` which calls `self.sign()` directly: + +```rust +let password = req.password.unwrap_or_default().into_bytes(); +let spend_bundle = self.sign(coin_spends, req.partial, &password).await?; +``` + +- [ ] **Step 6: Continue to remaining endpoints (do NOT commit yet)** + +### Task 6: Thread password through offers, actions, wallet_connect, and keys + +**Files:** +- Modify: `crates/sage/src/endpoints/offers.rs` +- Modify: `crates/sage/src/endpoints/actions.rs` +- Modify: `crates/sage/src/endpoints/wallet_connect.rs` +- Modify: `crates/sage/src/endpoints/keys.rs` +- Modify: `crates/sage/src/endpoints/action_system.rs` (for `create_transaction`) + +- [ ] **Step 1: Update `offers.rs`** + +`make_offer()` and `take_offer()` call `extract_secrets` directly. `cancel_offer()` and `cancel_offers()` call `transact()`. Update all four: + +For `make_offer` and `take_offer`: +```rust +let password = req.password.unwrap_or_default().into_bytes(); +// ... +let (_mnemonic, Some(master_sk)) = + self.keychain.extract_secrets(wallet.fingerprint, &password)? +``` + +For `cancel_offer` and `cancel_offers`: +```rust +let password = req.password.unwrap_or_default().into_bytes(); +// ... pass &password to self.transact() +``` + +- [ ] **Step 2: Update `actions.rs`** + +In `increase_derivation_index()`: +```rust +let password = req.password.unwrap_or_default().into_bytes(); +// ... +let (_mnemonic, Some(master_sk)) = + self.keychain.extract_secrets(wallet.fingerprint, &password)? +``` + +- [ ] **Step 3: Update `wallet_connect.rs`** + +Both `sign_message_with_public_key` and `sign_message_by_address`: +```rust +let password = req.password.unwrap_or_default().into_bytes(); +// ... +let (_mnemonic, Some(master_sk)) = + self.keychain.extract_secrets(wallet.fingerprint, &password)? +``` + +- [ ] **Step 4: Update `keys.rs`** + +`import_key()` — pass password to `add_secret_key` and `add_mnemonic`: +```rust +let password_bytes = req.password.unwrap_or_default().into_bytes(); +// ... +self.keychain.add_secret_key(&master_sk, &password_bytes)? +// ... +self.keychain.add_mnemonic(&mnemonic, &password_bytes)? +``` + +`get_secret_key()` — pass password to `extract_secrets`: +```rust +let password = req.password.unwrap_or_default().into_bytes(); +let (mnemonic, Some(secret_key)) = self.keychain.extract_secrets(req.fingerprint, &password)? +``` + +`get_key()` and `get_keys()` — populate `has_password` on `KeyInfo`: +```rust +has_password: self.keychain.is_password_protected(fingerprint), +``` + +- [ ] **Step 5: Update `action_system.rs`** + +In the `create_transaction()` method: +```rust +let password = req.password.unwrap_or_default().into_bytes(); +// ... pass &password to self.transact_with() +``` + +- [ ] **Step 6: Verify full compilation** + +Run: `cargo check -p sage` +Expected: PASS — all `b""` usages replaced, all call sites updated. + +- [ ] **Step 7: Run existing tests** + +Run: `cargo test -p sage-rpc` +Expected: FAIL — existing tests (like `test_send_xch`) pass `SendXch { ... }` without a `password` field. Since `password` has `#[serde(default)]`, deserialization should still work. But if tests construct structs directly, they'll need `password: None` added. Check and fix. + +- [ ] **Step 8: Fix any test compilation issues** + +In `crates/sage-rpc/src/tests.rs`, add `password: None` to any struct literal that now requires it (e.g., `SendXch`, `ImportKey`). + +- [ ] **Step 9: Run tests again** + +Run: `cargo test -p sage-rpc` +Expected: PASS + +- [ ] **Step 10: Commit (all call chain changes in one compilable commit)** + +```bash +git add crates/sage/src/utils/spends.rs crates/sage/src/endpoints/ crates/sage-rpc/src/tests.rs +git commit -m "feat: thread password through all protected endpoints" +``` + +### Task 7: Add `change_password` endpoint + +**Files:** +- Modify: `crates/sage/src/endpoints/keys.rs` + +- [ ] **Step 1: Implement `change_password` endpoint** + +In `crates/sage/src/endpoints/keys.rs`, add: + +```rust +pub fn change_password(&mut self, req: ChangePassword) -> Result { + let old_password = req.old_password.into_bytes(); + let new_password = req.new_password.into_bytes(); + self.keychain.change_password(req.fingerprint, &old_password, &new_password)?; + self.save_keychain()?; + Ok(ChangePasswordResponse {}) +} +``` + +Make sure `ChangePassword` and `ChangePasswordResponse` are imported at the top. + +- [ ] **Step 2: Verify endpoint is registered** + +`change_password` should already be registered in `crates/sage-api/endpoints.json` from Task 4, Step 3. Verify it appears there as `"change_password": false`. + +- [ ] **Step 3: Verify compilation** + +Run: `cargo check -p sage` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add crates/sage/src/endpoints/keys.rs +git commit -m "feat: add change_password endpoint" +``` + +--- + +## Chunk 4: Integration Tests + +### Task 8: Add integration tests for password protection + +**Files:** +- Modify: `crates/sage-rpc/src/tests.rs` + +- [ ] **Step 1: Add test for password-protected key import and secret retrieval** + +```rust +#[tokio::test] +async fn test_password_protected_import() -> Result<()> { + let mut app = TestApp::new().await?; + + // Import with password + let response = app.import_key(ImportKey { + key: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string(), + name: "Test".to_string(), + save_secrets: true, + password: Some("mypassword".to_string()), + // ... other required fields with defaults + }).await?; + + let fingerprint = response.fingerprint; + + // Verify has_password is true + let key = app.get_key(GetKey { fingerprint: Some(fingerprint) }).await?.key.unwrap(); + assert!(key.has_password); + + // Getting secret without password should fail + let result = app.get_secret_key(GetSecretKey { + fingerprint, + password: None, + }).await; + assert!(result.is_err()); + + // Getting secret with correct password should work + let result = app.get_secret_key(GetSecretKey { + fingerprint, + password: Some("mypassword".to_string()), + }).await?; + assert!(result.secrets.is_some()); + + Ok(()) +} +``` + +- [ ] **Step 2: Add test for password-protected transaction signing** + +```rust +#[tokio::test] +async fn test_password_protected_send() -> Result<()> { + let mut app = TestApp::new().await?; + + // Setup with password - use the test helper but import with password + // This may need adjusting based on the TestApp::setup_bls helper + let fingerprint = app.setup_bls_with_password(1000, "secret").await?; + + app.wait_for_coins().await; + + // Send without password should fail + let result = app.send_xch(SendXch { + address: "txch1...".to_string(), // use a valid test address + amount: Amount::u64(100), + fee: Amount::u64(0), + memos: vec![], + clawback: None, + auto_submit: true, + password: None, + }).await; + assert!(result.is_err()); + + // Send with password should succeed + let result = app.send_xch(SendXch { + address: "txch1...".to_string(), + amount: Amount::u64(100), + fee: Amount::u64(0), + memos: vec![], + clawback: None, + auto_submit: true, + password: Some("secret".to_string()), + }).await; + assert!(result.is_ok()); + + Ok(()) +} +``` + +Note: The exact test setup will depend on how `TestApp::setup_bls` works. You may need to add a `setup_bls_with_password` helper that imports a key with a password, or modify the import call in the test directly. + +- [ ] **Step 3: Add test for change_password** + +```rust +#[tokio::test] +async fn test_change_password() -> Result<()> { + let mut app = TestApp::new().await?; + + let fingerprint = app.setup_bls(0).await?; + + // Initially no password + let key = app.get_key(GetKey { fingerprint: Some(fingerprint) }).await?.key.unwrap(); + assert!(!key.has_password); + + // Set password + app.change_password(ChangePassword { + fingerprint, + old_password: "".to_string(), + new_password: "secret".to_string(), + }).await?; + + // Now has_password should be true + let key = app.get_key(GetKey { fingerprint: Some(fingerprint) }).await?.key.unwrap(); + assert!(key.has_password); + + // Old empty password should fail + let result = app.get_secret_key(GetSecretKey { + fingerprint, + password: None, + }).await; + assert!(result.is_err()); + + // New password should work + let result = app.get_secret_key(GetSecretKey { + fingerprint, + password: Some("secret".to_string()), + }).await?; + assert!(result.secrets.is_some()); + + Ok(()) +} +``` + +- [ ] **Step 4: Run all tests** + +Run: `cargo test -p sage-keychain && cargo test -p sage-rpc` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add crates/sage-rpc/src/tests.rs +git commit -m "test: add integration tests for password protection" +``` + +--- + +## Chunk 5: Tauri Command Layer and Frontend (Skeleton) + +### Task 9: Ensure Tauri commands compile with new request structs + +**Files:** +- Modify: `src-tauri/src/commands.rs` (if needed — the macro should auto-generate) + +- [ ] **Step 1: Check if the Tauri command layer auto-generates from the endpoint macro** + +The `impl_endpoints_tauri!` macro in `src-tauri/src/commands.rs` auto-generates Tauri commands from the endpoint definitions. If `change_password` was added to the endpoint list in the macro, it should auto-generate. + +Run: `cargo check -p sage-desktop` (or whatever the Tauri package name is) +Expected: PASS if macro handles everything. If not, manually add the `change_password` command. + +- [ ] **Step 2: Verify the full workspace compiles** + +Run: `cargo check --workspace` +Expected: PASS + +- [ ] **Step 3: Commit if any changes needed** + +```bash +git add src-tauri/ +git commit -m "feat: wire change_password through Tauri command layer" +``` + +### Task 10: Frontend — this task is a placeholder for frontend work + +The frontend implementation is a significant body of work that depends on the Sage React app structure. The key pieces are: + +1. **Password prompt dialog component** — a reusable modal that collects a password +2. **Wiring** — every protected Tauri `invoke()` call needs to collect the password first (if `has_password` is true for the active wallet) and include it in the request +3. **Settings UI** — "Set Password", "Change Password", "Remove Password" buttons +4. **Biometric toggle** — future work, depends on selecting a Tauri keyring/biometric plugin + +This task should be planned separately once the backend is complete and tested, as it requires exploring the React app structure, identifying all `invoke()` call sites, and designing the prompt flow. + +- [ ] **Step 1: Document the frontend contract** + +Create a brief document listing: +- All Tauri command names that now accept `password` +- The `has_password` field on `KeyInfo` for conditional prompting +- The `change_password` command for settings UI + +- [ ] **Step 2: Commit** + +```bash +git commit --allow-empty -m "docs: frontend password protection contract ready" +``` From 91805b6320ab2f5801d889e15eec27f1e1aedf73 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 14:37:12 -0500 Subject: [PATCH 03/53] feat: add password_protected flag to KeyData::Secret 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 --- crates/sage-keychain/src/key_data.rs | 1 + crates/sage-keychain/src/keychain.rs | 62 ++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/crates/sage-keychain/src/key_data.rs b/crates/sage-keychain/src/key_data.rs index 07fc0fa62..bd593e3c7 100644 --- a/crates/sage-keychain/src/key_data.rs +++ b/crates/sage-keychain/src/key_data.rs @@ -16,6 +16,7 @@ pub enum KeyData { master_pk: [u8; 48], entropy: bool, encrypted: Encrypted, + password_protected: bool, }, } diff --git a/crates/sage-keychain/src/keychain.rs b/crates/sage-keychain/src/keychain.rs index 235f143a8..8a70dab8c 100644 --- a/crates/sage-keychain/src/keychain.rs +++ b/crates/sage-keychain/src/keychain.rs @@ -4,13 +4,50 @@ use bip39::Mnemonic; use chia_wallet_sdk::prelude::*; use rand::SeedableRng; use rand_chacha::ChaCha20Rng; +use serde::{Deserialize, Serialize}; +use serde_with::{Bytes, serde_as}; use crate::{ KeychainError, - encrypt::{decrypt, encrypt}, + encrypt::{Encrypted, decrypt, encrypt}, key_data::{KeyData, SecretKeyData}, }; +/// Legacy KeyData without password_protected field, for backward compat +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[repr(u8)] +enum LegacyKeyData { + Public { + #[serde_as(as = "Bytes")] + master_pk: [u8; 48], + }, + Secret { + #[serde_as(as = "Bytes")] + master_pk: [u8; 48], + entropy: bool, + encrypted: Encrypted, + }, +} + +impl From for KeyData { + fn from(legacy: LegacyKeyData) -> Self { + match legacy { + LegacyKeyData::Public { master_pk } => KeyData::Public { master_pk }, + LegacyKeyData::Secret { + master_pk, + entropy, + encrypted, + } => KeyData::Secret { + master_pk, + entropy, + encrypted, + password_protected: false, + }, + } + } +} + #[derive(Debug)] pub struct Keychain { rng: ChaCha20Rng, @@ -28,7 +65,10 @@ impl Default for Keychain { impl Keychain { pub fn from_bytes(data: &[u8]) -> Result { - let keys = bincode::deserialize(data)?; + let keys: HashMap = bincode::deserialize(data).or_else(|_| { + let legacy: HashMap = bincode::deserialize(data)?; + Ok::<_, bincode::Error>(legacy.into_iter().map(|(k, v)| (k, v.into())).collect()) + })?; Ok(Self { rng: ChaCha20Rng::from_entropy(), keys, @@ -51,7 +91,10 @@ impl Keychain { self.keys.keys().copied() } - pub fn extract_public_key(&self, fingerprint: u32) -> Result, KeychainError> { + pub fn extract_public_key( + &self, + fingerprint: u32, + ) -> Result, KeychainError> { match self.keys.get(&fingerprint) { Some(KeyData::Public { master_pk } | KeyData::Secret { master_pk, .. }) => { Ok(Some(PublicKey::from_bytes(master_pk)?)) @@ -100,6 +143,16 @@ impl Keychain { } } + pub fn is_password_protected(&self, fingerprint: u32) -> bool { + matches!( + self.keys.get(&fingerprint), + Some(KeyData::Secret { + password_protected: true, + .. + }) + ) + } + pub fn add_public_key(&mut self, master_pk: &PublicKey) -> Result { let fingerprint = master_pk.get_fingerprint(); @@ -141,6 +194,7 @@ impl Keychain { master_pk: master_pk.to_bytes(), entropy: false, encrypted, + password_protected: !password.is_empty(), }, ); @@ -170,9 +224,11 @@ impl Keychain { master_pk: master_pk.to_bytes(), entropy: true, encrypted, + password_protected: !password.is_empty(), }, ); Ok(fingerprint) } + } From 87bda1500fb0a52df24b0cd8c6f461557f684d3d Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 14:37:30 -0500 Subject: [PATCH 04/53] feat: add change_password and keychain tests 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 --- crates/sage-keychain/src/error.rs | 6 + crates/sage-keychain/src/keychain.rs | 164 +++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) diff --git a/crates/sage-keychain/src/error.rs b/crates/sage-keychain/src/error.rs index a40e0eb97..30d8fc150 100644 --- a/crates/sage-keychain/src/error.rs +++ b/crates/sage-keychain/src/error.rs @@ -22,4 +22,10 @@ pub enum KeychainError { #[error("Key already exists")] KeyExists, + + #[error("Key not found")] + KeyNotFound, + + #[error("No secret key")] + NoSecretKey, } diff --git a/crates/sage-keychain/src/keychain.rs b/crates/sage-keychain/src/keychain.rs index 8a70dab8c..d1866a8e5 100644 --- a/crates/sage-keychain/src/keychain.rs +++ b/crates/sage-keychain/src/keychain.rs @@ -231,4 +231,168 @@ impl Keychain { Ok(fingerprint) } + pub fn change_password( + &mut self, + fingerprint: u32, + old_password: &[u8], + new_password: &[u8], + ) -> Result<(), KeychainError> { + let key_data = self + .keys + .get(&fingerprint) + .ok_or(KeychainError::KeyNotFound)?; + + let (entropy, master_pk, secret_data) = match key_data { + KeyData::Public { .. } => return Err(KeychainError::NoSecretKey), + KeyData::Secret { + entropy, + master_pk, + encrypted, + .. + } => { + let data = decrypt::(encrypted, old_password)?; + (*entropy, *master_pk, data) + } + }; + + let encrypted = encrypt(new_password, &mut self.rng, &secret_data)?; + + self.keys.insert( + fingerprint, + KeyData::Secret { + master_pk, + entropy, + encrypted, + password_protected: !new_password.is_empty(), + }, + ); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bip39::Mnemonic; + + #[test] + fn test_change_password() { + let mut keychain = Keychain::default(); + let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); + let fingerprint = keychain.add_mnemonic(&mnemonic, b"").unwrap(); + assert!(!keychain.is_password_protected(fingerprint)); + + keychain + .change_password(fingerprint, b"", b"secret123") + .unwrap(); + assert!(keychain.is_password_protected(fingerprint)); + assert!(keychain.extract_secrets(fingerprint, b"").is_err()); + + let (mnemonic_out, Some(_sk)) = + keychain.extract_secrets(fingerprint, b"secret123").unwrap() + else { + panic!("expected secret key"); + }; + assert!(mnemonic_out.is_some()); + + keychain + .change_password(fingerprint, b"secret123", b"newpass") + .unwrap(); + assert!(keychain.extract_secrets(fingerprint, b"secret123").is_err()); + let (_m, Some(_sk)) = keychain.extract_secrets(fingerprint, b"newpass").unwrap() else { + panic!("expected secret key"); + }; + + keychain + .change_password(fingerprint, b"newpass", b"") + .unwrap(); + assert!(!keychain.is_password_protected(fingerprint)); + let (_m, Some(_sk)) = keychain.extract_secrets(fingerprint, b"").unwrap() else { + panic!("expected secret key"); + }; + } + + #[test] + fn test_change_password_wrong_old_password() { + let mut keychain = Keychain::default(); + let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); + let fingerprint = keychain.add_mnemonic(&mnemonic, b"correct").unwrap(); + assert!(keychain + .change_password(fingerprint, b"wrong", b"newpass") + .is_err()); + let (_m, Some(_sk)) = keychain.extract_secrets(fingerprint, b"correct").unwrap() else { + panic!("expected secret key"); + }; + } + + #[test] + fn test_change_password_public_key_fails() { + let mut keychain = Keychain::default(); + let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); + let master_sk = SecretKey::from_seed(&mnemonic.to_seed("")); + let master_pk = master_sk.public_key(); + let fingerprint = keychain.add_public_key(&master_pk).unwrap(); + assert!(keychain + .change_password(fingerprint, b"", b"pass") + .is_err()); + } + + #[test] + fn test_password_protected_flag_on_import() { + let mut keychain = Keychain::default(); + let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); + let fp_no_pass = keychain.add_mnemonic(&mnemonic, b"").unwrap(); + assert!(!keychain.is_password_protected(fp_no_pass)); + + let mut keychain2 = Keychain::default(); + let fp_with_pass = keychain2.add_mnemonic(&mnemonic, b"secret").unwrap(); + assert!(keychain2.is_password_protected(fp_with_pass)); + } + + #[test] + fn test_serialization_roundtrip_with_password() { + let mut keychain = Keychain::default(); + let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); + let fingerprint = keychain.add_mnemonic(&mnemonic, b"pass123").unwrap(); + let bytes = keychain.to_bytes().unwrap(); + let keychain2 = Keychain::from_bytes(&bytes).unwrap(); + assert!(keychain2.is_password_protected(fingerprint)); + let (_m, Some(_sk)) = keychain2.extract_secrets(fingerprint, b"pass123").unwrap() else { + panic!("expected secret key"); + }; + } + + #[test] + fn test_legacy_format_backward_compat() { + use std::collections::HashMap; + + let mut keychain = Keychain::default(); + let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); + let fingerprint = keychain.add_mnemonic(&mnemonic, b"").unwrap(); + + let mut legacy_map: HashMap = HashMap::new(); + if let Some(KeyData::Secret { + master_pk, + entropy, + encrypted, + .. + }) = keychain.keys.get(&fingerprint) + { + legacy_map.insert( + fingerprint, + LegacyKeyData::Secret { + master_pk: *master_pk, + entropy: *entropy, + encrypted: encrypted.clone(), + }, + ); + } + let legacy_bytes = bincode::serialize(&legacy_map).unwrap(); + let restored = Keychain::from_bytes(&legacy_bytes).unwrap(); + assert!(!restored.is_password_protected(fingerprint)); + let (_m, Some(_sk)) = restored.extract_secrets(fingerprint, b"").unwrap() else { + panic!("expected secret key"); + }; + } } From c05106f846d088059ee693e0799470a0876630e1 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 15:04:31 -0500 Subject: [PATCH 05/53] feat: add password field to all protected request structs Co-Authored-By: Claude Opus 4.6 --- crates/sage-api/src/requests/action_system.rs | 4 + crates/sage-api/src/requests/actions.rs | 6 +- crates/sage-api/src/requests/keys.rs | 37 +++++++- crates/sage-api/src/requests/offers.rs | 16 ++++ crates/sage-api/src/requests/transactions.rs | 88 +++++++++++++++++++ .../sage-api/src/requests/wallet_connect.rs | 8 ++ 6 files changed, 157 insertions(+), 2 deletions(-) diff --git a/crates/sage-api/src/requests/action_system.rs b/crates/sage-api/src/requests/action_system.rs index dee2a403d..1af5ac3ef 100644 --- a/crates/sage-api/src/requests/action_system.rs +++ b/crates/sage-api/src/requests/action_system.rs @@ -23,6 +23,10 @@ pub struct CreateTransaction { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/sage-api/src/requests/actions.rs b/crates/sage-api/src/requests/actions.rs index 06e29c222..c1f19e2eb 100644 --- a/crates/sage-api/src/requests/actions.rs +++ b/crates/sage-api/src/requests/actions.rs @@ -192,7 +192,7 @@ pub struct RedownloadNftResponse {} description = "Increase the derivation index to generate more addresses for the wallet." ) )] -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct IncreaseDerivationIndex { @@ -205,6 +205,10 @@ pub struct IncreaseDerivationIndex { /// The target derivation index to increase to #[cfg_attr(feature = "openapi", schema(example = 100))] pub index: u32, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response after increasing the derivation index diff --git a/crates/sage-api/src/requests/keys.rs b/crates/sage-api/src/requests/keys.rs index 573bf53c4..117254d6d 100644 --- a/crates/sage-api/src/requests/keys.rs +++ b/crates/sage-api/src/requests/keys.rs @@ -177,6 +177,10 @@ pub struct ImportKey { #[serde(default)] #[cfg_attr(feature = "openapi", schema(nullable = true))] pub emoji: Option, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } fn yes() -> bool { @@ -375,13 +379,17 @@ pub struct GetKeyResponse { description = "Retrieve the secret key (mnemonic) for a wallet. Requires authentication." ) )] -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct GetSecretKey { /// Wallet fingerprint #[cfg_attr(feature = "openapi", schema(example = 1_234_567_890))] pub fingerprint: u32, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response with secret key information @@ -398,6 +406,33 @@ pub struct GetSecretKeyResponse { pub secrets: Option, } +/// Change the password for a wallet's secret key +#[cfg_attr( + feature = "openapi", + crate::openapi_attr( + tag = "Authentication & Keys", + description = "Change the password used to encrypt a wallet's secret key." + ) +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct ChangePassword { + /// Wallet fingerprint + pub fingerprint: u32, + /// Current password (empty string if no password is set) + pub old_password: String, + /// New password (empty string to remove password protection) + pub new_password: String, +} + +/// Response after changing the password +#[cfg_attr(feature = "openapi", crate::openapi_attr(tag = "Authentication & Keys"))] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct ChangePasswordResponse {} + /// List all custom theme NFTs #[cfg_attr( feature = "openapi", diff --git a/crates/sage-api/src/requests/offers.rs b/crates/sage-api/src/requests/offers.rs index bfdf89886..ab15b8cf1 100644 --- a/crates/sage-api/src/requests/offers.rs +++ b/crates/sage-api/src/requests/offers.rs @@ -40,6 +40,10 @@ pub struct MakeOffer { #[serde(default)] #[cfg_attr(feature = "openapi", schema(nullable = true))] pub coin_ids: Option>, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Asset amount in an offer @@ -92,6 +96,10 @@ pub struct TakeOffer { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response with accepted offer details @@ -307,6 +315,10 @@ pub struct CancelOffer { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } pub type CancelOfferResponse = TransactionResponse; @@ -332,6 +344,10 @@ pub struct CancelOffers { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } pub type CancelOffersResponse = TransactionResponse; diff --git a/crates/sage-api/src/requests/transactions.rs b/crates/sage-api/src/requests/transactions.rs index 50576f91d..06d281208 100644 --- a/crates/sage-api/src/requests/transactions.rs +++ b/crates/sage-api/src/requests/transactions.rs @@ -33,6 +33,10 @@ pub struct SendXch { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Send XCH to multiple addresses @@ -61,6 +65,10 @@ pub struct BulkSendXch { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Combine multiple coins into one @@ -84,6 +92,10 @@ pub struct Combine { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Split coins into multiple smaller coins @@ -109,6 +121,10 @@ pub struct Split { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Automatically combine XCH coins @@ -134,6 +150,10 @@ pub struct AutoCombineXch { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response for auto-combine XCH @@ -175,6 +195,10 @@ pub struct AutoCombineCat { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response for auto-combine CAT @@ -216,6 +240,10 @@ pub struct IssueCat { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Send CAT tokens to an address @@ -254,6 +282,10 @@ pub struct SendCat { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Send CAT tokens to multiple addresses @@ -288,6 +320,10 @@ pub struct BulkSendCat { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } fn yes() -> bool { @@ -315,6 +351,10 @@ pub struct MultiSend { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Individual payment in a multi-send transaction @@ -357,6 +397,10 @@ pub struct CreateDid { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Mint multiple NFTs in one transaction @@ -381,6 +425,10 @@ pub struct BulkMintNfts { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response for bulk NFT minting @@ -472,6 +520,10 @@ pub struct TransferNfts { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Add a URI to an NFT @@ -499,6 +551,10 @@ pub struct AddNftUri { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Type of NFT URI @@ -537,6 +593,10 @@ pub struct AssignNftsToDid { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Transfer DIDs to a new address @@ -566,6 +626,10 @@ pub struct TransferDids { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Normalize DIDs to latest state @@ -589,6 +653,10 @@ pub struct NormalizeDids { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Asset specification for options @@ -628,6 +696,10 @@ pub struct MintOption { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response for minting an option @@ -665,6 +737,10 @@ pub struct ExerciseOptions { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Transfer options to another address @@ -694,6 +770,10 @@ pub struct TransferOptions { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Send CAT tokens to an address @@ -717,6 +797,10 @@ pub struct FinalizeClawback { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Sign coin spends to create a transaction @@ -741,6 +825,10 @@ pub struct SignCoinSpends { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub partial: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response with signed spend bundle diff --git a/crates/sage-api/src/requests/wallet_connect.rs b/crates/sage-api/src/requests/wallet_connect.rs index 0e70825a9..b64cfcb72 100644 --- a/crates/sage-api/src/requests/wallet_connect.rs +++ b/crates/sage-api/src/requests/wallet_connect.rs @@ -147,6 +147,10 @@ pub struct SignMessageWithPublicKey { pub message: String, /// Public key to use for signing pub public_key: String, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response with message signature @@ -234,6 +238,10 @@ pub struct SignMessageByAddress { pub message: String, /// Address whose key to use pub address: String, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response with signed message From de45ca1b8dec7f268d8e71a2207cb3aabef62b39 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 15:04:36 -0500 Subject: [PATCH 06/53] feat: add ChangePassword endpoint and has_password to KeyInfo Co-Authored-By: Claude Opus 4.6 --- crates/sage-api/endpoints.json | 3 ++- crates/sage-api/src/types/key_info.rs | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/sage-api/endpoints.json b/crates/sage-api/endpoints.json index 16263fdd9..2cb9e1ba9 100644 --- a/crates/sage-api/endpoints.json +++ b/crates/sage-api/endpoints.json @@ -98,5 +98,6 @@ "update_nft_collection": true, "redownload_nft": true, "increase_derivation_index": true, - "is_asset_owned": true + "is_asset_owned": true, + "change_password": false } diff --git a/crates/sage-api/src/types/key_info.rs b/crates/sage-api/src/types/key_info.rs index 36dd3fead..6a8bf86e5 100644 --- a/crates/sage-api/src/types/key_info.rs +++ b/crates/sage-api/src/types/key_info.rs @@ -9,6 +9,7 @@ pub struct KeyInfo { pub public_key: String, pub kind: KeyKind, pub has_secrets: bool, + pub has_password: bool, pub network_id: String, pub emoji: Option, } From 3e837e3fc2fbfee15d1d2734cd30787a244b0836 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 15:20:57 -0500 Subject: [PATCH 07/53] feat: thread password through all protected endpoints 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 --- crates/sage-rpc/src/tests.rs | 2 + crates/sage/src/endpoints/action_system.rs | 3 +- crates/sage/src/endpoints/actions.rs | 3 +- crates/sage/src/endpoints/keys.rs | 29 ++++++--- crates/sage/src/endpoints/offers.rs | 12 ++-- crates/sage/src/endpoints/transactions.rs | 72 ++++++++++++++------- crates/sage/src/endpoints/wallet_connect.rs | 6 +- crates/sage/src/error.rs | 1 + crates/sage/src/utils/spends.rs | 3 +- 9 files changed, 90 insertions(+), 41 deletions(-) diff --git a/crates/sage-rpc/src/tests.rs b/crates/sage-rpc/src/tests.rs index a04617375..8d4df5a98 100644 --- a/crates/sage-rpc/src/tests.rs +++ b/crates/sage-rpc/src/tests.rs @@ -137,6 +137,7 @@ impl TestApp { save_secrets: true, login: true, emoji: None, + password: None, }) .await? .fingerprint; @@ -255,6 +256,7 @@ async fn test_send_xch() -> Result<()> { memos: vec![], clawback: None, auto_submit: true, + password: None, }) .await?; diff --git a/crates/sage/src/endpoints/action_system.rs b/crates/sage/src/endpoints/action_system.rs index 1b0b86326..558257e38 100644 --- a/crates/sage/src/endpoints/action_system.rs +++ b/crates/sage/src/endpoints/action_system.rs @@ -15,6 +15,7 @@ use crate::{ impl Sage { pub async fn create_transaction(&self, req: CreateTransaction) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let sender_puzzle_hash = wallet.change_p2_puzzle_hash().await?; @@ -146,7 +147,7 @@ impl Sage { let coin_spends = ctx.take(); - self.transact_with(coin_spends, req.auto_submit, info).await + self.transact_with(coin_spends, req.auto_submit, info, &password).await } } diff --git a/crates/sage/src/endpoints/actions.rs b/crates/sage/src/endpoints/actions.rs index 3e333ade2..c458f0857 100644 --- a/crates/sage/src/endpoints/actions.rs +++ b/crates/sage/src/endpoints/actions.rs @@ -189,6 +189,7 @@ impl Sage { &self, req: IncreaseDerivationIndex, ) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let hardened = req.hardened.is_none_or(|hardened| hardened); @@ -198,7 +199,7 @@ impl Sage { if hardened { let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, b"")? + self.keychain.extract_secrets(wallet.fingerprint, &password)? else { return Err(Error::NoSigningKey); }; diff --git a/crates/sage/src/endpoints/keys.rs b/crates/sage/src/endpoints/keys.rs index 7f7908195..30172485a 100644 --- a/crates/sage/src/endpoints/keys.rs +++ b/crates/sage/src/endpoints/keys.rs @@ -14,11 +14,11 @@ use chia_wallet_sdk::{ use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha20Rng; use sage_api::{ - DeleteDatabase, DeleteDatabaseResponse, DeleteKey, DeleteKeyResponse, GenerateMnemonic, - GenerateMnemonicResponse, GetKey, GetKeyResponse, GetKeys, GetKeysResponse, GetSecretKey, - GetSecretKeyResponse, ImportKey, ImportKeyResponse, KeyInfo, KeyKind, Login, LoginResponse, - Logout, LogoutResponse, RenameKey, RenameKeyResponse, Resync, ResyncResponse, SecretKeyInfo, - SetWalletEmoji, SetWalletEmojiResponse, + ChangePassword, ChangePasswordResponse, DeleteDatabase, DeleteDatabaseResponse, DeleteKey, + DeleteKeyResponse, GenerateMnemonic, GenerateMnemonicResponse, GetKey, GetKeyResponse, GetKeys, + GetKeysResponse, GetSecretKey, GetSecretKeyResponse, ImportKey, ImportKeyResponse, KeyInfo, + KeyKind, Login, LoginResponse, Logout, LogoutResponse, RenameKey, RenameKeyResponse, Resync, + ResyncResponse, SecretKeyInfo, SetWalletEmoji, SetWalletEmojiResponse, }; use sage_config::Wallet; use sage_database::{Database, Derivation}; @@ -122,6 +122,7 @@ impl Sage { } pub async fn import_key(&mut self, req: ImportKey) -> Result { + let password_bytes = req.password.unwrap_or_default().into_bytes(); let mut key_hex = req.key.as_str(); if key_hex.starts_with("0x") || key_hex.starts_with("0X") { @@ -138,7 +139,7 @@ impl Sage { let master_pk = master_sk.public_key(); let fingerprint = if req.save_secrets { - self.keychain.add_secret_key(&master_sk, b"")? + self.keychain.add_secret_key(&master_sk, &password_bytes)? } else { self.keychain.add_public_key(&master_pk)? }; @@ -175,7 +176,7 @@ impl Sage { let master_sk = SecretKey::from_seed(&mnemonic.to_seed("")); let master_pk = master_sk.public_key(); let fingerprint = if req.save_secrets { - self.keychain.add_mnemonic(&mnemonic, b"")? + self.keychain.add_mnemonic(&mnemonic, &password_bytes)? } else { self.keychain.add_public_key(&master_pk)? }; @@ -343,6 +344,7 @@ impl Sage { public_key: hex::encode(master_pk.to_bytes()), kind: KeyKind::Bls, has_secrets: self.keychain.has_secret_key(fingerprint), + has_password: self.keychain.is_password_protected(fingerprint), network_id, emoji: wallet_config.emoji, }), @@ -350,7 +352,8 @@ impl Sage { } pub fn get_secret_key(&self, req: GetSecretKey) -> Result { - let (mnemonic, Some(secret_key)) = self.keychain.extract_secrets(req.fingerprint, b"")? + let password = req.password.unwrap_or_default().into_bytes(); + let (mnemonic, Some(secret_key)) = self.keychain.extract_secrets(req.fingerprint, &password)? else { return Ok(GetSecretKeyResponse { secrets: None }); }; @@ -363,6 +366,15 @@ impl Sage { }) } + pub fn change_password(&mut self, req: ChangePassword) -> Result { + let old_password = req.old_password.into_bytes(); + let new_password = req.new_password.into_bytes(); + self.keychain + .change_password(req.fingerprint, &old_password, &new_password)?; + self.save_keychain()?; + Ok(ChangePasswordResponse {}) + } + pub fn get_keys(&self, _req: GetKeys) -> Result { let mut keys = Vec::new(); @@ -377,6 +389,7 @@ impl Sage { public_key: hex::encode(master_pk.to_bytes()), kind: KeyKind::Bls, has_secrets: self.keychain.has_secret_key(wallet.fingerprint), + has_password: self.keychain.is_password_protected(wallet.fingerprint), network_id: wallet.network.clone().unwrap_or_else(|| self.network_id()), emoji: wallet.emoji.clone(), }); diff --git a/crates/sage/src/endpoints/offers.rs b/crates/sage/src/endpoints/offers.rs index 974898e5c..f379c45b5 100644 --- a/crates/sage/src/endpoints/offers.rs +++ b/crates/sage/src/endpoints/offers.rs @@ -39,6 +39,7 @@ struct AssetToOffer { impl Sage { pub async fn make_offer(&self, req: MakeOffer) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let selected_coin_ids = parse_coin_ids(req.coin_ids.unwrap_or_default())?; @@ -167,7 +168,7 @@ impl Sage { .await?; let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, b"")? + self.keychain.extract_secrets(wallet.fingerprint, &password)? else { return Err(Error::NoSigningKey); }; @@ -197,6 +198,7 @@ impl Sage { } pub async fn take_offer(&self, req: TakeOffer) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let offer = decode_offer(&req.offer)?; @@ -205,7 +207,7 @@ impl Sage { let unsigned = wallet.take_offer(offer, fee).await?; let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, b"")? + self.keychain.extract_secrets(wallet.fingerprint, &password)? else { return Err(Error::NoSigningKey); }; @@ -699,6 +701,7 @@ impl Sage { } pub async fn cancel_offer(&self, req: CancelOffer) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let offer_id = parse_offer_id(req.offer_id)?; let fee = parse_amount(req.fee)?; @@ -710,10 +713,11 @@ impl Sage { let offer = decode_offer(&row.encoded_offer)?; let coin_spends = wallet.cancel_offer(offer, fee).await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn cancel_offers(&self, req: CancelOffers) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let fee = parse_amount(req.fee)?; @@ -735,6 +739,6 @@ impl Sage { coin_spends.extend(spends); } - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } } diff --git a/crates/sage/src/endpoints/transactions.rs b/crates/sage/src/endpoints/transactions.rs index 69a78315f..61ab6ab34 100644 --- a/crates/sage/src/endpoints/transactions.rs +++ b/crates/sage/src/endpoints/transactions.rs @@ -28,6 +28,7 @@ use crate::{ impl Sage { pub async fn send_xch(&self, req: SendXch) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let puzzle_hash = self.parse_address(req.address)?; let amount = parse_amount(req.amount)?; @@ -37,10 +38,11 @@ impl Sage { let coin_spends = wallet .send_xch(vec![(puzzle_hash, amount)], fee, memos, req.clawback) .await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn bulk_send_xch(&self, req: BulkSendXch) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let amount = parse_amount(req.amount)?; @@ -55,19 +57,21 @@ impl Sage { let memos = parse_memos(req.memos)?; let coin_spends = wallet.send_xch(amounts, fee, memos, None).await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn combine(&self, req: Combine) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let fee = parse_amount(req.fee)?; let coin_ids = parse_coin_ids(req.coin_ids)?; let coin_spends = wallet.combine(coin_ids, fee).await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn auto_combine_xch(&self, req: AutoCombineXch) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let fee = parse_amount(req.fee)?; let max_amount = req.max_coin_amount.map(parse_amount).transpose()?; @@ -95,7 +99,7 @@ impl Sage { let coin_spends = wallet .combine(coins.iter().map(Coin::coin_id).collect(), fee) .await?; - let response = self.transact(coin_spends, req.auto_submit).await?; + let response = self.transact(coin_spends, req.auto_submit, &password).await?; Ok(AutoCombineXchResponse { coin_ids, @@ -105,6 +109,7 @@ impl Sage { } pub async fn split(&self, req: Split) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let fee = parse_amount(req.fee)?; let coin_ids = parse_coin_ids(req.coin_ids)?; @@ -112,10 +117,11 @@ impl Sage { let coin_spends = wallet .split(coin_ids, req.output_count as usize, fee) .await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn auto_combine_cat(&self, req: AutoCombineCat) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let fee = parse_amount(req.fee)?; let asset_id = parse_asset_id(req.asset_id)?; @@ -144,7 +150,7 @@ impl Sage { let coin_spends = wallet .combine(cats.iter().map(|row| row.coin.coin_id()).collect(), fee) .await?; - let response = self.transact(coin_spends, req.auto_submit).await?; + let response = self.transact(coin_spends, req.auto_submit, &password).await?; Ok(AutoCombineCatResponse { coin_ids, @@ -154,6 +160,7 @@ impl Sage { } pub async fn issue_cat(&self, req: IssueCat) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let amount = parse_amount(req.amount)?; let fee = parse_amount(req.fee)?; @@ -176,10 +183,11 @@ impl Sage { .await?; tx.commit().await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn send_cat(&self, req: SendCat) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let asset_id = parse_asset_id(req.asset_id)?; let puzzle_hash = self.parse_address(req.address)?; @@ -197,10 +205,11 @@ impl Sage { req.clawback, ) .await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn bulk_send_cat(&self, req: BulkSendCat) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let asset_id = parse_asset_id(req.asset_id)?; @@ -218,10 +227,11 @@ impl Sage { let coin_spends = wallet .send_cat(asset_id, amounts, fee, req.include_hint, memos, None) .await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn multi_send(&self, req: MultiSend) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let mut payments = Vec::with_capacity(req.payments.len()); @@ -247,10 +257,11 @@ impl Sage { let fee = parse_amount(req.fee)?; let coin_spends = wallet.multi_send(payments, fee).await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn create_did(&self, req: CreateDid) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let fee = parse_amount(req.fee)?; @@ -272,11 +283,12 @@ impl Sage { }) .await?; - self.transact_with(coin_spends, req.auto_submit, ConfirmationInfo::default()) + self.transact_with(coin_spends, req.auto_submit, ConfirmationInfo::default(), &password) .await } pub async fn bulk_mint_nfts(&self, req: BulkMintNfts) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let fee = parse_amount(req.fee)?; let did_id = parse_did_id(req.did_id)?; @@ -297,7 +309,7 @@ impl Sage { } let response = self - .transact_with(coin_spends, req.auto_submit, info) + .transact_with(coin_spends, req.auto_submit, info, &password) .await?; Ok(BulkMintNftsResponse { @@ -308,6 +320,7 @@ impl Sage { } pub async fn transfer_nfts(&self, req: TransferNfts) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let nft_ids = req .nft_ids @@ -320,10 +333,11 @@ impl Sage { let coin_spends = wallet .transfer_nfts(nft_ids, puzzle_hash, fee, req.clawback) .await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn add_nft_uri(&self, req: AddNftUri) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let nft_id = parse_nft_id(req.nft_id)?; let fee = parse_amount(req.fee)?; @@ -344,10 +358,11 @@ impl Sage { }; let coin_spends = wallet.add_nft_uri(nft_id, fee, uri).await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn assign_nfts_to_did(&self, req: AssignNftsToDid) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let nft_ids = req .nft_ids @@ -358,10 +373,11 @@ impl Sage { let fee = parse_amount(req.fee)?; let coin_spends = wallet.assign_nfts(nft_ids, did_id, fee).await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn transfer_dids(&self, req: TransferDids) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let did_ids = req .did_ids @@ -374,10 +390,11 @@ impl Sage { let coin_spends = wallet .transfer_dids(did_ids, puzzle_hash, fee, req.clawback) .await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn normalize_dids(&self, req: NormalizeDids) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let did_ids = req .did_ids @@ -387,10 +404,11 @@ impl Sage { let fee = parse_amount(req.fee)?; let coin_spends = wallet.normalize_dids(did_ids, fee).await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn mint_option(&self, req: MintOption) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let fee = parse_amount(req.fee)?; @@ -408,7 +426,7 @@ impl Sage { ) .await?; - let response = self.transact(coin_spends, req.auto_submit).await?; + let response = self.transact(coin_spends, req.auto_submit, &password).await?; Ok(MintOptionResponse { option_id: Address::new(option.info.launcher_id, "option".to_string()).encode()?, @@ -451,6 +469,7 @@ impl Sage { } pub async fn transfer_options(&self, req: TransferOptions) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let option_ids = req .option_ids @@ -463,10 +482,11 @@ impl Sage { let coin_spends = wallet .transfer_options(option_ids, puzzle_hash, fee, req.clawback) .await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn exercise_options(&self, req: ExerciseOptions) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let option_ids = req .option_ids @@ -476,25 +496,27 @@ impl Sage { let fee = parse_amount(req.fee)?; let coin_spends = wallet.exercise_options(option_ids, fee).await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn finalize_clawback(&self, req: FinalizeClawback) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let coin_ids = parse_coin_ids(req.coin_ids)?; let fee = parse_amount(req.fee)?; let coin_spends = wallet.finalize_clawback(coin_ids, fee).await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn sign_coin_spends(&self, req: SignCoinSpends) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let coin_spends = req .coin_spends .into_iter() .map(rust_spend) .collect::>>()?; - let spend_bundle = self.sign(coin_spends, req.partial).await?; + let spend_bundle = self.sign(coin_spends, req.partial, &password).await?; let json_bundle = json_bundle(&spend_bundle); if req.auto_submit { @@ -534,8 +556,9 @@ impl Sage { &self, coin_spends: Vec, auto_submit: bool, + password: &[u8], ) -> Result { - self.transact_with(coin_spends, auto_submit, ConfirmationInfo::default()) + self.transact_with(coin_spends, auto_submit, ConfirmationInfo::default(), password) .await } @@ -544,9 +567,10 @@ impl Sage { coin_spends: Vec, auto_submit: bool, info: ConfirmationInfo, + password: &[u8], ) -> Result { if auto_submit { - let spend_bundle = self.sign(coin_spends.clone(), false).await?; + let spend_bundle = self.sign(coin_spends.clone(), false, password).await?; self.submit(spend_bundle).await?; } diff --git a/crates/sage/src/endpoints/wallet_connect.rs b/crates/sage/src/endpoints/wallet_connect.rs index 24c2aedde..0c0c54a41 100644 --- a/crates/sage/src/endpoints/wallet_connect.rs +++ b/crates/sage/src/endpoints/wallet_connect.rs @@ -170,6 +170,7 @@ impl Sage { &self, req: SignMessageWithPublicKey, ) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let public_key = parse_public_key(req.public_key)?; @@ -178,7 +179,7 @@ impl Sage { }; let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, b"")? + self.keychain.extract_secrets(wallet.fingerprint, &password)? else { return Err(Error::NoSigningKey); }; @@ -205,6 +206,7 @@ impl Sage { &self, req: SignMessageByAddress, ) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let p2_puzzle_hash = self.parse_address(req.address)?; @@ -217,7 +219,7 @@ impl Sage { }; let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, b"")? + self.keychain.extract_secrets(wallet.fingerprint, &password)? else { return Err(Error::NoSigningKey); }; diff --git a/crates/sage/src/error.rs b/crates/sage/src/error.rs index 043f7e0eb..052b1fb01 100644 --- a/crates/sage/src/error.rs +++ b/crates/sage/src/error.rs @@ -247,6 +247,7 @@ impl Error { | KeychainError::Bls(..) | KeychainError::Bip39(..) | KeychainError::Argon2(..) => ErrorKind::Internal, + KeychainError::KeyNotFound | KeychainError::NoSecretKey => ErrorKind::Unauthorized, }, Self::SqlxMigration(..) | Self::DatabaseVersionTooOld => ErrorKind::DatabaseMigration, Self::Send(..) diff --git a/crates/sage/src/utils/spends.rs b/crates/sage/src/utils/spends.rs index d9b19ee7f..e18800669 100644 --- a/crates/sage/src/utils/spends.rs +++ b/crates/sage/src/utils/spends.rs @@ -8,11 +8,12 @@ impl Sage { &self, coin_spends: Vec, partial: bool, + password: &[u8], ) -> Result { let wallet = self.wallet()?; let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, b"")? + self.keychain.extract_secrets(wallet.fingerprint, password)? else { return Err(Error::NoSigningKey); }; From 5b152b662fb1d4b398b7768b9c79fc4f7bd656b0 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 16:25:32 -0500 Subject: [PATCH 08/53] tests --- crates/sage-rpc/src/tests.rs | 178 ++++++++++++++++++++++++++++++++++- 1 file changed, 177 insertions(+), 1 deletion(-) diff --git a/crates/sage-rpc/src/tests.rs b/crates/sage-rpc/src/tests.rs index 8d4df5a98..bbc1b1a6e 100644 --- a/crates/sage-rpc/src/tests.rs +++ b/crates/sage-rpc/src/tests.rs @@ -19,7 +19,10 @@ use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha8Rng; use rustls::crypto::aws_lc_rs::default_provider; use sage::Sage; -use sage_api::{Amount, GetKey, GetPeers, GetSyncStatus, GetVersion, ImportKey, Login, SendXch}; +use sage_api::{ + Amount, ChangePassword, GetKey, GetPeers, GetSecretKey, GetSyncStatus, GetVersion, ImportKey, + Login, SendXch, +}; use sage_api_macro::impl_endpoints; use sage_wallet::{SyncCommand, SyncEvent}; use serde::{Serialize, de::DeserializeOwned}; @@ -173,6 +176,46 @@ impl TestApp { self.consume_until(|event| matches!(event, SyncEvent::PuzzleBatchSynced)) .await; } + + async fn setup_bls_with_password(&mut self, balance: u64, password: &str) -> Result { + let mnemonic = Mnemonic::from_entropy(&self.rng.r#gen::<[u8; 16]>())?; + + if balance > 0 { + let master_sk = SecretKey::from_seed(&mnemonic.to_seed("")); + let p2_puzzle_hash = StandardArgs::curry_tree_hash( + master_to_wallet_unhardened(&master_sk, 0) + .public_key() + .derive_synthetic(), + ); + + self.sim.lock().await.create_block(); + + self.sim + .lock() + .await + .new_coin(p2_puzzle_hash.into(), balance); + } + + let fingerprint = self + .import_key(ImportKey { + name: "Alice".to_string(), + key: mnemonic.to_string(), + derivation_index: 0, + hardened: None, + unhardened: None, + save_secrets: true, + login: true, + emoji: None, + password: Some(password.to_string()), + }) + .await? + .fingerprint; + + self.consume_until(|event| matches!(event, SyncEvent::Subscribed)) + .await; + + Ok(fingerprint) + } } impl_endpoints! { @@ -282,3 +325,136 @@ async fn test_send_xch() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn test_change_password() -> Result<()> { + let mut app = TestApp::new().await?; + let fingerprint = app.setup_bls(0).await?; + + // Initially no password + let key = app + .get_key(GetKey { + fingerprint: Some(fingerprint), + }) + .await? + .key + .unwrap(); + assert!(!key.has_password); + + // Set password + app.change_password(ChangePassword { + fingerprint, + old_password: "".to_string(), + new_password: "secret".to_string(), + }) + .await?; + + // Now has_password should be true + let key = app + .get_key(GetKey { + fingerprint: Some(fingerprint), + }) + .await? + .key + .unwrap(); + assert!(key.has_password); + + // Getting secret without password should fail + let result = app + .get_secret_key(GetSecretKey { + fingerprint, + password: None, + }) + .await; + assert!(result.is_err()); + + // Getting secret with correct password should work + let result = app + .get_secret_key(GetSecretKey { + fingerprint, + password: Some("secret".to_string()), + }) + .await?; + assert!(result.secrets.is_some()); + + Ok(()) +} + +#[tokio::test] +async fn test_password_protected_send() -> Result<()> { + let mut app = TestApp::new().await?; + + let alice = app.setup_bls_with_password(1000, "secret").await?; + + let _bob = app.setup_bls(1000).await?; + let bob_address = app.get_sync_status(GetSyncStatus {}).await?.receive_address; + + app.login(Login { fingerprint: alice }).await?; + app.wait_for_coins().await; + + // Send without password should fail (auto_submit requires signing) + let result = app + .send_xch(SendXch { + address: bob_address.clone(), + amount: Amount::u64(100), + fee: Amount::u64(0), + memos: vec![], + clawback: None, + auto_submit: true, + password: None, + }) + .await; + assert!(result.is_err()); + + // Send with correct password should succeed + app.send_xch(SendXch { + address: bob_address, + amount: Amount::u64(100), + fee: Amount::u64(0), + memos: vec![], + clawback: None, + auto_submit: true, + password: Some("secret".to_string()), + }) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn test_password_protected_import() -> Result<()> { + let mut app = TestApp::new().await?; + + let fingerprint = app.setup_bls_with_password(0, "mypassword").await?; + + // Verify has_password is true + let key = app + .get_key(GetKey { + fingerprint: Some(fingerprint), + }) + .await? + .key + .unwrap(); + assert!(key.has_password); + assert!(key.has_secrets); + + // Getting secret with wrong password should fail + let result = app + .get_secret_key(GetSecretKey { + fingerprint, + password: Some("wrongpassword".to_string()), + }) + .await; + assert!(result.is_err()); + + // Getting secret with correct password should work + let result = app + .get_secret_key(GetSecretKey { + fingerprint, + password: Some("mypassword".to_string()), + }) + .await?; + assert!(result.secrets.is_some()); + + Ok(()) +} From cb481fc7e25955ef2373979b45da8a1c555ee6e7 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 17:22:14 -0500 Subject: [PATCH 09/53] frontend changes --- .../plans/2026-03-15-password-protection.md | 22 ++ .../2026-03-15-password-protection-design.md | 34 ++- src-tauri/src/lib.rs | 1 + src/App.tsx | 21 +- src/bindings.ts | 211 +++++++++++-- src/components/ConfirmationDialog.tsx | 16 + src/components/OfferRowCard.tsx | 10 +- src/components/WalletCard.tsx | 26 +- src/components/dialogs/PasswordDialog.tsx | 86 ++++++ src/contexts/ErrorContext.tsx | 9 +- src/contexts/PasswordContext.tsx | 61 ++++ src/contexts/WalletConnectContext.tsx | 16 +- src/hooks/useOfferProcessor.ts | 12 + src/hooks/usePassword.ts | 10 + src/pages/Offer.tsx | 8 + src/pages/Offers.tsx | 10 +- src/pages/Settings.tsx | 277 +++++++++++++++++- src/walletconnect/commands/chip0002.ts | 11 +- src/walletconnect/commands/high-level.ts | 17 +- src/walletconnect/commands/offers.ts | 15 + src/walletconnect/handler.ts | 2 + 21 files changed, 795 insertions(+), 80 deletions(-) create mode 100644 src/components/dialogs/PasswordDialog.tsx create mode 100644 src/contexts/PasswordContext.tsx create mode 100644 src/hooks/usePassword.ts diff --git a/docs/superpowers/plans/2026-03-15-password-protection.md b/docs/superpowers/plans/2026-03-15-password-protection.md index a442ae385..281fbca95 100644 --- a/docs/superpowers/plans/2026-03-15-password-protection.md +++ b/docs/superpowers/plans/2026-03-15-password-protection.md @@ -17,6 +17,7 @@ ### Task 1: Add `password_protected` to `KeyData::Secret` **Files:** + - Modify: `crates/sage-keychain/src/key_data.rs:9-20` - [ ] **Step 1: Add `password_protected` field to `KeyData::Secret`** @@ -101,6 +102,7 @@ Expected: compilation errors in `keychain.rs` where `KeyData::Secret` is constru In `crates/sage-keychain/src/keychain.rs`, update `add_secret_key()` (line ~138) and `add_mnemonic()` (line ~166) to include `password_protected`: For `add_secret_key()`: + ```rust self.keys.insert( fingerprint, @@ -114,6 +116,7 @@ self.keys.insert( ``` For `add_mnemonic()`: + ```rust self.keys.insert( fingerprint, @@ -154,6 +157,7 @@ git commit -m "feat: add password_protected flag to KeyData::Secret" ### Task 2: Add `change_password` to `Keychain` **Files:** + - Modify: `crates/sage-keychain/src/keychain.rs` - [ ] **Step 1: Write a test for `change_password`** @@ -369,6 +373,7 @@ git commit -m "feat: add change_password and keychain tests" ### Task 3: Add `password` field to all protected request structs **Files:** + - Modify: `crates/sage-api/src/requests/keys.rs` - Modify: `crates/sage-api/src/requests/transactions.rs` - Modify: `crates/sage-api/src/requests/offers.rs` @@ -381,6 +386,7 @@ git commit -m "feat: add change_password and keychain tests" In `crates/sage-api/src/requests/keys.rs`: `GetSecretKey` — change from `Copy + Serialize, Deserialize` to just `Clone + Serialize, Deserialize` (since `Option` is not `Copy`), add: + ```rust pub struct GetSecretKey { pub fingerprint: u32, @@ -391,6 +397,7 @@ pub struct GetSecretKey { ``` `ImportKey` — add: + ```rust #[serde(default)] #[cfg_attr(feature = "openapi", schema(nullable = true))] @@ -448,6 +455,7 @@ git commit -m "feat: add password field to all protected request structs" ### Task 4: Add `ChangePassword` request/response and `has_password` to `KeyInfo` **Files:** + - Modify: `crates/sage-api/src/requests/keys.rs` - Modify: `crates/sage-api/src/types/key_info.rs` @@ -535,6 +543,7 @@ git commit -m "feat: add ChangePassword endpoint and has_password to KeyInfo" ### Task 5: Update `sign()` and `transact()` to accept passwords **Files:** + - Modify: `crates/sage/src/utils/spends.rs` - Modify: `crates/sage/src/endpoints/transactions.rs` @@ -636,6 +645,7 @@ let spend_bundle = self.sign(coin_spends, req.partial, &password).await?; ### Task 6: Thread password through offers, actions, wallet_connect, and keys **Files:** + - Modify: `crates/sage/src/endpoints/offers.rs` - Modify: `crates/sage/src/endpoints/actions.rs` - Modify: `crates/sage/src/endpoints/wallet_connect.rs` @@ -647,6 +657,7 @@ let spend_bundle = self.sign(coin_spends, req.partial, &password).await?; `make_offer()` and `take_offer()` call `extract_secrets` directly. `cancel_offer()` and `cancel_offers()` call `transact()`. Update all four: For `make_offer` and `take_offer`: + ```rust let password = req.password.unwrap_or_default().into_bytes(); // ... @@ -655,6 +666,7 @@ let (_mnemonic, Some(master_sk)) = ``` For `cancel_offer` and `cancel_offers`: + ```rust let password = req.password.unwrap_or_default().into_bytes(); // ... pass &password to self.transact() @@ -663,6 +675,7 @@ let password = req.password.unwrap_or_default().into_bytes(); - [ ] **Step 2: Update `actions.rs`** In `increase_derivation_index()`: + ```rust let password = req.password.unwrap_or_default().into_bytes(); // ... @@ -673,6 +686,7 @@ let (_mnemonic, Some(master_sk)) = - [ ] **Step 3: Update `wallet_connect.rs`** Both `sign_message_with_public_key` and `sign_message_by_address`: + ```rust let password = req.password.unwrap_or_default().into_bytes(); // ... @@ -683,6 +697,7 @@ let (_mnemonic, Some(master_sk)) = - [ ] **Step 4: Update `keys.rs`** `import_key()` — pass password to `add_secret_key` and `add_mnemonic`: + ```rust let password_bytes = req.password.unwrap_or_default().into_bytes(); // ... @@ -692,12 +707,14 @@ self.keychain.add_mnemonic(&mnemonic, &password_bytes)? ``` `get_secret_key()` — pass password to `extract_secrets`: + ```rust let password = req.password.unwrap_or_default().into_bytes(); let (mnemonic, Some(secret_key)) = self.keychain.extract_secrets(req.fingerprint, &password)? ``` `get_key()` and `get_keys()` — populate `has_password` on `KeyInfo`: + ```rust has_password: self.keychain.is_password_protected(fingerprint), ``` @@ -705,6 +722,7 @@ has_password: self.keychain.is_password_protected(fingerprint), - [ ] **Step 5: Update `action_system.rs`** In the `create_transaction()` method: + ```rust let password = req.password.unwrap_or_default().into_bytes(); // ... pass &password to self.transact_with() @@ -739,6 +757,7 @@ git commit -m "feat: thread password through all protected endpoints" ### Task 7: Add `change_password` endpoint **Files:** + - Modify: `crates/sage/src/endpoints/keys.rs` - [ ] **Step 1: Implement `change_password` endpoint** @@ -780,6 +799,7 @@ git commit -m "feat: add change_password endpoint" ### Task 8: Add integration tests for password protection **Files:** + - Modify: `crates/sage-rpc/src/tests.rs` - [ ] **Step 1: Add test for password-protected key import and secret retrieval** @@ -926,6 +946,7 @@ git commit -m "test: add integration tests for password protection" ### Task 9: Ensure Tauri commands compile with new request structs **Files:** + - Modify: `src-tauri/src/commands.rs` (if needed — the macro should auto-generate) - [ ] **Step 1: Check if the Tauri command layer auto-generates from the endpoint macro** @@ -961,6 +982,7 @@ This task should be planned separately once the backend is complete and tested, - [ ] **Step 1: Document the frontend contract** Create a brief document listing: + - All Tauri command names that now accept `password` - The `has_password` field on `KeyInfo` for conditional prompting - The `change_password` command for settings UI diff --git a/docs/superpowers/specs/2026-03-15-password-protection-design.md b/docs/superpowers/specs/2026-03-15-password-protection-design.md index 91d163324..2aed06725 100644 --- a/docs/superpowers/specs/2026-03-15-password-protection-design.md +++ b/docs/superpowers/specs/2026-03-15-password-protection-design.md @@ -57,8 +57,8 @@ There are 7 code points where `b""` is passed to `extract_secrets` or `add_mnemo ### 1. Display mnemonic/secret key (1 site) -| Call site | Function | -|-----------|----------| +| Call site | Function | +| --------------------------------------- | ------------------ | | `crates/sage/src/endpoints/keys.rs:353` | `get_secret_key()` | ### 2. Sign transactions and offers @@ -71,13 +71,13 @@ endpoint method → transact() / transact_with() → sign() → extract_secrets( **Direct `extract_secrets` call sites** (5 sites): -| Call site | Function | -|-----------|----------| -| `crates/sage/src/utils/spends.rs:15` | `sign()` — called by `transact_with()` and `sign_coin_spends()` | -| `crates/sage/src/endpoints/offers.rs:170` | `make_offer()` — calls `extract_secrets` directly | -| `crates/sage/src/endpoints/offers.rs:208` | `take_offer()` — calls `extract_secrets` directly | -| `crates/sage/src/endpoints/wallet_connect.rs:181` | `sign_message_with_public_key()` | -| `crates/sage/src/endpoints/wallet_connect.rs:220` | `sign_message_by_address()` | +| Call site | Function | +| ------------------------------------------------- | --------------------------------------------------------------- | +| `crates/sage/src/utils/spends.rs:15` | `sign()` — called by `transact_with()` and `sign_coin_spends()` | +| `crates/sage/src/endpoints/offers.rs:170` | `make_offer()` — calls `extract_secrets` directly | +| `crates/sage/src/endpoints/offers.rs:208` | `take_offer()` — calls `extract_secrets` directly | +| `crates/sage/src/endpoints/wallet_connect.rs:181` | `sign_message_with_public_key()` | +| `crates/sage/src/endpoints/wallet_connect.rs:220` | `sign_message_by_address()` | **Transaction endpoints that flow through `transact()` → `sign()`** (21 endpoints): @@ -87,16 +87,16 @@ Plus `cancel_offer`, `cancel_offers`, and `create_transaction` (action system) w ### 3. Generate hardened keys (1 site) -| Call site | Function | -|-----------|----------| +| Call site | Function | +| ------------------------------------------ | ----------------------------- | | `crates/sage/src/endpoints/actions.rs:201` | `increase_derivation_index()` | ### 4. Key import — encrypt at creation (2 sites) -| Call site | Function | -|-----------|----------| +| Call site | Function | +| --------------------------------------- | -------------------------------- | | `crates/sage/src/endpoints/keys.rs:141` | `import_key()` — secret key path | -| `crates/sage/src/endpoints/keys.rs:178` | `import_key()` — mnemonic path | +| `crates/sage/src/endpoints/keys.rs:178` | `import_key()` — mnemonic path | Note: `import_key()` also generates hardened derivations using the in-memory master key during import. This does NOT need the password since the key is already decrypted at that point. @@ -135,22 +135,28 @@ Note: this changes the `keys.bin` serialization format. Existing files will fail Add `password: Option` to **all request structs that trigger signing, secret access, or key import**: **Direct secret access:** + - `ImportKey` - `GetSecretKey` **Signing via `transact()` path — all transaction request structs:** + - `SendXch`, `BulkSendXch`, `Combine`, `AutoCombineXch`, `Split`, `AutoCombineCat`, `IssueCat`, `SendCat`, `BulkSendCat`, `MultiSend`, `CreateDid`, `BulkMintNfts`, `TransferNfts`, `AddNftUri`, `AssignNftsToDid`, `TransferDids`, `NormalizeDids`, `MintOption`, `TransferOptions`, `ExerciseOptions`, `FinalizeClawback` **Signing via direct `extract_secrets` or `sign()`:** + - `SignCoinSpends`, `MakeOffer`, `TakeOffer`, `CancelOffer`, `CancelOffers`, `CreateTransaction` **Hardened derivation:** + - `IncreaseDerivationIndex` **WalletConnect signing:** + - `SignMessageWithPublicKey`, `SignMessageByAddress` **New request/response pair:** + - `ChangePassword { fingerprint: u32, old_password: String, new_password: String }` - `ChangePasswordResponse {}` diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a4f2bd12a..e37d25550 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -141,6 +141,7 @@ pub fn run() { commands::download_cni_offercode, commands::get_logs, commands::is_asset_owned, + commands::change_password, ]) .events(collect_events![SyncEvent]); diff --git a/src/App.tsx b/src/App.tsx index 6fefeeff2..d6c4076c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { ThemeProvider, useTheme } from 'theme-o-rama'; import { useLocalStorage } from 'usehooks-ts'; import { BiometricProvider } from './contexts/BiometricContext'; import { ErrorProvider } from './contexts/ErrorContext'; +import { PasswordProvider } from './contexts/PasswordContext'; import { getBrowserLanguage, LanguageProvider, @@ -189,15 +190,17 @@ function AppInner() { initialized && isLocaleInitialized && ( - - - - - - - - - + + + + + + + + + + + ) ); diff --git a/src/bindings.ts b/src/bindings.ts index 45ad7bbf5..26b5714f6 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -358,6 +358,9 @@ async getLogs() : Promise { }, async isAssetOwned(req: IsAssetOwned) : Promise { return await TAURI_INVOKE("is_asset_owned", { req }); +}, +async changePassword(req: ChangePassword) : Promise { + return await TAURI_INVOKE("change_password", { req }); } } @@ -400,7 +403,11 @@ kind: NftUriKind; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Add a new peer to connect to */ @@ -436,7 +443,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Automatically combine CAT coins */ @@ -460,7 +471,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response for auto-combine CAT */ @@ -496,7 +511,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response for auto-combine XCH */ @@ -532,7 +551,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response for bulk NFT minting */ @@ -580,7 +603,11 @@ memos?: string[]; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Send XCH to multiple addresses */ @@ -604,7 +631,11 @@ memos?: string[]; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Cancel an offer on-chain */ @@ -620,7 +651,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Cancel multiple offers */ @@ -636,7 +671,31 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } +/** + * Change the password for a wallet's secret key + */ +export type ChangePassword = { +/** + * Wallet fingerprint + */ +fingerprint: number; +/** + * Current password (empty string if no password is set) + */ +old_password: string; +/** + * New password (empty string to remove password protection) + */ +new_password: string } +/** + * Response after changing the password + */ +export type ChangePasswordResponse = Record /** * Validate and check an address */ @@ -705,7 +764,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Combine multiple offers */ @@ -737,7 +800,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } export type CreateTransaction = { /** * Pre-selected coins to use in the transaction prior to coin selection @@ -750,7 +817,11 @@ actions: Action[]; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Delete a wallet database */ @@ -820,7 +891,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } export type FeeAction = { /** * The fee amount, in mojos @@ -857,7 +932,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Generate a new mnemonic phrase for wallet creation */ @@ -1425,7 +1504,11 @@ export type GetSecretKey = { /** * Wallet fingerprint */ -fingerprint: number } +fingerprint: number; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response with secret key information */ @@ -1648,7 +1731,11 @@ login?: boolean; /** * Optional emoji identifier */ -emoji?: string | null } +emoji?: string | null; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response with imported key fingerprint */ @@ -1688,7 +1775,11 @@ unhardened?: boolean | null; /** * The target derivation index to increase to */ -index: number } +index: number; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response after increasing the derivation index */ @@ -1733,8 +1824,12 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } -export type KeyInfo = { name: string; fingerprint: number; public_key: string; kind: KeyKind; has_secrets: boolean; network_id: string; emoji: string | null } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } +export type KeyInfo = { name: string; fingerprint: number; public_key: string; kind: KeyKind; has_secrets: boolean; has_password: boolean; network_id: string; emoji: string | null } export type KeyKind = "bls" /** * Lineage proof for CAT coins @@ -1804,7 +1899,11 @@ auto_import?: boolean; /** * Optional specific coin IDs to use for the offer instead of auto-selecting */ -coin_ids?: string[] | null } +coin_ids?: string[] | null; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response with created offer */ @@ -1885,7 +1984,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response for minting an option */ @@ -1993,7 +2096,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Asset amount in an offer */ @@ -2221,7 +2328,11 @@ clawback?: number | null; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Send a transaction immediately */ @@ -2269,7 +2380,11 @@ clawback?: number | null; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Set the change address for transactions */ @@ -2369,7 +2484,11 @@ auto_submit?: boolean; /** * Whether to partially sign (for multi-signature) */ -partial?: boolean } +partial?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response with signed spend bundle */ @@ -2389,7 +2508,11 @@ message: string; /** * Address whose key to use */ -address: string } +address: string; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response with signed message */ @@ -2413,7 +2536,11 @@ message: string; /** * Public key to use for signing */ -publicKey: string } +publicKey: string; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response with message signature */ @@ -2482,7 +2609,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Submit a transaction to the network */ @@ -2511,7 +2642,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response with accepted offer details */ @@ -2569,7 +2704,11 @@ clawback?: number | null; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Transfer NFTs to a new owner */ @@ -2593,7 +2732,11 @@ clawback?: number | null; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Transfer options to another address */ @@ -2617,7 +2760,11 @@ clawback?: number | null; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } export type Unit = { ticker: string; precision: number } /** * Update a `CAT` token's metadata and visibility diff --git a/src/components/ConfirmationDialog.tsx b/src/components/ConfirmationDialog.tsx index c2f7de3bd..13db8eb1c 100644 --- a/src/components/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog.tsx @@ -10,6 +10,8 @@ import { import { LoadingButton } from '@/components/ui/loading-button'; import { useBiometric } from '@/hooks/useBiometric'; import { useErrors } from '@/hooks/useErrors'; +import { usePassword } from '@/hooks/usePassword'; +import { useWallet } from '@/contexts/WalletContext'; import { fromMojos } from '@/lib/utils'; import { useWalletState } from '@/state'; import { t } from '@lingui/core/macro'; @@ -65,6 +67,8 @@ export default function ConfirmationDialog({ const { addError } = useErrors(); const { promptIfEnabled } = useBiometric(); + const { requestPassword } = usePassword(); + const { wallet } = useWallet(); const [pending, setPending] = useState(false); const [signature, setSignature] = useState(null); @@ -515,6 +519,11 @@ export default function ConfirmationDialog({ + + + + + ); +} diff --git a/src/contexts/ErrorContext.tsx b/src/contexts/ErrorContext.tsx index ec0efcc86..d608d6599 100644 --- a/src/contexts/ErrorContext.tsx +++ b/src/contexts/ErrorContext.tsx @@ -7,7 +7,9 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { t } from '@lingui/core/macro'; import { createContext, ReactNode, useCallback, useState } from 'react'; +import { toast } from 'react-toastify'; import { ErrorKind } from '../bindings'; export interface CustomError { @@ -28,9 +30,12 @@ export function ErrorProvider({ children }: { children: ReactNode }) { const [errors, setErrors] = useState([]); const addError = useCallback((error: CustomError) => { - // Skip unauthorized errors - they're expected during wallet transitions - // and redundant when not logged in (user already knows they need to log in) if (error.kind === 'unauthorized') { + // Wrong password errors surface as a toast so the user knows what happened + if (error.reason?.includes('decrypt')) { + toast.error(t`Incorrect password`); + } + // Other unauthorized errors are expected during wallet transitions return; } setErrors((prevErrors) => [...prevErrors, error]); diff --git a/src/contexts/PasswordContext.tsx b/src/contexts/PasswordContext.tsx new file mode 100644 index 000000000..0b84f6926 --- /dev/null +++ b/src/contexts/PasswordContext.tsx @@ -0,0 +1,61 @@ +import { PasswordDialog } from '@/components/dialogs/PasswordDialog'; +import { createContext, ReactNode, useCallback, useRef, useState } from 'react'; + +interface PasswordRequest { + resolve: (password: string | null) => void; +} + +export interface PasswordContextType { + /** + * If the wallet has a password, shows the password dialog and returns the entered password. + * If it doesn't, returns null immediately. + * Returns null if the user cancels. + */ + requestPassword: (hasPassword: boolean) => Promise; +} + +export const PasswordContext = createContext( + undefined, +); + +export function PasswordProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + const pendingRef = useRef(null); + + const requestPassword = useCallback( + (hasPassword: boolean): Promise => { + if (!hasPassword) { + return Promise.resolve(null); + } + + return new Promise((resolve) => { + pendingRef.current = { resolve }; + setOpen(true); + }); + }, + [], + ); + + const handleSubmit = useCallback((password: string) => { + setOpen(false); + pendingRef.current?.resolve(password); + pendingRef.current = null; + }, []); + + const handleCancel = useCallback(() => { + setOpen(false); + pendingRef.current?.resolve(null); + pendingRef.current = null; + }, []); + + return ( + + {children} + + + ); +} diff --git a/src/contexts/WalletConnectContext.tsx b/src/contexts/WalletConnectContext.tsx index a713b5ac1..88ebe16cb 100644 --- a/src/contexts/WalletConnectContext.tsx +++ b/src/contexts/WalletConnectContext.tsx @@ -22,6 +22,7 @@ import { Switch } from '@/components/ui/switch'; import { useWallet } from '@/contexts/WalletContext'; import { useBiometric } from '@/hooks/useBiometric'; import { useErrors } from '@/hooks/useErrors'; +import { usePassword } from '@/hooks/usePassword'; import { decodeHexMessage, fromMojos, isHex } from '@/lib/utils'; import { useWalletState } from '@/state'; import { @@ -65,6 +66,7 @@ export function WalletConnectProvider({ children }: { children: ReactNode }) { const { wallet } = useWallet(); const { addError } = useErrors(); const { promptIfEnabled } = useBiometric(); + const { requestPassword } = usePassword(); const [signClient, setSignClient] = useState @@ -103,7 +105,11 @@ export function WalletConnectProvider({ children }: { children: ReactNode }) { const result = await handleCommand( method, request.params.request.params, - { promptIfEnabled }, + { + promptIfEnabled, + requestPassword, + hasPassword: wallet?.has_password ?? false, + }, ); await signClient.respond({ @@ -137,7 +143,13 @@ export function WalletConnectProvider({ children }: { children: ReactNode }) { }); } }, - [signClient, addError, promptIfEnabled], + [ + signClient, + addError, + promptIfEnabled, + requestPassword, + wallet?.has_password, + ], ); useEffect(() => { diff --git a/src/hooks/useOfferProcessor.ts b/src/hooks/useOfferProcessor.ts index 2f0aa21cd..47ed0aaae 100644 --- a/src/hooks/useOfferProcessor.ts +++ b/src/hooks/useOfferProcessor.ts @@ -1,5 +1,7 @@ import { commands, OfferAmount } from '@/bindings'; +import { useWallet } from '@/contexts/WalletContext'; import { useBiometric } from '@/hooks/useBiometric'; +import { usePassword } from '@/hooks/usePassword'; import { toMojos } from '@/lib/utils'; import { OfferState, useWalletState } from '@/state'; import { t } from '@lingui/core/macro'; @@ -28,6 +30,8 @@ export function useOfferProcessor({ }: UseOfferProcessorProps): UseOfferProcessorReturn { const walletState = useWalletState(); const { promptIfEnabled } = useBiometric(); + const { requestPassword } = usePassword(); + const { wallet } = useWallet(); const [createdOffers, setCreatedOffers] = useState([]); const [isProcessing, setIsProcessing] = useState(false); const isCancelled = useRef(false); @@ -60,6 +64,11 @@ export function useOfferProcessor({ expiresAtSecond = Math.ceil(Date.now() / 1000) + totalSeconds; } + const password = await requestPassword(wallet?.has_password ?? false); + if (password === null && wallet?.has_password) { + throw new Error(t`Password authentication was cancelled`); + } + if (!(await promptIfEnabled())) { throw new Error(t`Biometric authentication failed or was cancelled`); } @@ -118,6 +127,7 @@ export function useOfferProcessor({ walletState.sync.unit.precision, ), expires_at_second: expiresAtSecond, + password, }); if (!isCancelled.current) { newOffers.push(data.offer); @@ -181,6 +191,8 @@ export function useOfferProcessor({ splitNftOffers, walletState.sync.unit.precision, promptIfEnabled, + requestPassword, + wallet?.has_password, onProcessingEnd, onProgress, ]); diff --git a/src/hooks/usePassword.ts b/src/hooks/usePassword.ts new file mode 100644 index 000000000..fff7f2b79 --- /dev/null +++ b/src/hooks/usePassword.ts @@ -0,0 +1,10 @@ +import { PasswordContext } from '@/contexts/PasswordContext'; +import { useContext } from 'react'; + +export function usePassword() { + const context = useContext(PasswordContext); + if (!context) { + throw new Error('usePassword must be used within a PasswordProvider'); + } + return context; +} diff --git a/src/pages/Offer.tsx b/src/pages/Offer.tsx index f71ae8f65..490a4e7d8 100644 --- a/src/pages/Offer.tsx +++ b/src/pages/Offer.tsx @@ -14,7 +14,9 @@ import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { FeeAmountInput } from '@/components/ui/masked-input'; import { CustomError } from '@/contexts/ErrorContext'; +import { useWallet } from '@/contexts/WalletContext'; import { useErrors } from '@/hooks/useErrors'; +import { usePassword } from '@/hooks/usePassword'; import { resolveOfferData } from '@/lib/offerData'; import { toMojos } from '@/lib/utils'; import { useWalletState } from '@/state'; @@ -26,6 +28,8 @@ import { useNavigate, useParams } from 'react-router-dom'; export function Offer() { const { offer } = useParams(); const { addError } = useErrors(); + const { requestPassword } = usePassword(); + const { wallet } = useWallet(); const walletState = useWalletState(); const navigate = useNavigate(); @@ -96,9 +100,13 @@ export function Offer() { } try { + const password = await requestPassword(wallet?.has_password ?? false); + if (password === null && wallet?.has_password) return; + const result = await commands.takeOffer({ offer: resolvedOffer, fee: toMojos(fee || '0', walletState.sync.unit.precision), + password, }); setResponse(result); } catch (error) { diff --git a/src/pages/Offers.tsx b/src/pages/Offers.tsx index 81227c5d3..2b7840351 100644 --- a/src/pages/Offers.tsx +++ b/src/pages/Offers.tsx @@ -24,7 +24,9 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; +import { useWallet } from '@/contexts/WalletContext'; import { useErrors } from '@/hooks/useErrors'; +import { usePassword } from '@/hooks/usePassword'; import { useScannerOrClipboard } from '@/hooks/useScannerOrClipboard'; import { amount } from '@/lib/formTypes'; import { toMojos } from '@/lib/utils'; @@ -56,6 +58,8 @@ export function Offers() { const offerState = useOfferState(); const walletState = useWalletState(); const { addError } = useErrors(); + const { requestPassword } = usePassword(); + const { wallet } = useWallet(); const [offerString, setOfferString] = useState(''); const [dialogOpen, setDialogOpen] = useState(false); const [offers, setOffers] = useState([]); @@ -185,7 +189,10 @@ export function Offers() { } }; - const cancelAllHandler = (values: z.infer) => { + const cancelAllHandler = async (values: z.infer) => { + const password = await requestPassword(wallet?.has_password ?? false); + if (password === null && wallet?.has_password) return; + const fee = toMojos(values.fee, walletState.sync.unit.precision); const activeOffers = filteredOffers.filter( (offer) => offer.status === 'active', @@ -198,6 +205,7 @@ export function Offers() { .cancelOffers({ offer_ids: activeOffers.map((offer) => offer.offer_id), fee, + password, }) .then((response) => { setCancelAllResponse(response); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 8ef69fa88..3eb4b2ba3 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -42,6 +42,7 @@ import { useDefaultClawback } from '@/hooks/useDefaultClawback'; import { useDefaultFee } from '@/hooks/useDefaultFee'; import { useDefaultOfferExpiry } from '@/hooks/useDefaultOfferExpiry'; import { useErrors } from '@/hooks/useErrors'; +import { usePassword } from '@/hooks/usePassword'; import { useScannerOrClipboard } from '@/hooks/useScannerOrClipboard'; import { useWalletConnect } from '@/hooks/useWalletConnect'; import { exportText, ExportType } from '@/lib/exportText'; @@ -67,6 +68,7 @@ import prettyBytes from 'pretty-bytes'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { toast } from 'react-toastify'; import { z } from 'zod'; import { commands, @@ -1020,6 +1022,7 @@ function RpcSettings() { function WalletSettings({ fingerprint }: { fingerprint: number }) { const { addError } = useErrors(); + const { requestPassword } = usePassword(); const walletState = useWalletState(); @@ -1039,6 +1042,15 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { const [performingMaintenance, setPerformingMaintenance] = useState(false); const [unhardened, setUnhardened] = useState(true); const [hardened, setHardened] = useState(true); + const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); + const [passwordDialogMode, setPasswordDialogMode] = useState< + 'set' | 'change' | 'remove' + >('set'); + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [passwordPending, setPasswordPending] = useState(false); + const [passwordError, setPasswordError] = useState(null); const saveChangeAddress = (address: string) => { const trimmedAddress = address.trim(); @@ -1188,21 +1200,92 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { }, }); - const handler = (values: z.infer) => { - setPending(true); + const openPasswordDialog = (mode: 'set' | 'change' | 'remove') => { + setPasswordDialogMode(mode); + setOldPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setPasswordError(null); + setPasswordDialogOpen(true); + }; - commands - .increaseDerivationIndex({ - index: parseInt(values.index), - hardened: key?.has_secrets && hardened, - unhardened, - }) - .then(() => { - setDeriveOpen(false); - updateSyncStatus(); - }) - .catch(addError) - .finally(() => setPending(false)); + const handlePasswordSubmit = async () => { + setPasswordError(null); + + if (passwordDialogMode !== 'remove' && newPassword !== confirmPassword) { + setPasswordError(t`Passwords do not match`); + return; + } + + if (passwordDialogMode !== 'remove' && newPassword.length === 0) { + setPasswordError(t`Password cannot be empty`); + return; + } + + setPasswordPending(true); + try { + await commands.changePassword({ + fingerprint, + old_password: passwordDialogMode === 'set' ? '' : oldPassword, + new_password: passwordDialogMode === 'remove' ? '' : newPassword, + }); + + setPasswordDialogOpen(false); + + // Refresh key info to update has_password + const data = await commands.getKey({ fingerprint }); + setKey(data.key); + + toast.success( + passwordDialogMode === 'set' + ? t`Password set successfully` + : passwordDialogMode === 'change' + ? t`Password changed successfully` + : t`Password removed successfully`, + ); + } catch (error) { + const err = error as CustomError; + setPasswordError(err.reason || t`Incorrect password`); + } finally { + setPasswordPending(false); + } + }; + + const handler = async (values: z.infer) => { + const needsPassword = key?.has_secrets && hardened; + if (needsPassword) { + const password = await requestPassword(key?.has_password ?? false); + if (password === null && key?.has_password) return; + + setPending(true); + commands + .increaseDerivationIndex({ + index: parseInt(values.index), + hardened: true, + unhardened, + password, + }) + .then(() => { + setDeriveOpen(false); + updateSyncStatus(); + }) + .catch(addError) + .finally(() => setPending(false)); + } else { + setPending(true); + commands + .increaseDerivationIndex({ + index: parseInt(values.index), + hardened: false, + unhardened, + }) + .then(() => { + setDeriveOpen(false); + updateSyncStatus(); + }) + .catch(addError) + .finally(() => setPending(false)); + } }; return ( @@ -1298,6 +1381,47 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { + {key?.has_secrets && ( + + + + + + ) : ( + + ) + } + /> + + )} + + !open && setPasswordDialogOpen(false)} + > + + + + {passwordDialogMode === 'set' && Set Password} + {passwordDialogMode === 'change' && ( + Change Password + )} + {passwordDialogMode === 'remove' && ( + Remove Password + )} + + + {passwordDialogMode === 'set' && ( + + Set a password to protect transaction signing and secret key + access. There is no way to recover a lost password. + + )} + {passwordDialogMode === 'change' && ( + Enter your current password and choose a new one. + )} + {passwordDialogMode === 'remove' && ( + + Enter your current password to remove password protection. + Your wallet secrets will no longer require a password. + + )} + + +
+ {passwordDialogMode !== 'set' && ( +
+ + setOldPassword(e.target.value)} + placeholder={t`Enter current password`} + onKeyDown={(e) => { + if (e.key === 'Enter' && passwordDialogMode === 'remove') { + e.preventDefault(); + handlePasswordSubmit(); + } + }} + /> +
+ )} + {passwordDialogMode !== 'remove' && ( + <> +
+ + setNewPassword(e.target.value)} + placeholder={t`Enter password`} + /> +
+
+ + setConfirmPassword(e.target.value)} + placeholder={t`Confirm password`} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handlePasswordSubmit(); + } + }} + /> +
+ + )} + {passwordError && ( +

{passwordError}

+ )} +
+ + + + +
+
+ diff --git a/src/walletconnect/commands/chip0002.ts b/src/walletconnect/commands/chip0002.ts index a29488daf..9eb7b07a2 100644 --- a/src/walletconnect/commands/chip0002.ts +++ b/src/walletconnect/commands/chip0002.ts @@ -75,6 +75,10 @@ export async function handleSignCoinSpends( params: Params<'chip0002_signCoinSpends'>, context: HandlerContext, ) { + const password = await context.requestPassword(context.hasPassword); + if (password === null && context.hasPassword) + throw new Error('Authentication failed'); + if (!(await context.promptIfEnabled())) { throw new Error('Authentication failed'); } @@ -93,6 +97,7 @@ export async function handleSignCoinSpends( }), partial: params.partialSign, auto_submit: false, + password, }); return data.spend_bundle.aggregated_signature; @@ -102,11 +107,15 @@ export async function handleSignMessage( params: Params<'chip0002_signMessage'>, context: HandlerContext, ) { + const password = await context.requestPassword(context.hasPassword); + if (password === null && context.hasPassword) + throw new Error('Authentication failed'); + if (!(await context.promptIfEnabled())) { throw new Error('Authentication failed'); } - const data = await commands.signMessageWithPublicKey(params); + const data = await commands.signMessageWithPublicKey({ ...params, password }); return data.signature; } diff --git a/src/walletconnect/commands/high-level.ts b/src/walletconnect/commands/high-level.ts index 7336a811e..5ff94c78e 100644 --- a/src/walletconnect/commands/high-level.ts +++ b/src/walletconnect/commands/high-level.ts @@ -47,6 +47,10 @@ export async function handleSend( params: Params<'chia_send'>, context: HandlerContext, ): Promise> { + const password = await context.requestPassword(context.hasPassword); + if (password === null && context.hasPassword) + throw new Error('Authentication failed'); + if (!(await context.promptIfEnabled())) { throw new Error('Authentication failed'); } @@ -59,6 +63,7 @@ export async function handleSend( fee: params.fee ?? 0, memos: params.memos ?? [], auto_submit: true, + password, }); } else { await commands.sendXch({ @@ -67,6 +72,7 @@ export async function handleSend( fee: params.fee ?? 0, memos: params.memos ?? [], auto_submit: true, + password, }); } @@ -83,17 +89,25 @@ export async function handleSignMessageByAddress( params: Params<'chia_signMessageByAddress'>, context: HandlerContext, ) { + const password = await context.requestPassword(context.hasPassword); + if (password === null && context.hasPassword) + throw new Error('Authentication failed'); + if (!(await context.promptIfEnabled())) { throw new Error('Authentication failed'); } - return await commands.signMessageByAddress(params); + return await commands.signMessageByAddress({ ...params, password }); } export async function handleBulkMintNfts( params: Params<'chia_bulkMintNfts'>, context: HandlerContext, ): Promise> { + const password = await context.requestPassword(context.hasPassword); + if (password === null && context.hasPassword) + throw new Error('Authentication failed'); + if (!(await context.promptIfEnabled())) { throw new Error('Authentication failed'); } @@ -102,6 +116,7 @@ export async function handleBulkMintNfts( did_id: params.did, fee: params.fee ?? 0, auto_submit: true, + password, mints: params.nfts.map((nft) => { if (nft.dataUris?.length && !nft.dataHash) { throw new Error('Data hash is required if data uris are provided'); diff --git a/src/walletconnect/commands/offers.ts b/src/walletconnect/commands/offers.ts index efa601f1a..be2298c53 100644 --- a/src/walletconnect/commands/offers.ts +++ b/src/walletconnect/commands/offers.ts @@ -6,6 +6,10 @@ export async function handleCreateOffer( params: Params<'chia_createOffer'>, context: HandlerContext, ) { + const password = await context.requestPassword(context.hasPassword); + if (password === null && context.hasPassword) + throw new Error('Authentication failed'); + if (!(await context.promptIfEnabled())) { throw new Error('Authentication failed'); } @@ -23,6 +27,7 @@ export async function handleCreateOffer( amount: asset.amount, })), expires_at_second: null, + password, }); return { @@ -35,6 +40,10 @@ export async function handleTakeOffer( params: Params<'chia_takeOffer'>, context: HandlerContext, ) { + const password = await context.requestPassword(context.hasPassword); + if (password === null && context.hasPassword) + throw new Error('Authentication failed'); + if (!(await context.promptIfEnabled())) { throw new Error('Authentication failed'); } @@ -43,6 +52,7 @@ export async function handleTakeOffer( offer: params.offer, fee: params.fee ?? 0, auto_submit: true, + password, }); return { id: data.transaction_id }; @@ -52,6 +62,10 @@ export async function handleCancelOffer( params: Params<'chia_cancelOffer'>, context: HandlerContext, ) { + const password = await context.requestPassword(context.hasPassword); + if (password === null && context.hasPassword) + throw new Error('Authentication failed'); + if (!(await context.promptIfEnabled())) { throw new Error('Authentication failed'); } @@ -60,6 +74,7 @@ export async function handleCancelOffer( offer_id: params.id, fee: params.fee ?? 0, auto_submit: true, + password, }); return {}; diff --git a/src/walletconnect/handler.ts b/src/walletconnect/handler.ts index 6e09e72d9..f8d2c9bfd 100644 --- a/src/walletconnect/handler.ts +++ b/src/walletconnect/handler.ts @@ -25,6 +25,8 @@ import { export interface HandlerContext { promptIfEnabled: () => Promise; + requestPassword: (hasPassword: boolean) => Promise; + hasPassword: boolean; } export const handleCommand = async ( From 08f0a8c80f0208ac45152dac663ee3ab66cbf557 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 17:23:01 -0500 Subject: [PATCH 10/53] cargo fmt --- crates/sage-api/src/requests/keys.rs | 5 +++- crates/sage-keychain/src/keychain.rs | 17 +++++------- crates/sage-wallet/src/wallet/offer.rs | 21 ++++++++++++--- crates/sage/src/endpoints/action_system.rs | 3 ++- crates/sage/src/endpoints/actions.rs | 5 ++-- crates/sage/src/endpoints/keys.rs | 3 ++- crates/sage/src/endpoints/offers.rs | 10 ++++--- crates/sage/src/endpoints/transactions.rs | 30 ++++++++++++++++----- crates/sage/src/endpoints/wallet_connect.rs | 10 ++++--- crates/sage/src/utils/spends.rs | 5 ++-- 10 files changed, 74 insertions(+), 35 deletions(-) diff --git a/crates/sage-api/src/requests/keys.rs b/crates/sage-api/src/requests/keys.rs index 117254d6d..c748bb5be 100644 --- a/crates/sage-api/src/requests/keys.rs +++ b/crates/sage-api/src/requests/keys.rs @@ -427,7 +427,10 @@ pub struct ChangePassword { } /// Response after changing the password -#[cfg_attr(feature = "openapi", crate::openapi_attr(tag = "Authentication & Keys"))] +#[cfg_attr( + feature = "openapi", + crate::openapi_attr(tag = "Authentication & Keys") +)] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] diff --git a/crates/sage-keychain/src/keychain.rs b/crates/sage-keychain/src/keychain.rs index d1866a8e5..5698afe7f 100644 --- a/crates/sage-keychain/src/keychain.rs +++ b/crates/sage-keychain/src/keychain.rs @@ -91,10 +91,7 @@ impl Keychain { self.keys.keys().copied() } - pub fn extract_public_key( - &self, - fingerprint: u32, - ) -> Result, KeychainError> { + pub fn extract_public_key(&self, fingerprint: u32) -> Result, KeychainError> { match self.keys.get(&fingerprint) { Some(KeyData::Public { master_pk } | KeyData::Secret { master_pk, .. }) => { Ok(Some(PublicKey::from_bytes(master_pk)?)) @@ -318,9 +315,11 @@ mod tests { let mut keychain = Keychain::default(); let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); let fingerprint = keychain.add_mnemonic(&mnemonic, b"correct").unwrap(); - assert!(keychain - .change_password(fingerprint, b"wrong", b"newpass") - .is_err()); + assert!( + keychain + .change_password(fingerprint, b"wrong", b"newpass") + .is_err() + ); let (_m, Some(_sk)) = keychain.extract_secrets(fingerprint, b"correct").unwrap() else { panic!("expected secret key"); }; @@ -333,9 +332,7 @@ mod tests { let master_sk = SecretKey::from_seed(&mnemonic.to_seed("")); let master_pk = master_sk.public_key(); let fingerprint = keychain.add_public_key(&master_pk).unwrap(); - assert!(keychain - .change_password(fingerprint, b"", b"pass") - .is_err()); + assert!(keychain.change_password(fingerprint, b"", b"pass").is_err()); } #[test] diff --git a/crates/sage-wallet/src/wallet/offer.rs b/crates/sage-wallet/src/wallet/offer.rs index 15abecef2..ea05264d6 100644 --- a/crates/sage-wallet/src/wallet/offer.rs +++ b/crates/sage-wallet/src/wallet/offer.rs @@ -623,7 +623,12 @@ mod tests { let offer = alice .wallet - .sign_transaction(unsigned_offer, &alice.agg_sig, alice.master_sk.clone(), true) + .sign_transaction( + unsigned_offer, + &alice.agg_sig, + alice.master_sk.clone(), + true, + ) .await?; // Take offer @@ -698,7 +703,12 @@ mod tests { let offer = alice .wallet - .sign_transaction(unsigned_offer, &alice.agg_sig, alice.master_sk.clone(), true) + .sign_transaction( + unsigned_offer, + &alice.agg_sig, + alice.master_sk.clone(), + true, + ) .await?; // Take offer @@ -765,7 +775,12 @@ mod tests { let offer = alice .wallet - .sign_transaction(unsigned_offer, &alice.agg_sig, alice.master_sk.clone(), true) + .sign_transaction( + unsigned_offer, + &alice.agg_sig, + alice.master_sk.clone(), + true, + ) .await?; // Take offer diff --git a/crates/sage/src/endpoints/action_system.rs b/crates/sage/src/endpoints/action_system.rs index 558257e38..9455a8342 100644 --- a/crates/sage/src/endpoints/action_system.rs +++ b/crates/sage/src/endpoints/action_system.rs @@ -147,7 +147,8 @@ impl Sage { let coin_spends = ctx.take(); - self.transact_with(coin_spends, req.auto_submit, info, &password).await + self.transact_with(coin_spends, req.auto_submit, info, &password) + .await } } diff --git a/crates/sage/src/endpoints/actions.rs b/crates/sage/src/endpoints/actions.rs index c458f0857..6a8e14b6d 100644 --- a/crates/sage/src/endpoints/actions.rs +++ b/crates/sage/src/endpoints/actions.rs @@ -198,8 +198,9 @@ impl Sage { let mut derivations = Vec::new(); if hardened { - let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, &password)? + let (_mnemonic, Some(master_sk)) = self + .keychain + .extract_secrets(wallet.fingerprint, &password)? else { return Err(Error::NoSigningKey); }; diff --git a/crates/sage/src/endpoints/keys.rs b/crates/sage/src/endpoints/keys.rs index 30172485a..e93e29263 100644 --- a/crates/sage/src/endpoints/keys.rs +++ b/crates/sage/src/endpoints/keys.rs @@ -353,7 +353,8 @@ impl Sage { pub fn get_secret_key(&self, req: GetSecretKey) -> Result { let password = req.password.unwrap_or_default().into_bytes(); - let (mnemonic, Some(secret_key)) = self.keychain.extract_secrets(req.fingerprint, &password)? + let (mnemonic, Some(secret_key)) = + self.keychain.extract_secrets(req.fingerprint, &password)? else { return Ok(GetSecretKeyResponse { secrets: None }); }; diff --git a/crates/sage/src/endpoints/offers.rs b/crates/sage/src/endpoints/offers.rs index f379c45b5..1b652cc61 100644 --- a/crates/sage/src/endpoints/offers.rs +++ b/crates/sage/src/endpoints/offers.rs @@ -167,8 +167,9 @@ impl Sage { .make_offer(offered, requested, req.expires_at_second) .await?; - let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, &password)? + let (_mnemonic, Some(master_sk)) = self + .keychain + .extract_secrets(wallet.fingerprint, &password)? else { return Err(Error::NoSigningKey); }; @@ -206,8 +207,9 @@ impl Sage { let unsigned = wallet.take_offer(offer, fee).await?; - let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, &password)? + let (_mnemonic, Some(master_sk)) = self + .keychain + .extract_secrets(wallet.fingerprint, &password)? else { return Err(Error::NoSigningKey); }; diff --git a/crates/sage/src/endpoints/transactions.rs b/crates/sage/src/endpoints/transactions.rs index 61ab6ab34..aef8b645a 100644 --- a/crates/sage/src/endpoints/transactions.rs +++ b/crates/sage/src/endpoints/transactions.rs @@ -99,7 +99,9 @@ impl Sage { let coin_spends = wallet .combine(coins.iter().map(Coin::coin_id).collect(), fee) .await?; - let response = self.transact(coin_spends, req.auto_submit, &password).await?; + let response = self + .transact(coin_spends, req.auto_submit, &password) + .await?; Ok(AutoCombineXchResponse { coin_ids, @@ -150,7 +152,9 @@ impl Sage { let coin_spends = wallet .combine(cats.iter().map(|row| row.coin.coin_id()).collect(), fee) .await?; - let response = self.transact(coin_spends, req.auto_submit, &password).await?; + let response = self + .transact(coin_spends, req.auto_submit, &password) + .await?; Ok(AutoCombineCatResponse { coin_ids, @@ -283,8 +287,13 @@ impl Sage { }) .await?; - self.transact_with(coin_spends, req.auto_submit, ConfirmationInfo::default(), &password) - .await + self.transact_with( + coin_spends, + req.auto_submit, + ConfirmationInfo::default(), + &password, + ) + .await } pub async fn bulk_mint_nfts(&self, req: BulkMintNfts) -> Result { @@ -426,7 +435,9 @@ impl Sage { ) .await?; - let response = self.transact(coin_spends, req.auto_submit, &password).await?; + let response = self + .transact(coin_spends, req.auto_submit, &password) + .await?; Ok(MintOptionResponse { option_id: Address::new(option.info.launcher_id, "option".to_string()).encode()?, @@ -558,8 +569,13 @@ impl Sage { auto_submit: bool, password: &[u8], ) -> Result { - self.transact_with(coin_spends, auto_submit, ConfirmationInfo::default(), password) - .await + self.transact_with( + coin_spends, + auto_submit, + ConfirmationInfo::default(), + password, + ) + .await } pub(crate) async fn transact_with( diff --git a/crates/sage/src/endpoints/wallet_connect.rs b/crates/sage/src/endpoints/wallet_connect.rs index 0c0c54a41..0f2c899f3 100644 --- a/crates/sage/src/endpoints/wallet_connect.rs +++ b/crates/sage/src/endpoints/wallet_connect.rs @@ -178,8 +178,9 @@ impl Sage { return Err(Error::InvalidKey); }; - let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, &password)? + let (_mnemonic, Some(master_sk)) = self + .keychain + .extract_secrets(wallet.fingerprint, &password)? else { return Err(Error::NoSigningKey); }; @@ -218,8 +219,9 @@ impl Sage { return Err(Error::InvalidKey); }; - let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, &password)? + let (_mnemonic, Some(master_sk)) = self + .keychain + .extract_secrets(wallet.fingerprint, &password)? else { return Err(Error::NoSigningKey); }; diff --git a/crates/sage/src/utils/spends.rs b/crates/sage/src/utils/spends.rs index e18800669..a9c1b4237 100644 --- a/crates/sage/src/utils/spends.rs +++ b/crates/sage/src/utils/spends.rs @@ -12,8 +12,9 @@ impl Sage { ) -> Result { let wallet = self.wallet()?; - let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, password)? + let (_mnemonic, Some(master_sk)) = self + .keychain + .extract_secrets(wallet.fingerprint, password)? else { return Err(Error::NoSigningKey); }; From 19f9944ed474d659bb146549098660be8dfe69e7 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 17:25:24 -0500 Subject: [PATCH 11/53] lint --- src/components/NftCard.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/NftCard.tsx b/src/components/NftCard.tsx index 44257646d..1cf86dd67 100644 --- a/src/components/NftCard.tsx +++ b/src/components/NftCard.tsx @@ -299,8 +299,6 @@ export function NftCard({ nft, updateNfts, selectionState }: NftCardProps) { role='article' tabIndex={0} aria-label={nftName} - aria-disabled={!nft.created_height} - aria-selected={selectionState?.[0]} >
From 7d09db61243411bdef96fa33b78d784d2cfd7dba Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 17:29:54 -0500 Subject: [PATCH 12/53] add frontend notes --- .../2026-03-15-password-protection-design.md | 84 ++++++++++++++++--- 1 file changed, 73 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/specs/2026-03-15-password-protection-design.md b/docs/superpowers/specs/2026-03-15-password-protection-design.md index 2aed06725..020330ba7 100644 --- a/docs/superpowers/specs/2026-03-15-password-protection-design.md +++ b/docs/superpowers/specs/2026-03-15-password-protection-design.md @@ -2,7 +2,7 @@ **Issue:** [xch-dev/sage#206](https://github.com/xch-dev/sage/issues/206) **Date:** 2026-03-15 -**Status:** Design +**Status:** Implemented ## Overview @@ -180,18 +180,80 @@ New `change_password()` endpoint. ### Frontend (TypeScript/React) -- Reusable password prompt dialog component -- Prompt shown before each protected operation (only when `has_password` is true for the active wallet) -- "Set Password" in wallet settings (calls `change_password` with empty old password) -- "Change Password" in wallet settings -- "Remove Password" in wallet settings (calls `change_password` with empty new password, with confirmation dialog warning about reduced security) -- Biometric opt-in toggle (calls keyring/biometric plugin) -- Biometric retrieval logic with fallback to manual entry -- Password change also updates the biometric keychain entry +#### PasswordContext (`src/contexts/PasswordContext.tsx`) -### New dependency +A React context provider following the same architecture as the existing `BiometricContext`. Provides a single function: -- A Tauri keyring or biometric plugin (e.g., `tauri-plugin-biometry`) for the optional biometric layer +```typescript +requestPassword(hasPassword: boolean): Promise +``` + +- If `hasPassword` is `false`, resolves to `null` immediately (no prompt). +- If `hasPassword` is `true`, shows a modal dialog and resolves with the entered password, or `null` if cancelled. +- Uses a `useRef`-based pending promise pattern to bridge the dialog UI with the async call site. + +**Provider placement:** Inside `I18nProvider` (required because the dialog uses Lingui translation macros) but wrapping `WalletProvider` and all downstream providers, so `usePassword()` is available everywhere. + +#### PasswordDialog (`src/components/dialogs/PasswordDialog.tsx`) + +A reusable modal dialog rendered by `PasswordProvider`. Features: + +- Auto-focuses the password input on open +- Clears password state on open/close +- Supports Enter key to submit +- Cancel closes the dialog and resolves the promise with `null` + +#### usePassword hook (`src/hooks/usePassword.ts`) + +Thin wrapper around `PasswordContext` with a guard that throws if used outside `PasswordProvider`. + +#### Call site pattern + +Every protected operation follows the same pattern before proceeding: + +```typescript +const password = await requestPassword(wallet?.has_password ?? false); +if (password === null && wallet?.has_password) return; +``` + +Password is then passed to the backend command. Call sites that were updated: + +| File | Operations | +| --- | --- | +| `ConfirmationDialog.tsx` | `signCoinSpends` (Sign Transaction button, Submit button) | +| `WalletCard.tsx` | `getSecretKey` (View Details dialog) | +| `Settings.tsx` | `increaseDerivationIndex` (when hardened keys enabled) | +| `Offers.tsx` | `cancelOffers` (Cancel All Active) | +| `OfferRowCard.tsx` | `cancelOffer` (individual offer cancel) | +| `useOfferProcessor.ts` | `makeOffer` (create offer flow) | +| `Offer.tsx` | `takeOffer` (take offer flow) | +| `WalletConnectContext.tsx` | All WC command handling via `HandlerContext` | +| WalletConnect commands | `signCoinSpends`, `signMessage`, `signMessageByAddress`, `send`, `createOffer`, `takeOffer`, `cancelOffer`, `bulkMintNfts` | + +#### WalletConnect integration + +The `HandlerContext` interface was extended with `requestPassword` and `hasPassword`. WalletConnect command handlers prompt for the password before executing protected operations, using the same pattern as direct UI call sites. + +#### Password management in Settings + +A new **Security** section in Wallet Settings (only shown for hot wallets with `has_secrets`): + +- **Set Password** — shown when `has_password` is `false`. Opens a dialog with New Password + Confirm Password fields. +- **Change Password** — shown when `has_password` is `true`. Opens a dialog with Current Password + New Password + Confirm Password fields. +- **Remove Password** — shown when `has_password` is `true`. Opens a dialog with Current Password field. Uses destructive button variant. + +All three operations call `commands.changePassword()` with appropriate `old_password`/`new_password` values (empty string = no password). On success, refreshes `KeyInfo` via `commands.getKey()` and shows a success toast. + +#### Error feedback + +Wrong password errors (`ErrorKind::Unauthorized` with reason containing "decrypt") are surfaced as a toast notification "Incorrect password" via the global `ErrorContext.addError` handler. This provides consistent feedback across all password-protected operations without requiring per-call-site error handling. Other unauthorized errors (e.g., wallet transition race conditions) continue to be silently discarded. + +#### Design decisions + +- **No password at import time** — users set a password later via Settings. Simpler UX, same security outcome. +- **No session caching** — every protected operation prompts independently. Matches the per-operation model described in the backend design. +- **Single dialog instance** — `PasswordProvider` renders one `PasswordDialog` at the provider level, avoiding duplicate dialog instances across components. +- **Biometric integration** — the existing `BiometricContext` (`promptIfEnabled`) continues to work alongside password. Password is prompted first, then biometric if enabled. The biometric layer is orthogonal to password protection. ## Error Handling From a358fbc0f91466f28c51dfe14bf12b7710122752 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 20:07:10 -0500 Subject: [PATCH 13/53] feat: add tauri-plugin-keychain dependency for biometric-password bridge Co-Authored-By: Claude Sonnet 4.6 --- package.json | 1 + pnpm-lock.yaml | 169 +++++++++++++++++++++++++++-- src-tauri/Cargo.toml | 1 + src-tauri/capabilities/mobile.json | 3 +- src-tauri/src/lib.rs | 3 +- 5 files changed, 167 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 7d0f9fc84..89eccfcbd 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "spoiled": "^0.4.0", "tailwind-merge": "^2.6.1", "tailwindcss-animate": "^1.0.7", + "tauri-plugin-keychain": "^2.0.1", "tauri-plugin-safe-area-insets": "^0.1.0", "tauri-plugin-sage": "file:tauri-plugin-sage", "theme-o-rama": "0.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9aef00af..6fccac259 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.19(yaml@2.5.1)) + tauri-plugin-keychain: + specifier: ^2.0.1 + version: 2.0.1(rollup@4.57.1) tauri-plugin-safe-area-insets: specifier: ^0.1.0 version: 0.1.0 @@ -212,7 +215,7 @@ importers: version: 5.10.1(@lingui/core@5.9.0(@lingui/babel-plugin-lingui-macro@5.9.0(babel-plugin-macros@3.1.0)(typescript@5.9.3))(babel-plugin-macros@3.1.0)) '@lingui/vite-plugin': specifier: ^5.9.0 - version: 5.9.0(babel-plugin-macros@3.1.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1)) + version: 5.9.0(babel-plugin-macros@3.1.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(terser@5.46.0)(yaml@2.5.1)) '@tauri-apps/cli': specifier: ^2.10.0 version: 2.10.0 @@ -233,7 +236,7 @@ importers: version: 7.18.0(eslint@8.57.1)(typescript@5.9.3) '@vitejs/plugin-react-swc': specifier: ^3.11.0 - version: 3.11.0(vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1)) + version: 3.11.0(vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(terser@5.46.0)(yaml@2.5.1)) autoprefixer: specifier: ^10.4.24 version: 10.4.24(postcss@8.5.6) @@ -269,7 +272,7 @@ importers: version: 8.54.0(eslint@8.57.1)(typescript@5.9.3) vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1) + version: 7.3.1(@types/node@22.19.10)(jiti@1.21.7)(terser@5.46.0)(yaml@2.5.1) packages: @@ -792,6 +795,9 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -1396,6 +1402,33 @@ packages: '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rollup/plugin-node-resolve@15.3.1': + resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-terser@0.4.4': + resolution: {integrity: sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -1756,6 +1789,9 @@ packages: '@types/react@18.3.28': resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -2113,6 +2149,9 @@ packages: bs58@6.0.0: resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -2206,6 +2245,9 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -2274,6 +2316,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -2442,6 +2488,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2730,6 +2779,9 @@ packages: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + is-negative-zero@2.0.3: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} @@ -3219,6 +3271,9 @@ packages: radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -3391,6 +3446,9 @@ packages: engines: {node: '>=10'} hasBin: true + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3441,6 +3499,10 @@ packages: slow-redact@0.3.2: resolution: {integrity: sha512-MseHyi2+E/hBRqdOi5COy6wZ7j7DxXRz9NkseavNYSvvWC06D8a5cidVZX3tcG5eCW3NIyVU4zT63hw0Q486jw==} + smob@1.6.1: + resolution: {integrity: sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==} + engines: {node: '>=20.0.0'} + sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} @@ -3448,6 +3510,13 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} @@ -3522,12 +3591,20 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + tauri-plugin-keychain@2.0.1: + resolution: {integrity: sha512-Avbg7XEEl8AVf04wEBhtoKV6Iv7k5g4NLKY0zbxGSDilgnCMszjc1oVgLqNMTNiYLJ6YUj+Pn/IyE5F/olEj1w==} + tauri-plugin-safe-area-insets@0.1.0: resolution: {integrity: sha512-NhtxSQdU7+laTJiO4WZFlTB1CBzRlNE5EuHflGAGXS55fiwOkkaWztfNishFQO+eZtb9i5jLHX3Yho48lnqcRQ==} tauri-plugin-sage-api@file:tauri-plugin-sage: resolution: {directory: tauri-plugin-sage, type: directory} + terser@5.46.0: + resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==} + engines: {node: '>=10'} + hasBin: true + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -4257,6 +4334,11 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.31': @@ -4359,11 +4441,11 @@ snapshots: dependencies: '@lingui/core': 5.9.0(@lingui/babel-plugin-lingui-macro@5.9.0(babel-plugin-macros@3.1.0)(typescript@5.9.3))(babel-plugin-macros@3.1.0) - '@lingui/vite-plugin@5.9.0(babel-plugin-macros@3.1.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1))': + '@lingui/vite-plugin@5.9.0(babel-plugin-macros@3.1.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(terser@5.46.0)(yaml@2.5.1))': dependencies: '@lingui/cli': 5.9.0(babel-plugin-macros@3.1.0)(typescript@5.9.3) '@lingui/conf': 5.9.0(typescript@5.9.3) - vite: 7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1) + vite: 7.3.1(@types/node@22.19.10)(jiti@1.21.7)(terser@5.46.0)(yaml@2.5.1) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -4894,6 +4976,32 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rollup/plugin-node-resolve@15.3.1(rollup@4.57.1)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.57.1) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.11 + optionalDependencies: + rollup: 4.57.1 + + '@rollup/plugin-terser@0.4.4(rollup@4.57.1)': + dependencies: + serialize-javascript: 6.0.2 + smob: 1.6.1 + terser: 5.46.0 + optionalDependencies: + rollup: 4.57.1 + + '@rollup/pluginutils@5.3.0(rollup@4.57.1)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.57.1 + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true @@ -5161,6 +5269,8 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/resolve@1.20.2': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -5348,11 +5458,11 @@ snapshots: '@use-gesture/core': 10.3.1 react: 18.3.1 - '@vitejs/plugin-react-swc@3.11.0(vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1))': + '@vitejs/plugin-react-swc@3.11.0(vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(terser@5.46.0)(yaml@2.5.1))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.27 '@swc/core': 1.15.11 - vite: 7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1) + vite: 7.3.1(@types/node@22.19.10)(jiti@1.21.7)(terser@5.46.0)(yaml@2.5.1) transitivePeerDependencies: - '@swc/helpers' @@ -5782,6 +5892,8 @@ snapshots: dependencies: base-x: 5.0.1 + buffer-from@1.1.2: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -5887,6 +5999,8 @@ snapshots: commander@10.0.1: {} + commander@2.20.3: {} + commander@4.1.1: {} concat-map@0.0.1: {} @@ -5953,6 +6067,8 @@ snapshots: deep-is@0.1.4: {} + deepmerge@4.3.1: {} + defaults@1.0.4: dependencies: clone: 1.0.4 @@ -6282,6 +6398,8 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + esutils@2.0.3: {} eventemitter3@5.0.1: {} @@ -6580,6 +6698,8 @@ snapshots: is-map@2.0.3: {} + is-module@1.0.0: {} + is-negative-zero@2.0.3: {} is-number-object@1.1.1: @@ -7036,6 +7156,10 @@ snapshots: radix3@1.1.2: {} + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -7237,6 +7361,10 @@ snapshots: semver@7.7.4: {} + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -7301,12 +7429,21 @@ snapshots: slow-redact@0.3.2: {} + smob@1.6.1: {} + sonic-boom@4.2.0: dependencies: atomic-sleep: 1.0.0 source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + source-map@0.7.6: {} split2@4.2.0: {} @@ -7426,6 +7563,14 @@ snapshots: - tsx - yaml + tauri-plugin-keychain@2.0.1(rollup@4.57.1): + dependencies: + '@rollup/plugin-node-resolve': 15.3.1(rollup@4.57.1) + '@rollup/plugin-terser': 0.4.4(rollup@4.57.1) + '@tauri-apps/api': 2.10.1 + transitivePeerDependencies: + - rollup + tauri-plugin-safe-area-insets@0.1.0: dependencies: '@tauri-apps/api': 2.10.1 @@ -7434,6 +7579,13 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + terser@5.46.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.15.0 + commander: 2.20.3 + source-map-support: 0.5.21 + text-table@0.2.0: {} theme-o-rama@0.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -7611,7 +7763,7 @@ snapshots: util-deprecate@1.0.2: {} - vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1): + vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(terser@5.46.0)(yaml@2.5.1): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -7623,6 +7775,7 @@ snapshots: '@types/node': 22.19.10 fsevents: 2.3.3 jiti: 1.21.7 + terser: 5.46.0 yaml: 2.5.1 wcwidth@1.0.1: diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9412c3830..b5370b753 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -52,6 +52,7 @@ tauri-plugin-biometric = { workspace = true } tauri-plugin-barcode-scanner = { workspace = true } tauri-plugin-safe-area-insets = { workspace = true } tauri-plugin-sage = { workspace = true } +tauri-plugin-keychain = "2" [build-dependencies] tauri-build = { workspace = true, features = [] } diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json index 07f0f17b4..4ddae1bc5 100644 --- a/src-tauri/capabilities/mobile.json +++ b/src-tauri/capabilities/mobile.json @@ -8,6 +8,7 @@ "barcode-scanner:default", "biometric:default", "sage:default", - "sharesheet:allow-share-text" + "sharesheet:allow-share-text", + "keychain:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e37d25550..fece3964f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -174,7 +174,8 @@ pub fn run() { .plugin(tauri_plugin_safe_area_insets::init()) .plugin(tauri_plugin_biometric::init()) .plugin(tauri_plugin_sharesheet::init()) - .plugin(tauri_plugin_sage::init()); + .plugin(tauri_plugin_sage::init()) + .plugin(tauri_plugin_keychain::init()); } tauri_builder From 9b4115fb5ff9a14c5d453b2682065864d7ca7bd1 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 20:08:08 -0500 Subject: [PATCH 14/53] docs: update spec to reference tauri-plugin-keychain Co-Authored-By: Claude Opus 4.6 --- .../2026-03-15-password-protection-design.md | 107 ++++++++++++++---- 1 file changed, 87 insertions(+), 20 deletions(-) diff --git a/docs/superpowers/specs/2026-03-15-password-protection-design.md b/docs/superpowers/specs/2026-03-15-password-protection-design.md index 020330ba7..8721244cc 100644 --- a/docs/superpowers/specs/2026-03-15-password-protection-design.md +++ b/docs/superpowers/specs/2026-03-15-password-protection-design.md @@ -2,7 +2,7 @@ **Issue:** [xch-dev/sage#206](https://github.com/xch-dev/sage/issues/206) **Date:** 2026-03-15 -**Status:** Implemented +**Status:** Implemented (backend + frontend password); In Progress (biometric-password bridge) ## Overview @@ -40,16 +40,31 @@ Add a `has_password: bool` field to the `KeyInfo` struct returned by `get_key()` Preferred approach: add `password_hint: Option` or a simple `password_protected: bool` to `KeyData::Secret`. This avoids trial decryption and is serialized into `keys.bin`. Set to `true` when a non-empty password is used at encryption time. Exposed via `KeyInfo` to the frontend. -### Biometric Layer +### Biometric-Password Bridge (Mobile) Biometric is a frontend-only concern. The backend never knows whether a password came from typing or biometric retrieval. -1. User enables biometric after setting a password -2. Frontend stores the password in the OS keychain with biometric access control (via a Tauri keyring/biometric plugin), keyed by wallet fingerprint -3. On protected operations: frontend tries biometric retrieval first, falls back to manual entry -4. Backend receives the password in the request struct either way +**Plugin:** `tauri-plugin-keychain` — wraps iOS Keychain and Android AccountManager. Key-value API: `saveItem(key, password)`, `getItem(key)`, `removeItem(key)`. Supports multiple entries per app (one per wallet). -The OS keychain uses the platform's secure element under the hood (Secure Enclave on macOS/iOS, TEE on Android, TPM on Windows) without Sage needing to manage SE keys directly. +**Keychain key format:** `sage-password-{fingerprint}` — one entry per wallet. + +**Global setting:** Biometric unlock is a single global toggle (the existing `useLocalStorage('biometric', false)` flag). When enabled, it applies to all wallets that have a password. No per-wallet configuration needed. + +**Store-on-first-use:** Passwords are not stored in the keychain at enable time. Instead, the first time a password-protected operation occurs for a given wallet after biometric is enabled, the user types their password normally. On successful backend operation, the password is stored in the keychain. Subsequent operations for that wallet use biometric retrieval. + +**Retrieval flow:** + +1. `requestPassword(hasPassword)` is called at a protected operation call site +2. If `hasPassword` is false and biometric not enabled → return `null` (no auth needed) +3. If `hasPassword` is false and biometric enabled → biometric prompt (standalone gate), return `null` on success, `undefined` on failure +4. If `hasPassword` is true and biometric enabled → attempt `retrieve(sage-password-{fingerprint})` from OS keychain (triggers biometric prompt) +5. If retrieval succeeds → return the stored password (no dialog shown) +6. If retrieval fails (no entry, user cancels, enrollment changed, lockout) → fall back to password dialog +7. If `hasPassword` is true and biometric not enabled → show password dialog + +**The OS keychain uses the platform's secure element** (Secure Enclave on iOS, TEE on Android) without Sage needing to manage SE keys directly. + +> **TODO:** Add macOS Touch ID + Keychain and Windows Hello + Credential Manager support for desktop platforms. ## Protected Operations @@ -182,17 +197,35 @@ New `change_password()` endpoint. #### PasswordContext (`src/contexts/PasswordContext.tsx`) -A React context provider following the same architecture as the existing `BiometricContext`. Provides a single function: +A React context provider that serves as the **single entry point for all operation authentication** — password, biometric, or both. Provides: ```typescript -requestPassword(hasPassword: boolean): Promise +requestPassword(hasPassword: boolean): Promise +``` + +**Return values:** + +- `string` → use this password (typed by user or retrieved from keychain via biometric) +- `null` → no password needed, all auth passed (biometric gate passed or no auth required) +- `undefined` → auth cancelled or failed, abort the operation + +**Internal decision tree:** + +```text +hasPassword=false, biometric not enabled → return null (no auth needed) +hasPassword=false, biometric enabled → biometric prompt, return null on success, undefined on fail +hasPassword=true, biometric enabled → try keychain retrieval, fall back to dialog on failure +hasPassword=true, biometric not enabled → show password dialog +cancelled at any point → return undefined ``` -- If `hasPassword` is `false`, resolves to `null` immediately (no prompt). -- If `hasPassword` is `true`, shows a modal dialog and resolves with the entered password, or `null` if cancelled. -- Uses a `useRef`-based pending promise pattern to bridge the dialog UI with the async call site. +On desktop (no keychain available), the biometric path is skipped — behaves as if biometric is not enabled. -**Provider placement:** Inside `I18nProvider` (required because the dialog uses Lingui translation macros) but wrapping `WalletProvider` and all downstream providers, so `usePassword()` is available everywhere. +Uses a `useRef`-based pending promise pattern to bridge the dialog UI with the async call site. + +**Provider placement:** Inside `I18nProvider` and `WalletProvider` (required because `PasswordProvider` reads `useWallet()` for the fingerprint and `useBiometric()` for the enabled state). Wraps `WalletConnectProvider` and all downstream providers, so `usePassword()` is available everywhere. + +Provider tree: `BiometricProvider` → `I18nProvider` → `WalletProvider` → `PasswordProvider` → `PeerProvider` → `WalletConnectProvider` → `PriceProvider` → `RouterProvider` #### PasswordDialog (`src/components/dialogs/PasswordDialog.tsx`) @@ -209,14 +242,14 @@ Thin wrapper around `PasswordContext` with a guard that throws if used outside ` #### Call site pattern -Every protected operation follows the same pattern before proceeding: +Every protected operation follows the same unified pattern — a single call that handles password, biometric, or both: ```typescript const password = await requestPassword(wallet?.has_password ?? false); -if (password === null && wallet?.has_password) return; +if (password === undefined) return; // auth cancelled or failed ``` -Password is then passed to the backend command. Call sites that were updated: +The separate `promptIfEnabled()` biometric call is removed from all call sites. `requestPassword` is now the sole auth gate. Password is then passed to the backend command. Call sites that were updated: | File | Operations | | --- | --- | @@ -248,19 +281,47 @@ All three operations call `commands.changePassword()` with appropriate `old_pass Wrong password errors (`ErrorKind::Unauthorized` with reason containing "decrypt") are surfaced as a toast notification "Incorrect password" via the global `ErrorContext.addError` handler. This provides consistent feedback across all password-protected operations without requiring per-call-site error handling. Other unauthorized errors (e.g., wallet transition race conditions) continue to be silently discarded. +#### Keychain lifecycle + +| Event | Action | +| --- | --- | +| Password-protected operation succeeds (first time) | Store password in keychain for that wallet's fingerprint | +| Password changed in Settings | Update keychain entry with new password | +| Password removed in Settings | Delete keychain entry; biometric reverts to standalone gate mode | +| Biometric toggle disabled | Delete all `sage-password-*` keychain entries | +| Wallet deleted | Delete that wallet's keychain entry | +| OS biometric enrollment changes | OS invalidates keychain items; next retrieval fails → dialog fallback → re-stored on success | + +#### Settings UI changes + +The existing biometric toggle in Settings moves into the **Security** section alongside password controls: + +- When `has_password` is false: toggle label "Biometric Authentication" / "Require biometrics for sensitive actions" (standalone gate, current behavior) +- When `has_password` is true: toggle label "Biometric Unlock" / "Use Face ID / Touch ID instead of typing your password" +- Toggle only visible on mobile when biometric hardware is available and enrolled + +A warning is shown when enabling biometric with a password: "Biometric unlock is a convenience — remember your password. There is no way to recover a lost password." + #### Design decisions - **No password at import time** — users set a password later via Settings. Simpler UX, same security outcome. -- **No session caching** — every protected operation prompts independently. Matches the per-operation model described in the backend design. +- **No session caching** — every protected operation prompts independently. The OS handles its own biometric caching at the system level. Sage does not cache passwords in memory after biometric retrieval. - **Single dialog instance** — `PasswordProvider` renders one `PasswordDialog` at the provider level, avoiding duplicate dialog instances across components. -- **Biometric integration** — the existing `BiometricContext` (`promptIfEnabled`) continues to work alongside password. Password is prompted first, then biometric if enabled. The biometric layer is orthogonal to password protection. +- **Unified auth entry point** — `requestPassword` subsumes the standalone `promptIfEnabled()` biometric check. Call sites make one auth call instead of two. The `BiometricContext` continues to exist for state management (`enabled`, `available`) but `promptIfEnabled()` is no longer called directly at operation sites. +- **Global biometric setting** — one toggle applies to all wallets. Per-wallet passwords are stored in the OS keychain on first successful use. No per-wallet biometric configuration needed. +- **Graceful degradation** — keychain retrieval failure (enrollment change, lockout, corruption, device restore) always falls back to the password dialog. The biometric path is an optimistic fast path, never a hard requirement. ## Error Handling -- **Wrong password**: AES-GCM authentication fails → `KeychainError::Decrypt` → frontend shows "Incorrect password" +- **Wrong password**: AES-GCM authentication fails → `KeychainError::Decrypt` → frontend shows "Incorrect password" toast. - **Public-key-only wallets**: `extract_secrets` returns `(None, None)` — no prompt needed. Frontend checks `has_secret_key` and `has_password` to decide. - **Lost password**: No recovery. AES-256-GCM + Argon2 is irreversible without the password. UI warns at password-set time. Matches industry standard (Chia reference wallet, MetaMask). -- **Biometric invalidation**: OS invalidates keychain items when biometric enrollment changes. Falls back to manual password entry. User re-enables biometric by entering password again. +- **Biometric enrollment changes**: OS invalidates keychain items when biometric enrollment changes. Next retrieval fails → falls back to password dialog → password re-stored in keychain on successful operation. +- **Biometric lockout**: After too many failed OS-level biometric attempts, the OS locks biometric temporarily. `requestPassword` detects retrieval failure and goes straight to the password dialog. +- **Stale keychain entry**: If the user changed their password but the keychain has the old one, the backend rejects with "Could not decrypt." The "Incorrect password" toast fires, the stale entry is cleared, and next attempt falls back to the dialog. +- **Device restore**: Keychain items may not survive a device restore depending on OS backup settings and access control flags. Same handling: retrieval fails → dialog fallback → re-stored on success. +- **App backgrounded during biometric**: OS may cancel the biometric prompt. Treated as cancellation → `requestPassword` returns `undefined`. +- **WalletConnect while backgrounded**: Biometric prompts cannot appear while backgrounded on iOS. `requestPassword` skips biometric and falls back to dialog (which will appear when the app returns to foreground). ## Migration @@ -268,9 +329,15 @@ Existing keys encrypted with `b""` continue to work — the user simply never ge The `keys.bin` format change (adding `password_protected` to `KeyData::Secret`) requires a deserialization fallback: try new format first, fall back to old format with `password_protected: false`. On next save, the file is written in the new format. +### New dependency (biometric bridge) + +- `tauri-plugin-keychain` — Rust crate (`tauri-plugin-keychain = "2"`) + JS bindings (`tauri-plugin-keychain`) for iOS Keychain / Android AccountManager access. Mobile-only. Added to `src-tauri/Cargo.toml` (mobile target) and `package.json`. +- Mobile capabilities (`src-tauri/capabilities/mobile.json`) — add `keychain:default` permission. + ## What's NOT Changing - `encrypt.rs` — AES-256-GCM + Argon2 implementation is already correct - `keys.bin` encryption scheme — same Argon2 + AES-256-GCM, just with real passwords instead of `b""` - Any sync, peer, or database logic - `SendTransactionImmediately`, `SubmitTransaction`, `ViewCoinSpends` — these operate on pre-signed spend bundles or read-only data and do not call `extract_secrets()` or `sign()` +- Backend — no backend changes for the biometric bridge. It's entirely frontend. From 953a9c0f6ade22a45b21c4c35656ad4a1455a799 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 20:09:41 -0500 Subject: [PATCH 15/53] feat: refactor PasswordContext to unified auth with keychain integration Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 8 +- src/contexts/PasswordContext.tsx | 195 +++++++++++++++++++++++++++---- 2 files changed, 177 insertions(+), 26 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d6c4076c7..9df1f16bd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -190,8 +190,8 @@ function AppInner() { initialized && isLocaleInitialized && ( - - + + @@ -199,8 +199,8 @@ function AppInner() { - - + + ) ); diff --git a/src/contexts/PasswordContext.tsx b/src/contexts/PasswordContext.tsx index 0b84f6926..731d76a99 100644 --- a/src/contexts/PasswordContext.tsx +++ b/src/contexts/PasswordContext.tsx @@ -1,55 +1,206 @@ import { PasswordDialog } from '@/components/dialogs/PasswordDialog'; +import { useBiometric } from '@/hooks/useBiometric'; +import { useWallet } from '@/contexts/WalletContext'; +import { platform } from '@tauri-apps/plugin-os'; import { createContext, ReactNode, useCallback, useRef, useState } from 'react'; +const isMobile = platform() === 'ios' || platform() === 'android'; + +// Lazy-initialized keychain module (mobile only) +let keychainPromise: Promise | null = null; +function getKeychain() { + if (!isMobile) return null; + if (!keychainPromise) keychainPromise = import('tauri-plugin-keychain'); + return keychainPromise; +} + +async function keychainGet(key: string): Promise { + const mod = getKeychain(); + if (!mod) return null; + try { + const { getItem } = await mod; + return await getItem(key); + } catch { + return null; + } +} + +async function keychainSave(key: string, password: string): Promise { + const mod = getKeychain(); + if (!mod) return; + try { + const { saveItem } = await mod; + await saveItem(key, password); + } catch { + // Silently fail — keychain storage is best-effort + } +} + +async function keychainRemove(key: string): Promise { + const mod = getKeychain(); + if (!mod) return; + try { + const { removeItem } = await mod; + await removeItem(key); + } catch { + // Silently fail + } +} + +// Biometric caching interval (5 minutes), matching existing BiometricContext behavior +const BIOMETRIC_CACHE_MS = 5 * 60 * 1000; + interface PasswordRequest { - resolve: (password: string | null) => void; + resolve: (password: string | null | undefined) => void; } export interface PasswordContextType { - /** - * If the wallet has a password, shows the password dialog and returns the entered password. - * If it doesn't, returns null immediately. - * Returns null if the user cancels. - */ - requestPassword: (hasPassword: boolean) => Promise; + requestPassword: (hasPassword: boolean) => Promise; + clearKeychainEntry: (fingerprint: number) => Promise; + updateKeychainEntry: (fingerprint: number, newPassword: string) => Promise; + clearAllKeychainEntries: (fingerprints: number[]) => Promise; } export const PasswordContext = createContext( undefined, ); +function keychainKey(fingerprint: number): string { + return `sage-password-${fingerprint.toString()}`; +} + export function PasswordProvider({ children }: { children: ReactNode }) { const [open, setOpen] = useState(false); const pendingRef = useRef(null); + const { enabled: biometricEnabled } = useBiometric(); + const { wallet } = useWallet(); + + // Stale keychain recovery: skip keychain for fingerprints that returned bad passwords + const skipKeychainRef = useRef>(new Set()); + + // Biometric caching for standalone gate (Case 2) + const lastBiometricPromptRef = useRef(null); const requestPassword = useCallback( - (hasPassword: boolean): Promise => { - if (!hasPassword) { - return Promise.resolve(null); + async (hasPassword: boolean): Promise => { + const fingerprint = wallet?.fingerprint; + + // Case 1: No password, no biometric → no auth needed + if (!hasPassword && !biometricEnabled) { + return null; + } + + // Case 2: No password, biometric enabled → standalone biometric gate with 5-min cache + if (!hasPassword && biometricEnabled && isMobile) { + const now = performance.now(); + if ( + lastBiometricPromptRef.current !== null && + now - lastBiometricPromptRef.current < BIOMETRIC_CACHE_MS + ) { + return null; // Within cache window, skip prompt + } + + try { + const { authenticate } = await import('@tauri-apps/plugin-biometric'); + await authenticate('Authenticate to continue', { + allowDeviceCredential: false, + }); + lastBiometricPromptRef.current = now; + return null; + } catch { + return undefined; // biometric failed/cancelled + } + } + + // Case 3: Has password, biometric enabled → try keychain first (unless skipped) + if ( + hasPassword && + biometricEnabled && + isMobile && + fingerprint && + !skipKeychainRef.current.has(fingerprint) + ) { + 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 + } + + // Case 4: Has password → show dialog (fallback or no biometric) + if (hasPassword) { + return new Promise((resolve) => { + pendingRef.current = { resolve }; + setOpen(true); + }); } - return new Promise((resolve) => { - pendingRef.current = { resolve }; - setOpen(true); - }); + return null; }, - [], + [biometricEnabled, wallet?.fingerprint], ); - const handleSubmit = useCallback((password: string) => { - setOpen(false); - pendingRef.current?.resolve(password); - pendingRef.current = null; - }, []); + const handleSubmit = useCallback( + (password: string) => { + setOpen(false); + const fingerprint = wallet?.fingerprint; + + // Manual password entry: clear skip flag and store in keychain + if (fingerprint) { + skipKeychainRef.current.delete(fingerprint); + if (biometricEnabled && isMobile) { + keychainSave(keychainKey(fingerprint), password); + } + } + + pendingRef.current?.resolve(password); + pendingRef.current = null; + }, + [biometricEnabled, wallet?.fingerprint], + ); const handleCancel = useCallback(() => { setOpen(false); - pendingRef.current?.resolve(null); + pendingRef.current?.resolve(undefined); pendingRef.current = null; }, []); + const clearKeychainEntry = useCallback(async (fingerprint: number) => { + skipKeychainRef.current.delete(fingerprint); + await keychainRemove(keychainKey(fingerprint)); + }, []); + + const updateKeychainEntry = useCallback( + async (fingerprint: number, newPassword: string) => { + skipKeychainRef.current.delete(fingerprint); + if (biometricEnabled && isMobile) { + await keychainSave(keychainKey(fingerprint), newPassword); + } + }, + [biometricEnabled], + ); + + const clearAllKeychainEntries = useCallback( + async (fingerprints: number[]) => { + for (const fp of fingerprints) { + skipKeychainRef.current.delete(fp); + await keychainRemove(keychainKey(fp)); + } + }, + [], + ); + return ( - + {children} Date: Sun, 15 Mar 2026 20:11:37 -0500 Subject: [PATCH 16/53] refactor: update ConfirmationDialog to unified auth pattern Co-Authored-By: Claude Sonnet 4.6 --- src/components/ConfirmationDialog.tsx | 46 ++++++++++++--------------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/src/components/ConfirmationDialog.tsx b/src/components/ConfirmationDialog.tsx index 13db8eb1c..ef180673a 100644 --- a/src/components/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog.tsx @@ -8,7 +8,6 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { LoadingButton } from '@/components/ui/loading-button'; -import { useBiometric } from '@/hooks/useBiometric'; import { useErrors } from '@/hooks/useErrors'; import { usePassword } from '@/hooks/usePassword'; import { useWallet } from '@/contexts/WalletContext'; @@ -66,7 +65,6 @@ export default function ConfirmationDialog({ const ticker = walletState.sync.unit.ticker; const { addError } = useErrors(); - const { promptIfEnabled } = useBiometric(); const { requestPassword } = usePassword(); const { wallet } = useWallet(); @@ -522,27 +520,25 @@ export default function ConfirmationDialog({ const password = await requestPassword( wallet?.has_password ?? false, ); - if (password === null && wallet?.has_password) return; - - if (await promptIfEnabled()) { - commands - .signCoinSpends({ - coin_spends: - response === null - ? [] - : 'coin_spends' in response - ? response.coin_spends - : response.spend_bundle.coin_spends, - password, - }) - .then((data) => { - setSignature( - data.spend_bundle.aggregated_signature, - ); - toast.success(t`Transaction signed successfully`); - }) - .catch(addError); - } + if (password === undefined) return; + + commands + .signCoinSpends({ + coin_spends: + response === null + ? [] + : 'coin_spends' in response + ? response.coin_spends + : response.spend_bundle.coin_spends, + password, + }) + .then((data) => { + setSignature( + data.spend_bundle.aggregated_signature, + ); + toast.success(t`Transaction signed successfully`); + }) + .catch(addError); }} disabled={!!signature} > @@ -633,9 +629,7 @@ export default function ConfirmationDialog({ const password = await requestPassword( wallet?.has_password ?? false, ); - if (password === null && wallet?.has_password) return; - - if (!(await promptIfEnabled())) return; + if (password === undefined) return; const data = await commands .signCoinSpends({ From 9b25946686cdfd2af3439d761ac464b86e648ff3 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 20:12:02 -0500 Subject: [PATCH 17/53] refactor: update WalletCard to unified auth pattern Co-Authored-By: Claude Sonnet 4.6 --- src/components/WalletCard.tsx | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/src/components/WalletCard.tsx b/src/components/WalletCard.tsx index a371412b8..89abb0e55 100644 --- a/src/components/WalletCard.tsx +++ b/src/components/WalletCard.tsx @@ -20,7 +20,6 @@ import { } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { useBiometric } from '@/hooks/useBiometric'; import { useErrors } from '@/hooks/useErrors'; import { usePassword } from '@/hooks/usePassword'; import { useSortable } from '@dnd-kit/sortable'; @@ -67,7 +66,6 @@ export function WalletCard({ const navigate = useNavigate(); const { addError } = useErrors(); const { setWallet } = useWallet(); - const { promptIfEnabled } = useBiometric(); const { requestPassword } = usePassword(); const [isDeleteOpen, setIsDeleteOpen] = useState(false); @@ -81,15 +79,14 @@ export function WalletCard({ const { currentTheme } = useTheme(); const deleteSelf = async () => { - if (await promptIfEnabled()) { - await commands - .deleteKey({ fingerprint: info.fingerprint }) - .then(() => - setKeys(keys.filter((key) => key.fingerprint !== info.fingerprint)), - ) - .catch(addError); - } - + const password = await requestPassword(info.has_password); + if (password === undefined) return; + await commands + .deleteKey({ fingerprint: info.fingerprint }) + .then(() => + setKeys(keys.filter((key) => key.fingerprint !== info.fingerprint)), + ) + .catch(addError); setIsDeleteOpen(false); }; @@ -177,12 +174,7 @@ export function WalletCard({ } const password = await requestPassword(info.has_password); - if (password === null && info.has_password) { - setIsDetailsOpen(false); - return; - } - - if (!(await promptIfEnabled())) { + if (password === undefined) { setIsDetailsOpen(false); return; } @@ -197,7 +189,6 @@ export function WalletCard({ info.fingerprint, info.has_password, addError, - promptIfEnabled, requestPassword, ]); From ff3f658ef1eba62eaae8def95e77fe5d6c5e1f71 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 20:12:25 -0500 Subject: [PATCH 18/53] refactor: update useOfferProcessor to unified auth pattern Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/useOfferProcessor.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/hooks/useOfferProcessor.ts b/src/hooks/useOfferProcessor.ts index 47ed0aaae..320acb0e5 100644 --- a/src/hooks/useOfferProcessor.ts +++ b/src/hooks/useOfferProcessor.ts @@ -1,6 +1,5 @@ import { commands, OfferAmount } from '@/bindings'; import { useWallet } from '@/contexts/WalletContext'; -import { useBiometric } from '@/hooks/useBiometric'; import { usePassword } from '@/hooks/usePassword'; import { toMojos } from '@/lib/utils'; import { OfferState, useWalletState } from '@/state'; @@ -29,7 +28,6 @@ export function useOfferProcessor({ onProgress, }: UseOfferProcessorProps): UseOfferProcessorReturn { const walletState = useWalletState(); - const { promptIfEnabled } = useBiometric(); const { requestPassword } = usePassword(); const { wallet } = useWallet(); const [createdOffers, setCreatedOffers] = useState([]); @@ -65,12 +63,8 @@ export function useOfferProcessor({ } const password = await requestPassword(wallet?.has_password ?? false); - if (password === null && wallet?.has_password) { - throw new Error(t`Password authentication was cancelled`); - } - - if (!(await promptIfEnabled())) { - throw new Error(t`Biometric authentication failed or was cancelled`); + if (password === undefined) { + throw new Error(t`Authentication was cancelled`); } const offeredTokens = offerState.offered.tokens.map((token) => ({ @@ -190,7 +184,6 @@ export function useOfferProcessor({ offerState, splitNftOffers, walletState.sync.unit.precision, - promptIfEnabled, requestPassword, wallet?.has_password, onProcessingEnd, From bd2a1daa944dd6a78c56c416af81623016950da8 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 20:14:13 -0500 Subject: [PATCH 19/53] refactor: update WalletConnect to unified auth pattern Replace two-step promptIfEnabled + requestPassword auth with unified requestPassword returning string | null | undefined, where undefined means cancelled. Co-Authored-By: Claude Sonnet 4.6 --- src/contexts/WalletConnectContext.tsx | 4 ---- src/walletconnect/commands/chip0002.ts | 14 ++------------ src/walletconnect/commands/high-level.ts | 21 +++------------------ src/walletconnect/commands/offers.ts | 21 +++------------------ src/walletconnect/handler.ts | 3 +-- 5 files changed, 9 insertions(+), 54 deletions(-) diff --git a/src/contexts/WalletConnectContext.tsx b/src/contexts/WalletConnectContext.tsx index 88ebe16cb..8c1baa2f8 100644 --- a/src/contexts/WalletConnectContext.tsx +++ b/src/contexts/WalletConnectContext.tsx @@ -20,7 +20,6 @@ import { import { LoadingButton } from '@/components/ui/loading-button'; import { Switch } from '@/components/ui/switch'; import { useWallet } from '@/contexts/WalletContext'; -import { useBiometric } from '@/hooks/useBiometric'; import { useErrors } from '@/hooks/useErrors'; import { usePassword } from '@/hooks/usePassword'; import { decodeHexMessage, fromMojos, isHex } from '@/lib/utils'; @@ -65,7 +64,6 @@ type SessionRequest = SignClientTypes.EventArguments['session_request']; export function WalletConnectProvider({ children }: { children: ReactNode }) { const { wallet } = useWallet(); const { addError } = useErrors(); - const { promptIfEnabled } = useBiometric(); const { requestPassword } = usePassword(); const [signClient, setSignClient] = useState { @@ -108,12 +103,7 @@ export async function handleSignMessage( context: HandlerContext, ) { const password = await context.requestPassword(context.hasPassword); - if (password === null && context.hasPassword) - throw new Error('Authentication failed'); - - if (!(await context.promptIfEnabled())) { - throw new Error('Authentication failed'); - } + if (password === undefined) throw new Error('Authentication failed'); const data = await commands.signMessageWithPublicKey({ ...params, password }); diff --git a/src/walletconnect/commands/high-level.ts b/src/walletconnect/commands/high-level.ts index 5ff94c78e..a5865af66 100644 --- a/src/walletconnect/commands/high-level.ts +++ b/src/walletconnect/commands/high-level.ts @@ -48,12 +48,7 @@ export async function handleSend( context: HandlerContext, ): Promise> { const password = await context.requestPassword(context.hasPassword); - if (password === null && context.hasPassword) - throw new Error('Authentication failed'); - - if (!(await context.promptIfEnabled())) { - throw new Error('Authentication failed'); - } + if (password === undefined) throw new Error('Authentication failed'); if (params.assetId) { await commands.sendCat({ @@ -90,12 +85,7 @@ export async function handleSignMessageByAddress( context: HandlerContext, ) { const password = await context.requestPassword(context.hasPassword); - if (password === null && context.hasPassword) - throw new Error('Authentication failed'); - - if (!(await context.promptIfEnabled())) { - throw new Error('Authentication failed'); - } + if (password === undefined) throw new Error('Authentication failed'); return await commands.signMessageByAddress({ ...params, password }); } @@ -105,12 +95,7 @@ export async function handleBulkMintNfts( context: HandlerContext, ): Promise> { const password = await context.requestPassword(context.hasPassword); - if (password === null && context.hasPassword) - throw new Error('Authentication failed'); - - if (!(await context.promptIfEnabled())) { - throw new Error('Authentication failed'); - } + if (password === undefined) throw new Error('Authentication failed'); const response = await commands.bulkMintNfts({ did_id: params.did, diff --git a/src/walletconnect/commands/offers.ts b/src/walletconnect/commands/offers.ts index be2298c53..b15fd2699 100644 --- a/src/walletconnect/commands/offers.ts +++ b/src/walletconnect/commands/offers.ts @@ -7,12 +7,7 @@ export async function handleCreateOffer( context: HandlerContext, ) { const password = await context.requestPassword(context.hasPassword); - if (password === null && context.hasPassword) - throw new Error('Authentication failed'); - - if (!(await context.promptIfEnabled())) { - throw new Error('Authentication failed'); - } + if (password === undefined) throw new Error('Authentication failed'); const data = await commands.makeOffer({ fee: params.fee ?? 0, @@ -41,12 +36,7 @@ export async function handleTakeOffer( context: HandlerContext, ) { const password = await context.requestPassword(context.hasPassword); - if (password === null && context.hasPassword) - throw new Error('Authentication failed'); - - if (!(await context.promptIfEnabled())) { - throw new Error('Authentication failed'); - } + if (password === undefined) throw new Error('Authentication failed'); const data = await commands.takeOffer({ offer: params.offer, @@ -63,12 +53,7 @@ export async function handleCancelOffer( context: HandlerContext, ) { const password = await context.requestPassword(context.hasPassword); - if (password === null && context.hasPassword) - throw new Error('Authentication failed'); - - if (!(await context.promptIfEnabled())) { - throw new Error('Authentication failed'); - } + if (password === undefined) throw new Error('Authentication failed'); await commands.cancelOffer({ offer_id: params.id, diff --git a/src/walletconnect/handler.ts b/src/walletconnect/handler.ts index f8d2c9bfd..1f86bfc49 100644 --- a/src/walletconnect/handler.ts +++ b/src/walletconnect/handler.ts @@ -24,8 +24,7 @@ import { } from './commands/offers'; export interface HandlerContext { - promptIfEnabled: () => Promise; - requestPassword: (hasPassword: boolean) => Promise; + requestPassword: (hasPassword: boolean) => Promise; hasPassword: boolean; } From dba826715d3ee7a221ffeeda7005e55e3cd72049 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 20:18:57 -0500 Subject: [PATCH 20/53] refactor: update remaining call sites to unified auth pattern Co-Authored-By: Claude Sonnet 4.6 --- src/components/OfferRowCard.tsx | 2 +- src/pages/Offer.tsx | 2 +- src/pages/Offers.tsx | 2 +- src/pages/Settings.tsx | 10 ++++++---- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/OfferRowCard.tsx b/src/components/OfferRowCard.tsx index ac1599671..b56afd049 100644 --- a/src/components/OfferRowCard.tsx +++ b/src/components/OfferRowCard.tsx @@ -65,7 +65,7 @@ export function OfferRowCard({ record, refresh }: OfferRowCardProps) { const cancelHandler = async (values: z.infer) => { const password = await requestPassword(wallet?.has_password ?? false); - if (password === null && wallet?.has_password) return; + if (password === undefined) return; const fee = toMojos(values.fee, walletState.sync.unit.precision); diff --git a/src/pages/Offer.tsx b/src/pages/Offer.tsx index 490a4e7d8..c147b5088 100644 --- a/src/pages/Offer.tsx +++ b/src/pages/Offer.tsx @@ -101,7 +101,7 @@ export function Offer() { try { const password = await requestPassword(wallet?.has_password ?? false); - if (password === null && wallet?.has_password) return; + if (password === undefined) return; const result = await commands.takeOffer({ offer: resolvedOffer, diff --git a/src/pages/Offers.tsx b/src/pages/Offers.tsx index 2b7840351..11ffc7861 100644 --- a/src/pages/Offers.tsx +++ b/src/pages/Offers.tsx @@ -191,7 +191,7 @@ export function Offers() { const cancelAllHandler = async (values: z.infer) => { const password = await requestPassword(wallet?.has_password ?? false); - if (password === null && wallet?.has_password) return; + if (password === undefined) return; const fee = toMojos(values.fee, walletState.sync.unit.precision); const activeOffers = filteredOffers.filter( diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 3eb4b2ba3..ce1ccc85c 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -938,7 +938,7 @@ function LogViewer() { function RpcSettings() { const { addError } = useErrors(); - const { promptIfEnabled } = useBiometric(); + const { requestPassword } = usePassword(); const [isRunning, setIsRunning] = useState(false); const [runOnStartup, setRunOnStartup] = useState(false); @@ -961,7 +961,8 @@ function RpcSettings() { }, [addError]); const start = async () => { - if (!(await promptIfEnabled())) return; + const auth = await requestPassword(false); + if (auth === undefined) return; commands .startRpcServer() @@ -977,7 +978,8 @@ function RpcSettings() { }; const toggleRunOnStartup = async (checked: boolean) => { - if (!(await promptIfEnabled())) return; + const auth = await requestPassword(false); + if (auth === undefined) return; commands .setRpcRunOnStartup(checked) @@ -1255,7 +1257,7 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { const needsPassword = key?.has_secrets && hardened; if (needsPassword) { const password = await requestPassword(key?.has_password ?? false); - if (password === null && key?.has_password) return; + if (password === undefined) return; setPending(true); commands From 5196a91d8ee58befebf665ffe7c376f50b8f255e Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 20:21:16 -0500 Subject: [PATCH 21/53] feat: move biometric toggle to Security section with context-aware labels Co-Authored-By: Claude Sonnet 4.6 --- src/pages/Settings.tsx | 64 +++++++++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index ce1ccc85c..23802dbfd 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -262,7 +262,6 @@ function GlobalSettings() { const { locale, changeLanguage } = useLanguage(); const { expiry, setExpiry } = useDefaultOfferExpiry(); const { clawback, setClawback } = useDefaultClawback(); - const { enabled, available, enableIfAvailable, disable } = useBiometric(); const { setFee } = useDefaultFee(); const [defaultWalletConfig, setDefaultWalletConfig] = @@ -272,16 +271,6 @@ function GlobalSettings() { commands.defaultWalletConfig().then(setDefaultWalletConfig).catch(addError); }, [addError]); - const isMobile = platform() === 'ios' || platform() === 'android'; - - const toggleBiometric = async (value: boolean) => { - if (value) { - await enableIfAvailable(); - } else { - await disable(); - } - }; - return ( <> @@ -304,19 +293,6 @@ function GlobalSettings() {
- {isMobile && ( - - } - /> - )} { + if (value) { + await enableIfAvailable(); + } else { + await disable(); + // Clear all stored passwords from keychain when biometric is disabled + const keysData = await commands.getKeys({}); + await clearAllKeychainEntries(keysData.keys.map((k) => k.fingerprint)); + } + }; const walletState = useWalletState(); @@ -1421,6 +1410,31 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { ) } /> + {isMobile && available && ( + <> + + } + /> + {key?.has_password && biometricEnabled && ( +
+

+ {t`Biometric unlock is a convenience — remember your password. There is no way to recover a lost password.`} +

+
+ )} + + )}
)} From 1ae94ecc4de5238bf1dffd13aa9d9d4e41f5fec5 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 20:21:33 -0500 Subject: [PATCH 22/53] feat: handle keychain lifecycle for password changes Co-Authored-By: Claude Sonnet 4.6 --- src/pages/Settings.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 23802dbfd..8fdc4fa77 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1223,6 +1223,14 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { setPasswordDialogOpen(false); + // Update keychain entries based on the operation + if (passwordDialogMode === 'change') { + await updateKeychainEntry(fingerprint, newPassword); + } else if (passwordDialogMode === 'remove') { + await clearKeychainEntry(fingerprint); + } + // 'set' mode: no keychain action needed — store-on-first-use will handle it + // Refresh key info to update has_password const data = await commands.getKey({ fingerprint }); setKey(data.key); From bfa733da04bf6039d8e643250e5128a33f84fb17 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 15 Mar 2026 20:21:49 -0500 Subject: [PATCH 23/53] feat: clear keychain entry on wallet deletion Co-Authored-By: Claude Sonnet 4.6 --- src/components/WalletCard.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/WalletCard.tsx b/src/components/WalletCard.tsx index 89abb0e55..a3ceb6f8a 100644 --- a/src/components/WalletCard.tsx +++ b/src/components/WalletCard.tsx @@ -66,7 +66,7 @@ export function WalletCard({ const navigate = useNavigate(); const { addError } = useErrors(); const { setWallet } = useWallet(); - const { requestPassword } = usePassword(); + const { requestPassword, clearKeychainEntry } = usePassword(); const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isDetailsOpen, setIsDetailsOpen] = useState(false); @@ -83,9 +83,10 @@ export function WalletCard({ if (password === undefined) return; await commands .deleteKey({ fingerprint: info.fingerprint }) - .then(() => - setKeys(keys.filter((key) => key.fingerprint !== info.fingerprint)), - ) + .then(async () => { + await clearKeychainEntry(info.fingerprint); + setKeys(keys.filter((key) => key.fingerprint !== info.fingerprint)); + }) .catch(addError); setIsDeleteOpen(false); }; From 23bdc9cdfaafe8ccbe9c44e72b41996cb9869101 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 07:42:28 -0500 Subject: [PATCH 24/53] prettier --- Cargo.lock | 13 + .../2026-03-15-biometric-password-bridge.md | 1036 +++++++++++++++++ .../2026-03-15-password-protection-design.md | 38 +- src-tauri/gen/apple/sage-tauri_iOS/Info.plist | 2 +- .../sage-tauri_iOS.entitlements | 2 +- src/components/ConfirmationDialog.tsx | 4 +- src/contexts/PasswordContext.tsx | 8 +- src/contexts/WalletConnectContext.tsx | 7 +- src/pages/Settings.tsx | 20 +- 9 files changed, 1095 insertions(+), 35 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-15-biometric-password-bridge.md diff --git a/Cargo.lock b/Cargo.lock index f4949c3fe..9e5b379b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6410,6 +6410,7 @@ dependencies = [ "tauri-plugin-clipboard-manager", "tauri-plugin-dialog", "tauri-plugin-fs", + "tauri-plugin-keychain", "tauri-plugin-opener", "tauri-plugin-os", "tauri-plugin-safe-area-insets", @@ -7591,6 +7592,18 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-keychain" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f23e3f77ea4c7218b7268090b76f822efe729463570d39396c8f80dfc49d6cb6" +dependencies = [ + "serde", + "tauri", + "tauri-plugin", + "thiserror 1.0.69", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" diff --git a/docs/superpowers/plans/2026-03-15-biometric-password-bridge.md b/docs/superpowers/plans/2026-03-15-biometric-password-bridge.md new file mode 100644 index 000000000..f777ae5f8 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-biometric-password-bridge.md @@ -0,0 +1,1036 @@ +# Biometric-Password Bridge Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Unify password and biometric authentication into a single `requestPassword` entry point that stores/retrieves passwords from the OS keychain via biometric on mobile. + +**Architecture:** `PasswordContext.requestPassword()` becomes the sole auth gate. On mobile with biometric enabled, it attempts keychain retrieval (which triggers OS biometric prompt) before falling back to the password dialog. On first successful typed password, the password is stored in the keychain for future biometric retrieval. `BiometricContext` retains state management (`enabled`, `available`) but `promptIfEnabled()` is no longer called at operation sites. + +**Tech Stack:** `tauri-plugin-keychain` (Rust + JS) for iOS Keychain / Android AccountManager, `@tauri-apps/plugin-biometric` (existing), React context + hooks, TypeScript. + +**Spec:** `docs/superpowers/specs/2026-03-15-password-protection-design.md` + +--- + +## File Structure + +### New files + +- None — all changes are modifications to existing files + +### Modified files + +| File | Responsibility | +| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `src-tauri/Cargo.toml` | Add `tauri-plugin-keychain` dependency (mobile target) | +| `src-tauri/src/lib.rs` | Register keychain plugin in `#[cfg(mobile)]` block | +| `src-tauri/capabilities/mobile.json` | Add keychain permission | +| `package.json` | Add `tauri-plugin-keychain` JS package | +| `src/contexts/PasswordContext.tsx` | Integrate keychain store/retrieve into `requestPassword`, change return type to `string \| null \| undefined` | +| `src/hooks/usePassword.ts` | Update type to match new `PasswordContextType` | +| `src/App.tsx` | Restructure provider tree: `WalletProvider` → `PasswordProvider` → `WalletConnectProvider` | +| `src/contexts/WalletConnectContext.tsx` | Remove `promptIfEnabled` usage, update auth pattern | +| `src/walletconnect/handler.ts` | Remove `promptIfEnabled` from `HandlerContext` | +| `src/walletconnect/commands/chip0002.ts` | Remove `promptIfEnabled` calls, update auth check | +| `src/walletconnect/commands/offers.ts` | Remove `promptIfEnabled` calls, update auth check | +| `src/walletconnect/commands/high-level.ts` | Remove `promptIfEnabled` calls, update auth check | +| `src/components/ConfirmationDialog.tsx` | Remove `promptIfEnabled`, update auth pattern | +| `src/components/WalletCard.tsx` | Remove `promptIfEnabled`, update auth pattern, keychain cleanup on delete | +| `src/components/OfferRowCard.tsx` | Update auth check pattern (`password === null` → `password === undefined`) | +| `src/hooks/useOfferProcessor.ts` | Remove `promptIfEnabled`, update auth pattern | +| `src/pages/Settings.tsx` | Remove `promptIfEnabled` from RpcSettings, update `increaseDerivationIndex` auth pattern, move biometric toggle to Security section | +| `src/pages/Offers.tsx` | Update auth check pattern | +| `src/pages/Offer.tsx` | Update auth check pattern | +| `docs/superpowers/specs/2026-03-15-password-protection-design.md` | Update plugin reference from `tauri-plugin-keystore` to `tauri-plugin-keychain`, update provider placement | + +--- + +## Chunk 1: Dependencies and Plugin Registration + +### Task 1: Add tauri-plugin-keychain Rust dependency + +**Files:** + +- Modify: `src-tauri/Cargo.toml:50-54` + +- [ ] **Step 1: Add keychain dependency to mobile target** + +Add `tauri-plugin-keychain` to the mobile-only dependencies section: + +```toml +[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] +tauri-plugin-biometric = { workspace = true } +tauri-plugin-barcode-scanner = { workspace = true } +tauri-plugin-safe-area-insets = { workspace = true } +tauri-plugin-sage = { workspace = true } +tauri-plugin-keychain = "2" +``` + +- [ ] **Step 2: Register keychain plugin in lib.rs** + +Modify: `src-tauri/src/lib.rs:170-178` + +Add `tauri_plugin_keychain::init()` to the `#[cfg(mobile)]` block: + +```rust +#[cfg(mobile)] +{ + tauri_builder = tauri_builder + .plugin(tauri_plugin_barcode_scanner::init()) + .plugin(tauri_plugin_safe_area_insets::init()) + .plugin(tauri_plugin_biometric::init()) + .plugin(tauri_plugin_sharesheet::init()) + .plugin(tauri_plugin_sage::init()) + .plugin(tauri_plugin_keychain::init()); +} +``` + +- [ ] **Step 3: Add keychain permission to mobile capabilities** + +Modify: `src-tauri/capabilities/mobile.json` + +```json +{ + "$schema": "../gen/schemas/mobile-schema.json", + "identifier": "mobile-capability", + "windows": ["main"], + "platforms": ["android", "iOS"], + "permissions": [ + "safe-area-insets:default", + "barcode-scanner:default", + "biometric:default", + "sage:default", + "sharesheet:allow-share-text", + "keychain:default" + ] +} +``` + +- [ ] **Step 4: Add JS package dependency** + +Run: `pnpm add tauri-plugin-keychain` + +- [ ] **Step 5: Verify the project builds** + +Run: `pnpm tauri build --debug` or at minimum `cargo check` in `src-tauri/` + +- [ ] **Step 6: Commit** + +```bash +git add src-tauri/Cargo.toml src-tauri/src/lib.rs src-tauri/capabilities/mobile.json package.json pnpm-lock.yaml +git commit -m "feat: add tauri-plugin-keychain dependency for biometric-password bridge" +``` + +--- + +### Task 2: Update spec to reference tauri-plugin-keychain + +**Files:** + +- Modify: `docs/superpowers/specs/2026-03-15-password-protection-design.md` + +- [ ] **Step 1: Replace all references to `tauri-plugin-keystore` with `tauri-plugin-keychain`** + +In the spec, find and replace: + +- `tauri-plugin-keystore` → `tauri-plugin-keychain` +- Update the plugin description from "Simple API: `store(key, value)`, `retrieve(key)`, `remove(key)`" to "Key-value API: `saveItem(key, password)`, `getItem(key)`, `removeItem(key)`" +- Update the dependency section: "wraps iOS Keychain and Android AccountManager" instead of "iOS Keychain and Android Keystore (API 28+)" +- Update the Cargo dependency line to `tauri-plugin-keychain = "2"` + +- [ ] **Step 2: Update provider placement in spec** + +The spec currently says (line 226): "Provider placement: Inside `I18nProvider`... but wrapping `WalletProvider`". Update to reflect the new dependency chain: + +> **Provider placement:** Inside `I18nProvider` and `WalletProvider` (required because `PasswordProvider` now reads `useWallet()` for the fingerprint and `useBiometric()` for the enabled state). Wraps `WalletConnectProvider` and all downstream providers, so `usePassword()` is available everywhere. + +New tree: `BiometricProvider` → `I18nProvider` → `WalletProvider` → `PasswordProvider` → `PeerProvider` → `WalletConnectProvider` → `PriceProvider` → `RouterProvider` + +- [ ] **Step 3: Commit** + +```bash +git add docs/superpowers/specs/2026-03-15-password-protection-design.md +git commit -m "docs: update spec to reference tauri-plugin-keychain" +``` + +--- + +## Chunk 2: Unified PasswordContext with Keychain Integration + +### Task 3: Refactor PasswordContext to unified auth entry point + +This is the core task. `requestPassword` gains keychain awareness and subsumes the standalone biometric gate. + +**Files:** + +- Modify: `src/contexts/PasswordContext.tsx` +- Modify: `src/hooks/usePassword.ts` + +- [ ] **Step 1: Write the updated PasswordContext** + +The new `requestPassword` implements the decision tree from the spec. Key design decisions: + +1. **Store-after-validation, not store-on-submit:** `handleSubmit` does NOT store the password in the keychain. Instead, `requestPassword` returns the password and the caller's backend operation validates it. Only after a successful operation does the password get stored. This is achieved by `requestPassword` saving the typed password in `pendingStoreRef` and only writing to keychain on the _next_ successful keychain-skipped call (i.e., the password worked last time). +2. **Stale keychain recovery:** A `skipKeychainRef` tracks fingerprints where keychain retrieval returned a password that was rejected. On the next `requestPassword` call for that fingerprint, keychain is skipped and the dialog is shown instead. +3. **Biometric caching for standalone gate:** The existing 5-minute caching window from `BiometricContext.promptIfEnabled()` is preserved for Case 2 (no password, biometric enabled). A `lastBiometricPromptRef` tracks the last successful biometric auth time. + +```typescript +// src/contexts/PasswordContext.tsx +import { PasswordDialog } from '@/components/dialogs/PasswordDialog'; +import { useBiometric } from '@/hooks/useBiometric'; +import { useWallet } from '@/contexts/WalletContext'; +import { platform } from '@tauri-apps/plugin-os'; +import { createContext, ReactNode, useCallback, useRef, useState } from 'react'; + +const isMobile = platform() === 'ios' || platform() === 'android'; + +// Lazy-initialized keychain module (mobile only) +let keychainPromise: Promise | null = null; +function getKeychain() { + if (!isMobile) return null; + if (!keychainPromise) keychainPromise = import('tauri-plugin-keychain'); + return keychainPromise; +} + +async function keychainGet(key: string): Promise { + const mod = getKeychain(); + if (!mod) return null; + try { + const { getItem } = await mod; + return await getItem(key); + } catch { + return null; + } +} + +async function keychainSave(key: string, password: string): Promise { + const mod = getKeychain(); + if (!mod) return; + try { + const { saveItem } = await mod; + await saveItem(key, password); + } catch { + // Silently fail — keychain storage is best-effort + } +} + +async function keychainRemove(key: string): Promise { + const mod = getKeychain(); + if (!mod) return; + try { + const { removeItem } = await mod; + await removeItem(key); + } catch { + // Silently fail + } +} + +// Biometric caching interval (5 minutes), matching the existing BiometricContext behavior +const BIOMETRIC_CACHE_MS = 5 * 60 * 1000; + +interface PasswordRequest { + resolve: (password: string | null | undefined) => void; +} + +export interface PasswordContextType { + /** + * Unified auth entry point for all protected operations. + * + * Returns: + * - string: use this password (typed or retrieved from keychain) + * - null: no password needed, auth passed + * - undefined: auth cancelled or failed, abort the operation + */ + requestPassword: (hasPassword: boolean) => Promise; + + /** Clear the keychain entry for a specific wallet fingerprint */ + clearKeychainEntry: (fingerprint: number) => Promise; + + /** Update the keychain entry after a password change */ + updateKeychainEntry: (fingerprint: number, newPassword: string) => Promise; + + /** Clear all keychain entries (used when disabling biometric) */ + clearAllKeychainEntries: (fingerprints: number[]) => Promise; +} + +export const PasswordContext = createContext( + undefined, +); + +function keychainKey(fingerprint: number): string { + return `sage-password-${fingerprint.toString()}`; +} + +export function PasswordProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + const pendingRef = useRef(null); + const { enabled: biometricEnabled } = useBiometric(); + const { wallet } = useWallet(); + + // Stale keychain recovery: skip keychain for fingerprints that returned bad passwords + const skipKeychainRef = useRef>(new Set()); + + // Biometric caching for standalone gate (Case 2) + const lastBiometricPromptRef = useRef(null); + + const requestPassword = useCallback( + async (hasPassword: boolean): Promise => { + const fingerprint = wallet?.fingerprint; + + // Case 1: No password, no biometric → no auth needed + if (!hasPassword && !biometricEnabled) { + return null; + } + + // Case 2: No password, biometric enabled → standalone biometric gate with 5-min cache + if (!hasPassword && biometricEnabled && isMobile) { + const now = performance.now(); + if ( + lastBiometricPromptRef.current !== null && + now - lastBiometricPromptRef.current < BIOMETRIC_CACHE_MS + ) { + return null; // Within cache window, skip prompt + } + + try { + const { authenticate } = await import('@tauri-apps/plugin-biometric'); + await authenticate('Authenticate to continue', { + allowDeviceCredential: false, + }); + lastBiometricPromptRef.current = now; + return null; + } catch { + return undefined; // biometric failed/cancelled + } + } + + // Case 3: Has password, biometric enabled → try keychain first (unless skipped) + if ( + hasPassword && + biometricEnabled && + isMobile && + fingerprint && + !skipKeychainRef.current.has(fingerprint) + ) { + 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 + } + + // Case 4: Has password → show dialog (fallback or no biometric) + if (hasPassword) { + return new Promise((resolve) => { + pendingRef.current = { resolve }; + setOpen(true); + }); + } + + return null; + }, + [biometricEnabled, wallet?.fingerprint], + ); + + const handleSubmit = useCallback( + (password: string) => { + setOpen(false); + const fingerprint = wallet?.fingerprint; + + // Manual password entry: clear skip flag and store in keychain + // The password will be validated by the backend. If it's wrong, the + // "Incorrect password" toast fires. On next requestPassword, keychain + // will return this password, skipKeychainRef kicks in, and dialog shows. + if (fingerprint) { + skipKeychainRef.current.delete(fingerprint); + if (biometricEnabled && isMobile) { + keychainSave(keychainKey(fingerprint), password); + } + } + + pendingRef.current?.resolve(password); + pendingRef.current = null; + }, + [biometricEnabled, wallet?.fingerprint], + ); + + const handleCancel = useCallback(() => { + setOpen(false); + pendingRef.current?.resolve(undefined); // undefined = cancelled + pendingRef.current = null; + }, []); + + const clearKeychainEntry = useCallback(async (fingerprint: number) => { + skipKeychainRef.current.delete(fingerprint); + await keychainRemove(keychainKey(fingerprint)); + }, []); + + const updateKeychainEntry = useCallback( + async (fingerprint: number, newPassword: string) => { + skipKeychainRef.current.delete(fingerprint); + if (biometricEnabled && isMobile) { + await keychainSave(keychainKey(fingerprint), newPassword); + } + }, + [biometricEnabled], + ); + + const clearAllKeychainEntries = useCallback( + async (fingerprints: number[]) => { + for (const fp of fingerprints) { + skipKeychainRef.current.delete(fp); + await keychainRemove(keychainKey(fp)); + } + }, + [], + ); + + return ( + + {children} + + + ); +} +``` + +**Important notes for the implementer:** + +- `PasswordProvider` now depends on `useBiometric()` and `useWallet()`. It must be placed inside both `BiometricProvider` and `WalletProvider` in the component tree. This is a change from the current placement where `PasswordProvider` wraps `WalletProvider`. See Step 3 for the new provider tree. +- **Stale keychain recovery:** When `requestPassword` returns a keychain-retrieved password, it adds the fingerprint to `skipKeychainRef`. If the backend accepts the password (normal flow), the _next_ `requestPassword` will skip keychain and show the dialog. But the user won't see this because the operation succeeded. If the backend _rejects_ the password (stale entry), the "Incorrect password" toast fires, and the next `requestPassword` shows the dialog. The user types the correct password in the dialog, which clears the skip flag and updates the keychain. +- **Why store-on-dialog-submit is acceptable:** The dialog password is stored in the keychain immediately on submit, before backend validation. If the user types a wrong password, it gets stored in the keychain — but `skipKeychainRef` ensures the next call skips keychain and shows the dialog again. When the user eventually types the correct password, it replaces the stale entry. This is a pragmatic trade-off: the one-wrong-password-in-keychain scenario is self-healing. +- The lazy `getKeychain()` import ensures the plugin is only loaded on mobile. On desktop, all keychain operations are no-ops. +- `keychainKey` uses `fingerprint.toString()` explicitly for clarity, though JS number-to-string conversion of u32 values is safe. + +- [ ] **Step 2: Update usePassword hook type** + +Update `src/hooks/usePassword.ts` — the type is already correct since it just re-exports the context, but verify it matches. + +- [ ] **Step 3: Restructure provider tree in App.tsx** + +`PasswordProvider` now depends on `useBiometric()` (from `BiometricProvider` in outer `App`) and `useWallet()` (from `WalletProvider`). `WalletConnectContext` uses `usePassword()`, so `PasswordProvider` must wrap it. The correct ordering is: + +``` +BiometricProvider (outer App) → I18nProvider → WalletProvider → PasswordProvider → WalletConnectProvider +``` + +Update `src/App.tsx` `AppInner`: + +```typescript + + + + + + + + + + + + + +``` + +- [ ] **Step 4: Verify build compiles** + +Run: `pnpm tsc --noEmit` + +- [ ] **Step 5: Commit** + +```bash +git add src/contexts/PasswordContext.tsx src/hooks/usePassword.ts src/App.tsx +git commit -m "feat: refactor PasswordContext to unified auth with keychain integration" +``` + +--- + +## Chunk 3: Update All Call Sites + +Every call site currently has a two-step pattern: + +```typescript +const password = await requestPassword(wallet?.has_password ?? false); +if (password === null && wallet?.has_password) return; +if (!(await promptIfEnabled())) return; +``` + +This becomes: + +```typescript +const password = await requestPassword(wallet?.has_password ?? false); +if (password === undefined) return; +``` + +### Task 4: Update ConfirmationDialog + +**Files:** + +- Modify: `src/components/ConfirmationDialog.tsx` + +- [ ] **Step 1: Remove biometric import and usage** + +Remove `import { useBiometric } from '@/hooks/useBiometric'` and `const { promptIfEnabled } = useBiometric()`. + +- [ ] **Step 2: Update Sign Transaction button (line ~522-545)** + +Replace the auth pattern in the Sign Transaction onClick handler: + +```typescript +onClick={async () => { + const password = await requestPassword( + wallet?.has_password ?? false, + ); + if (password === undefined) return; + + commands + .signCoinSpends({ + coin_spends: + response === null + ? [] + : 'coin_spends' in response + ? response.coin_spends + : response.spend_bundle.coin_spends, + password, + }) + .then((data) => { + setSignature( + data.spend_bundle.aggregated_signature, + ); + toast.success(t`Transaction signed successfully`); + }) + .catch(addError); +}} +``` + +- [ ] **Step 3: Update Submit button (line ~633-645)** + +Replace the auth pattern in the submit flow: + +```typescript +const password = await requestPassword(wallet?.has_password ?? false); +if (password === undefined) return; + +const data = await commands + .signCoinSpends({ + coin_spends: response.coin_spends, + password, + }) + .catch(addError); +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/components/ConfirmationDialog.tsx +git commit -m "refactor: update ConfirmationDialog to unified auth pattern" +``` + +--- + +### Task 5: Update WalletCard + +**Files:** + +- Modify: `src/components/WalletCard.tsx` + +- [ ] **Step 1: Remove biometric import and usage** + +Remove `import { useBiometric } from '@/hooks/useBiometric'` and `const { promptIfEnabled } = useBiometric()`. + +- [ ] **Step 2: Update deleteSelf (line ~83-94)** + +`deleteSelf` currently only uses `promptIfEnabled` (no password). With unified auth, this becomes a `requestPassword` call that triggers the standalone biometric gate when no password is set: + +```typescript +const deleteSelf = async () => { + const password = await requestPassword(info.has_password); + if (password === undefined) return; + + await commands + .deleteKey({ fingerprint: info.fingerprint }) + .then(() => + setKeys(keys.filter((key) => key.fingerprint !== info.fingerprint)), + ) + .catch(addError); + + setIsDeleteOpen(false); +}; +``` + +Note: `deleteKey` doesn't take a password param. The `requestPassword` call here is purely for the biometric gate. The returned password string is unused. + +- [ ] **Step 3: Update View Details effect (line ~172-202)** + +```typescript +useEffect(() => { + (async () => { + if (!isDetailsOpen) { + setSecrets(null); + return; + } + + const password = await requestPassword(info.has_password); + if (password === undefined) { + setIsDetailsOpen(false); + return; + } + + commands + .getSecretKey({ fingerprint: info.fingerprint, password }) + .then((data) => data.secrets !== null && setSecrets(data.secrets)) + .catch(addError); + })(); +}, [ + isDetailsOpen, + info.fingerprint, + info.has_password, + addError, + requestPassword, +]); +``` + +- [ ] **Step 4: Update loginSelf biometric guard (line ~185 area)** + +Check if `loginSelf` or any other function in WalletCard uses `promptIfEnabled`. If the existing code at line 185 (`if (!(await promptIfEnabled()))`) is inside the View Details effect, it's already handled in step 3. Search for any other usage. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/WalletCard.tsx +git commit -m "refactor: update WalletCard to unified auth pattern" +``` + +--- + +### Task 6: Update useOfferProcessor + +**Files:** + +- Modify: `src/hooks/useOfferProcessor.ts` + +- [ ] **Step 1: Remove biometric import and usage** + +Remove `import { useBiometric } from '@/hooks/useBiometric'` and `const { promptIfEnabled } = useBiometric()`. + +- [ ] **Step 2: Update processOffer auth pattern (line ~67-74)** + +```typescript +const password = await requestPassword(wallet?.has_password ?? false); +if (password === undefined) { + throw new Error(t`Authentication was cancelled`); +} +``` + +Remove the separate `promptIfEnabled` check and its error. + +- [ ] **Step 3: Remove promptIfEnabled from dependency array (line ~193)** + +Remove `promptIfEnabled` from the `useCallback` dependency array. + +- [ ] **Step 4: Commit** + +```bash +git add src/hooks/useOfferProcessor.ts +git commit -m "refactor: update useOfferProcessor to unified auth pattern" +``` + +--- + +### Task 7: Update WalletConnect handler and commands + +**Files:** + +- Modify: `src/walletconnect/handler.ts` +- Modify: `src/walletconnect/commands/chip0002.ts` +- Modify: `src/walletconnect/commands/offers.ts` +- Modify: `src/walletconnect/commands/high-level.ts` +- Modify: `src/contexts/WalletConnectContext.tsx` + +- [ ] **Step 1: Update HandlerContext interface** + +In `src/walletconnect/handler.ts`, remove `promptIfEnabled`: + +```typescript +export interface HandlerContext { + requestPassword: (hasPassword: boolean) => Promise; + hasPassword: boolean; +} +``` + +- [ ] **Step 2: Update chip0002.ts commands** + +In `handleSignCoinSpends` and `handleSignMessage`, replace: + +```typescript +const password = await context.requestPassword(context.hasPassword); +if (password === null && context.hasPassword) + throw new Error('Authentication failed'); + +if (!(await context.promptIfEnabled())) { + throw new Error('Authentication failed'); +} +``` + +With: + +```typescript +const password = await context.requestPassword(context.hasPassword); +if (password === undefined) throw new Error('Authentication failed'); +``` + +- [ ] **Step 3: Update offers.ts commands** + +In `handleCreateOffer`, `handleTakeOffer`, `handleCancelOffer`, replace the same two-step pattern with the unified check. Each function has `if (!(await context.promptIfEnabled()))` — remove all of them and update the password check. + +- [ ] **Step 4: Update high-level.ts commands** + +In `handleSend`, `handleSignMessageByAddress`, `handleBulkMintNfts`, and any other function using `context.promptIfEnabled()`, apply the same replacement. + +- [ ] **Step 5: Update WalletConnectContext.tsx** + +Remove `useBiometric` import and `promptIfEnabled` destructuring. Remove `promptIfEnabled` from the context object passed to `handleCommand` and from the `useCallback` dependency array: + +```typescript +const result = await handleCommand(method, request.params.request.params, { + requestPassword, + hasPassword: wallet?.has_password ?? false, +}); +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/walletconnect/handler.ts src/walletconnect/commands/chip0002.ts src/walletconnect/commands/offers.ts src/walletconnect/commands/high-level.ts src/contexts/WalletConnectContext.tsx +git commit -m "refactor: update WalletConnect to unified auth pattern" +``` + +--- + +### Task 8: Update Settings RPC, increaseDerivationIndex, OfferRowCard, and remaining call sites + +**Files:** + +- Modify: `src/pages/Settings.tsx` +- Modify: `src/pages/Offers.tsx` +- Modify: `src/pages/Offer.tsx` +- Modify: `src/components/OfferRowCard.tsx` + +- [ ] **Step 1: Update RpcSettings in Settings.tsx (line ~939-986)** + +Replace `promptIfEnabled` usage in `start` and `toggleRunOnStartup` with `requestPassword`. Since these are sensitive operations but don't require a password, use `requestPassword(false)` which triggers the standalone biometric gate: + +```typescript +function RpcSettings() { + const { addError } = useErrors(); + const { requestPassword } = usePassword(); + // ... existing state ... + + const start = async () => { + const auth = await requestPassword(false); + if (auth === undefined) return; + + commands + .startRpcServer() + .catch(addError) + .then(() => setIsRunning(true)); + }; + + const toggleRunOnStartup = async (checked: boolean) => { + const auth = await requestPassword(false); + if (auth === undefined) return; + + commands + .setRpcRunOnStartup(checked) + .catch(addError) + .then(() => setRunOnStartup(checked)); + }; + // ... +} +``` + +- [ ] **Step 2: Update increaseDerivationIndex in Settings.tsx (line ~1257-1258)** + +The `handler` function in the derivation index section uses the old pattern: + +```typescript +// Old: +if (password === null && key?.has_password) return; +// New: +if (password === undefined) return; +``` + +- [ ] **Step 3: Update OfferRowCard.tsx (line ~67-68)** + +The `cancelHandler` uses the old pattern: + +```typescript +// Old: +const password = await requestPassword(wallet?.has_password ?? false); +if (password === null && wallet?.has_password) return; +// New: +const password = await requestPassword(wallet?.has_password ?? false); +if (password === undefined) return; +``` + +- [ ] **Step 4: Update Offers.tsx auth pattern (line ~192-194)** + +Same replacement: `password === null && wallet?.has_password` → `password === undefined`. + +- [ ] **Step 5: Update Offer.tsx auth pattern (line ~103-104)** + +Same replacement. + +- [ ] **Step 6: Verify no remaining promptIfEnabled call sites** + +Run: `grep -r "promptIfEnabled" src/ --include="*.ts" --include="*.tsx" -l` + +Expected: Only `BiometricContext.tsx` (definition) and `useBiometric.ts` (re-export). No call sites. + +- [ ] **Step 7: Commit** + +```bash +git add src/pages/Settings.tsx src/pages/Offers.tsx src/pages/Offer.tsx src/components/OfferRowCard.tsx +git commit -m "refactor: update remaining call sites to unified auth pattern" +``` + +--- + +## Chunk 4: Settings UI and Keychain Lifecycle + +### Task 9: Move biometric toggle to Security section with context-aware labels + +**Files:** + +- Modify: `src/pages/Settings.tsx` + +- [ ] **Step 1: Remove biometric toggle from GlobalSettings (line ~307-318)** + +Remove the `isMobile && (...)` block containing the biometric toggle from the Preferences section in `GlobalSettings`. + +- [ ] **Step 2: Add biometric toggle to WalletSettings Security section** + +The Security section already exists in WalletSettings (where password management lives). Add the biometric toggle there with context-aware labels: + +```typescript +// Inside the Security section of WalletSettings, after password controls +{isMobile && available && ( + <> + + } + /> + {wallet?.has_password && biometricEnabled && ( +

+ {t`Biometric unlock is a convenience — remember your password. There is no way to recover a lost password.`} +

+ )} + +)} +``` + +Where `biometricEnabled`, `available`, and `toggleBiometric` come from the existing biometric context, but `toggleBiometric` is updated to clear keychain entries on disable: + +```typescript +const { + enabled: biometricEnabled, + available, + enableIfAvailable, + disable, +} = useBiometric(); +const { clearAllKeychainEntries } = usePassword(); + +const toggleBiometric = async (value: boolean) => { + if (value) { + await enableIfAvailable(); + } else { + await disable(); + // Clear all stored passwords from keychain + if (keys) { + await clearAllKeychainEntries(keys.map((k) => k.fingerprint)); + } + } +}; +``` + +Note: `useBiometric` and `usePassword` need to be available in the WalletSettings component. Check if they're already imported there. + +- [ ] **Step 3: Commit** + +```bash +git add src/pages/Settings.tsx +git commit -m "feat: move biometric toggle to Security section with context-aware labels" +``` + +--- + +### Task 10: Handle keychain lifecycle in password management + +**Files:** + +- Modify: `src/pages/Settings.tsx` (password change/remove handlers) + +**Note:** Stale keychain entry recovery is already handled in the `PasswordContext` code from Task 3 via `skipKeychainRef`. This task only covers the explicit lifecycle events triggered by user actions in Settings. + +- [ ] **Step 1: Update password change handler to update keychain** + +When a user changes their password in Settings, update the keychain entry. Destructure `updateKeychainEntry` from `usePassword()` in the relevant Settings component: + +```typescript +// After successful commands.changePassword(): +await updateKeychainEntry(wallet.fingerprint, newPassword); +``` + +- [ ] **Step 2: Update password remove handler to clear keychain** + +When a user removes their password, clear the keychain entry: + +```typescript +// After successful commands.changePassword(old, ""): +await clearKeychainEntry(wallet.fingerprint); +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/pages/Settings.tsx +git commit -m "feat: handle keychain lifecycle for password changes" +``` + +--- + +### Task 11: Handle wallet deletion keychain cleanup + +**Files:** + +- Modify: `src/components/WalletCard.tsx` or wherever `deleteKey` is called + +- [ ] **Step 1: Clear keychain entry on wallet deletion** + +In the `deleteSelf` handler in `WalletCard.tsx`, after successful deletion: + +```typescript +const { clearKeychainEntry } = usePassword(); + +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); +}; +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/components/WalletCard.tsx +git commit -m "feat: clear keychain entry on wallet deletion" +``` + +--- + +## Chunk 5: Verification and Cleanup + +### Task 12: Verify no remaining direct promptIfEnabled usage + +- [ ] **Step 1: Search for any remaining promptIfEnabled call sites** + +Run: `grep -r "promptIfEnabled" src/ --include="*.ts" --include="*.tsx"` + +Expected: Only `BiometricContext.tsx` (definition) and `useBiometric.ts` (re-export). No call sites. + +- [ ] **Step 2: Search for old auth pattern** + +Run: `grep -r "password === null && wallet" src/ --include="*.ts" --include="*.tsx"` + +Expected: No results. All should use `password === undefined`. + +- [ ] **Step 3: Verify TypeScript compiles** + +Run: `pnpm tsc --noEmit` + +- [ ] **Step 4: Verify build succeeds** + +Run: `pnpm build` + +- [ ] **Step 5: Commit any final cleanup** + +```bash +git add -A +git commit -m "chore: final cleanup for biometric-password bridge" +``` + +--- + +### Task 13: Manual testing checklist + +These are manual verification steps since the biometric bridge requires mobile hardware: + +- [ ] **Step 1: Desktop — password dialog works as before** + - Create wallet, set password, verify operations prompt for password + - Verify cancel returns undefined (operation aborted) + - Verify wrong password shows toast + +- [ ] **Step 2: Desktop — biometric toggle not visible** + - Verify biometric toggle does not appear in Settings on desktop + +- [ ] **Step 3: Mobile — standalone biometric gate** + - Enable biometric, no password set + - Verify sensitive operations trigger biometric prompt + - Verify cancel returns undefined + +- [ ] **Step 4: Mobile — biometric + password (first use)** + - Set password on wallet, enable biometric + - First protected operation → password dialog (no keychain entry yet) + - Type correct password → operation succeeds → password stored in keychain + +- [ ] **Step 5: Mobile — biometric + password (subsequent)** + - Next protected operation → biometric prompt (keychain retrieval) + - On success → operation proceeds without typing password + +- [ ] **Step 6: Mobile — biometric fails → fallback to dialog** + - Cancel biometric prompt → password dialog appears + - Type password → operation succeeds + +- [ ] **Step 7: Mobile — stale keychain entry** + - Change password in Settings + - Next operation should use new keychain entry (updated on change) + +- [ ] **Step 8: Mobile — disable biometric clears keychain** + - Disable biometric toggle + - Next operation should prompt for password (no keychain) + +- [ ] **Step 9: WalletConnect — unified auth works** + - Connect dApp via WalletConnect + - Trigger signing operation → single auth prompt (not two) diff --git a/docs/superpowers/specs/2026-03-15-password-protection-design.md b/docs/superpowers/specs/2026-03-15-password-protection-design.md index 8721244cc..4fe10b864 100644 --- a/docs/superpowers/specs/2026-03-15-password-protection-design.md +++ b/docs/superpowers/specs/2026-03-15-password-protection-design.md @@ -251,17 +251,17 @@ if (password === undefined) return; // auth cancelled or failed The separate `promptIfEnabled()` biometric call is removed from all call sites. `requestPassword` is now the sole auth gate. Password is then passed to the backend command. Call sites that were updated: -| File | Operations | -| --- | --- | -| `ConfirmationDialog.tsx` | `signCoinSpends` (Sign Transaction button, Submit button) | -| `WalletCard.tsx` | `getSecretKey` (View Details dialog) | -| `Settings.tsx` | `increaseDerivationIndex` (when hardened keys enabled) | -| `Offers.tsx` | `cancelOffers` (Cancel All Active) | -| `OfferRowCard.tsx` | `cancelOffer` (individual offer cancel) | -| `useOfferProcessor.ts` | `makeOffer` (create offer flow) | -| `Offer.tsx` | `takeOffer` (take offer flow) | -| `WalletConnectContext.tsx` | All WC command handling via `HandlerContext` | -| WalletConnect commands | `signCoinSpends`, `signMessage`, `signMessageByAddress`, `send`, `createOffer`, `takeOffer`, `cancelOffer`, `bulkMintNfts` | +| File | Operations | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `ConfirmationDialog.tsx` | `signCoinSpends` (Sign Transaction button, Submit button) | +| `WalletCard.tsx` | `getSecretKey` (View Details dialog) | +| `Settings.tsx` | `increaseDerivationIndex` (when hardened keys enabled) | +| `Offers.tsx` | `cancelOffers` (Cancel All Active) | +| `OfferRowCard.tsx` | `cancelOffer` (individual offer cancel) | +| `useOfferProcessor.ts` | `makeOffer` (create offer flow) | +| `Offer.tsx` | `takeOffer` (take offer flow) | +| `WalletConnectContext.tsx` | All WC command handling via `HandlerContext` | +| WalletConnect commands | `signCoinSpends`, `signMessage`, `signMessageByAddress`, `send`, `createOffer`, `takeOffer`, `cancelOffer`, `bulkMintNfts` | #### WalletConnect integration @@ -283,14 +283,14 @@ Wrong password errors (`ErrorKind::Unauthorized` with reason containing "decrypt #### Keychain lifecycle -| Event | Action | -| --- | --- | -| Password-protected operation succeeds (first time) | Store password in keychain for that wallet's fingerprint | -| Password changed in Settings | Update keychain entry with new password | -| Password removed in Settings | Delete keychain entry; biometric reverts to standalone gate mode | -| Biometric toggle disabled | Delete all `sage-password-*` keychain entries | -| Wallet deleted | Delete that wallet's keychain entry | -| OS biometric enrollment changes | OS invalidates keychain items; next retrieval fails → dialog fallback → re-stored on success | +| Event | Action | +| -------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| Password-protected operation succeeds (first time) | Store password in keychain for that wallet's fingerprint | +| Password changed in Settings | Update keychain entry with new password | +| Password removed in Settings | Delete keychain entry; biometric reverts to standalone gate mode | +| Biometric toggle disabled | Delete all `sage-password-*` keychain entries | +| Wallet deleted | Delete that wallet's keychain entry | +| OS biometric enrollment changes | OS invalidates keychain items; next retrieval fails → dialog fallback → re-stored on success | #### Settings UI changes diff --git a/src-tauri/gen/apple/sage-tauri_iOS/Info.plist b/src-tauri/gen/apple/sage-tauri_iOS/Info.plist index a0c433087..fee610044 100644 --- a/src-tauri/gen/apple/sage-tauri_iOS/Info.plist +++ b/src-tauri/gen/apple/sage-tauri_iOS/Info.plist @@ -49,4 +49,4 @@ NSPhotoLibraryAddUsageDescription This app needs access to save images to your photo library. - + \ No newline at end of file diff --git a/src-tauri/gen/apple/sage-tauri_iOS/sage-tauri_iOS.entitlements b/src-tauri/gen/apple/sage-tauri_iOS/sage-tauri_iOS.entitlements index b1c4bd3a1..cab3c8853 100644 --- a/src-tauri/gen/apple/sage-tauri_iOS/sage-tauri_iOS.entitlements +++ b/src-tauri/gen/apple/sage-tauri_iOS/sage-tauri_iOS.entitlements @@ -9,4 +9,4 @@ com.apple.developer.group-session - + \ No newline at end of file diff --git a/src/components/ConfirmationDialog.tsx b/src/components/ConfirmationDialog.tsx index ef180673a..da433fd85 100644 --- a/src/components/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog.tsx @@ -533,9 +533,7 @@ export default function ConfirmationDialog({ password, }) .then((data) => { - setSignature( - data.spend_bundle.aggregated_signature, - ); + setSignature(data.spend_bundle.aggregated_signature); toast.success(t`Transaction signed successfully`); }) .catch(addError); diff --git a/src/contexts/PasswordContext.tsx b/src/contexts/PasswordContext.tsx index 731d76a99..03abc787b 100644 --- a/src/contexts/PasswordContext.tsx +++ b/src/contexts/PasswordContext.tsx @@ -7,7 +7,8 @@ import { createContext, ReactNode, useCallback, useRef, useState } from 'react'; const isMobile = platform() === 'ios' || platform() === 'android'; // Lazy-initialized keychain module (mobile only) -let keychainPromise: Promise | null = null; +let keychainPromise: Promise | null = + null; function getKeychain() { if (!isMobile) return null; if (!keychainPromise) keychainPromise = import('tauri-plugin-keychain'); @@ -57,7 +58,10 @@ interface PasswordRequest { export interface PasswordContextType { requestPassword: (hasPassword: boolean) => Promise; clearKeychainEntry: (fingerprint: number) => Promise; - updateKeychainEntry: (fingerprint: number, newPassword: string) => Promise; + updateKeychainEntry: ( + fingerprint: number, + newPassword: string, + ) => Promise; clearAllKeychainEntries: (fingerprints: number[]) => Promise; } diff --git a/src/contexts/WalletConnectContext.tsx b/src/contexts/WalletConnectContext.tsx index 8c1baa2f8..637fc187b 100644 --- a/src/contexts/WalletConnectContext.tsx +++ b/src/contexts/WalletConnectContext.tsx @@ -140,12 +140,7 @@ export function WalletConnectProvider({ children }: { children: ReactNode }) { }); } }, - [ - signClient, - addError, - requestPassword, - wallet?.has_password, - ], + [signClient, addError, requestPassword, wallet?.has_password], ); useEffect(() => { diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 8fdc4fa77..da58e9f19 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1000,8 +1000,18 @@ function RpcSettings() { function WalletSettings({ fingerprint }: { fingerprint: number }) { const { addError } = useErrors(); - const { requestPassword, clearKeychainEntry, updateKeychainEntry, clearAllKeychainEntries } = usePassword(); - const { enabled: biometricEnabled, available, enableIfAvailable, disable } = useBiometric(); + const { + requestPassword, + clearKeychainEntry, + updateKeychainEntry, + clearAllKeychainEntries, + } = usePassword(); + const { + enabled: biometricEnabled, + available, + enableIfAvailable, + disable, + } = useBiometric(); const isMobile = platform() === 'ios' || platform() === 'android'; const toggleBiometric = async (value: boolean) => { @@ -1421,7 +1431,11 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { {isMobile && available && ( <> Date: Mon, 16 Mar 2026 07:42:54 -0500 Subject: [PATCH 25/53] remove plan docs --- .../2026-03-15-biometric-password-bridge.md | 1036 ----------------- .../plans/2026-03-15-password-protection.md | 994 ---------------- 2 files changed, 2030 deletions(-) delete mode 100644 docs/superpowers/plans/2026-03-15-biometric-password-bridge.md delete mode 100644 docs/superpowers/plans/2026-03-15-password-protection.md diff --git a/docs/superpowers/plans/2026-03-15-biometric-password-bridge.md b/docs/superpowers/plans/2026-03-15-biometric-password-bridge.md deleted file mode 100644 index f777ae5f8..000000000 --- a/docs/superpowers/plans/2026-03-15-biometric-password-bridge.md +++ /dev/null @@ -1,1036 +0,0 @@ -# Biometric-Password Bridge Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Unify password and biometric authentication into a single `requestPassword` entry point that stores/retrieves passwords from the OS keychain via biometric on mobile. - -**Architecture:** `PasswordContext.requestPassword()` becomes the sole auth gate. On mobile with biometric enabled, it attempts keychain retrieval (which triggers OS biometric prompt) before falling back to the password dialog. On first successful typed password, the password is stored in the keychain for future biometric retrieval. `BiometricContext` retains state management (`enabled`, `available`) but `promptIfEnabled()` is no longer called at operation sites. - -**Tech Stack:** `tauri-plugin-keychain` (Rust + JS) for iOS Keychain / Android AccountManager, `@tauri-apps/plugin-biometric` (existing), React context + hooks, TypeScript. - -**Spec:** `docs/superpowers/specs/2026-03-15-password-protection-design.md` - ---- - -## File Structure - -### New files - -- None — all changes are modifications to existing files - -### Modified files - -| File | Responsibility | -| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `src-tauri/Cargo.toml` | Add `tauri-plugin-keychain` dependency (mobile target) | -| `src-tauri/src/lib.rs` | Register keychain plugin in `#[cfg(mobile)]` block | -| `src-tauri/capabilities/mobile.json` | Add keychain permission | -| `package.json` | Add `tauri-plugin-keychain` JS package | -| `src/contexts/PasswordContext.tsx` | Integrate keychain store/retrieve into `requestPassword`, change return type to `string \| null \| undefined` | -| `src/hooks/usePassword.ts` | Update type to match new `PasswordContextType` | -| `src/App.tsx` | Restructure provider tree: `WalletProvider` → `PasswordProvider` → `WalletConnectProvider` | -| `src/contexts/WalletConnectContext.tsx` | Remove `promptIfEnabled` usage, update auth pattern | -| `src/walletconnect/handler.ts` | Remove `promptIfEnabled` from `HandlerContext` | -| `src/walletconnect/commands/chip0002.ts` | Remove `promptIfEnabled` calls, update auth check | -| `src/walletconnect/commands/offers.ts` | Remove `promptIfEnabled` calls, update auth check | -| `src/walletconnect/commands/high-level.ts` | Remove `promptIfEnabled` calls, update auth check | -| `src/components/ConfirmationDialog.tsx` | Remove `promptIfEnabled`, update auth pattern | -| `src/components/WalletCard.tsx` | Remove `promptIfEnabled`, update auth pattern, keychain cleanup on delete | -| `src/components/OfferRowCard.tsx` | Update auth check pattern (`password === null` → `password === undefined`) | -| `src/hooks/useOfferProcessor.ts` | Remove `promptIfEnabled`, update auth pattern | -| `src/pages/Settings.tsx` | Remove `promptIfEnabled` from RpcSettings, update `increaseDerivationIndex` auth pattern, move biometric toggle to Security section | -| `src/pages/Offers.tsx` | Update auth check pattern | -| `src/pages/Offer.tsx` | Update auth check pattern | -| `docs/superpowers/specs/2026-03-15-password-protection-design.md` | Update plugin reference from `tauri-plugin-keystore` to `tauri-plugin-keychain`, update provider placement | - ---- - -## Chunk 1: Dependencies and Plugin Registration - -### Task 1: Add tauri-plugin-keychain Rust dependency - -**Files:** - -- Modify: `src-tauri/Cargo.toml:50-54` - -- [ ] **Step 1: Add keychain dependency to mobile target** - -Add `tauri-plugin-keychain` to the mobile-only dependencies section: - -```toml -[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] -tauri-plugin-biometric = { workspace = true } -tauri-plugin-barcode-scanner = { workspace = true } -tauri-plugin-safe-area-insets = { workspace = true } -tauri-plugin-sage = { workspace = true } -tauri-plugin-keychain = "2" -``` - -- [ ] **Step 2: Register keychain plugin in lib.rs** - -Modify: `src-tauri/src/lib.rs:170-178` - -Add `tauri_plugin_keychain::init()` to the `#[cfg(mobile)]` block: - -```rust -#[cfg(mobile)] -{ - tauri_builder = tauri_builder - .plugin(tauri_plugin_barcode_scanner::init()) - .plugin(tauri_plugin_safe_area_insets::init()) - .plugin(tauri_plugin_biometric::init()) - .plugin(tauri_plugin_sharesheet::init()) - .plugin(tauri_plugin_sage::init()) - .plugin(tauri_plugin_keychain::init()); -} -``` - -- [ ] **Step 3: Add keychain permission to mobile capabilities** - -Modify: `src-tauri/capabilities/mobile.json` - -```json -{ - "$schema": "../gen/schemas/mobile-schema.json", - "identifier": "mobile-capability", - "windows": ["main"], - "platforms": ["android", "iOS"], - "permissions": [ - "safe-area-insets:default", - "barcode-scanner:default", - "biometric:default", - "sage:default", - "sharesheet:allow-share-text", - "keychain:default" - ] -} -``` - -- [ ] **Step 4: Add JS package dependency** - -Run: `pnpm add tauri-plugin-keychain` - -- [ ] **Step 5: Verify the project builds** - -Run: `pnpm tauri build --debug` or at minimum `cargo check` in `src-tauri/` - -- [ ] **Step 6: Commit** - -```bash -git add src-tauri/Cargo.toml src-tauri/src/lib.rs src-tauri/capabilities/mobile.json package.json pnpm-lock.yaml -git commit -m "feat: add tauri-plugin-keychain dependency for biometric-password bridge" -``` - ---- - -### Task 2: Update spec to reference tauri-plugin-keychain - -**Files:** - -- Modify: `docs/superpowers/specs/2026-03-15-password-protection-design.md` - -- [ ] **Step 1: Replace all references to `tauri-plugin-keystore` with `tauri-plugin-keychain`** - -In the spec, find and replace: - -- `tauri-plugin-keystore` → `tauri-plugin-keychain` -- Update the plugin description from "Simple API: `store(key, value)`, `retrieve(key)`, `remove(key)`" to "Key-value API: `saveItem(key, password)`, `getItem(key)`, `removeItem(key)`" -- Update the dependency section: "wraps iOS Keychain and Android AccountManager" instead of "iOS Keychain and Android Keystore (API 28+)" -- Update the Cargo dependency line to `tauri-plugin-keychain = "2"` - -- [ ] **Step 2: Update provider placement in spec** - -The spec currently says (line 226): "Provider placement: Inside `I18nProvider`... but wrapping `WalletProvider`". Update to reflect the new dependency chain: - -> **Provider placement:** Inside `I18nProvider` and `WalletProvider` (required because `PasswordProvider` now reads `useWallet()` for the fingerprint and `useBiometric()` for the enabled state). Wraps `WalletConnectProvider` and all downstream providers, so `usePassword()` is available everywhere. - -New tree: `BiometricProvider` → `I18nProvider` → `WalletProvider` → `PasswordProvider` → `PeerProvider` → `WalletConnectProvider` → `PriceProvider` → `RouterProvider` - -- [ ] **Step 3: Commit** - -```bash -git add docs/superpowers/specs/2026-03-15-password-protection-design.md -git commit -m "docs: update spec to reference tauri-plugin-keychain" -``` - ---- - -## Chunk 2: Unified PasswordContext with Keychain Integration - -### Task 3: Refactor PasswordContext to unified auth entry point - -This is the core task. `requestPassword` gains keychain awareness and subsumes the standalone biometric gate. - -**Files:** - -- Modify: `src/contexts/PasswordContext.tsx` -- Modify: `src/hooks/usePassword.ts` - -- [ ] **Step 1: Write the updated PasswordContext** - -The new `requestPassword` implements the decision tree from the spec. Key design decisions: - -1. **Store-after-validation, not store-on-submit:** `handleSubmit` does NOT store the password in the keychain. Instead, `requestPassword` returns the password and the caller's backend operation validates it. Only after a successful operation does the password get stored. This is achieved by `requestPassword` saving the typed password in `pendingStoreRef` and only writing to keychain on the _next_ successful keychain-skipped call (i.e., the password worked last time). -2. **Stale keychain recovery:** A `skipKeychainRef` tracks fingerprints where keychain retrieval returned a password that was rejected. On the next `requestPassword` call for that fingerprint, keychain is skipped and the dialog is shown instead. -3. **Biometric caching for standalone gate:** The existing 5-minute caching window from `BiometricContext.promptIfEnabled()` is preserved for Case 2 (no password, biometric enabled). A `lastBiometricPromptRef` tracks the last successful biometric auth time. - -```typescript -// src/contexts/PasswordContext.tsx -import { PasswordDialog } from '@/components/dialogs/PasswordDialog'; -import { useBiometric } from '@/hooks/useBiometric'; -import { useWallet } from '@/contexts/WalletContext'; -import { platform } from '@tauri-apps/plugin-os'; -import { createContext, ReactNode, useCallback, useRef, useState } from 'react'; - -const isMobile = platform() === 'ios' || platform() === 'android'; - -// Lazy-initialized keychain module (mobile only) -let keychainPromise: Promise | null = null; -function getKeychain() { - if (!isMobile) return null; - if (!keychainPromise) keychainPromise = import('tauri-plugin-keychain'); - return keychainPromise; -} - -async function keychainGet(key: string): Promise { - const mod = getKeychain(); - if (!mod) return null; - try { - const { getItem } = await mod; - return await getItem(key); - } catch { - return null; - } -} - -async function keychainSave(key: string, password: string): Promise { - const mod = getKeychain(); - if (!mod) return; - try { - const { saveItem } = await mod; - await saveItem(key, password); - } catch { - // Silently fail — keychain storage is best-effort - } -} - -async function keychainRemove(key: string): Promise { - const mod = getKeychain(); - if (!mod) return; - try { - const { removeItem } = await mod; - await removeItem(key); - } catch { - // Silently fail - } -} - -// Biometric caching interval (5 minutes), matching the existing BiometricContext behavior -const BIOMETRIC_CACHE_MS = 5 * 60 * 1000; - -interface PasswordRequest { - resolve: (password: string | null | undefined) => void; -} - -export interface PasswordContextType { - /** - * Unified auth entry point for all protected operations. - * - * Returns: - * - string: use this password (typed or retrieved from keychain) - * - null: no password needed, auth passed - * - undefined: auth cancelled or failed, abort the operation - */ - requestPassword: (hasPassword: boolean) => Promise; - - /** Clear the keychain entry for a specific wallet fingerprint */ - clearKeychainEntry: (fingerprint: number) => Promise; - - /** Update the keychain entry after a password change */ - updateKeychainEntry: (fingerprint: number, newPassword: string) => Promise; - - /** Clear all keychain entries (used when disabling biometric) */ - clearAllKeychainEntries: (fingerprints: number[]) => Promise; -} - -export const PasswordContext = createContext( - undefined, -); - -function keychainKey(fingerprint: number): string { - return `sage-password-${fingerprint.toString()}`; -} - -export function PasswordProvider({ children }: { children: ReactNode }) { - const [open, setOpen] = useState(false); - const pendingRef = useRef(null); - const { enabled: biometricEnabled } = useBiometric(); - const { wallet } = useWallet(); - - // Stale keychain recovery: skip keychain for fingerprints that returned bad passwords - const skipKeychainRef = useRef>(new Set()); - - // Biometric caching for standalone gate (Case 2) - const lastBiometricPromptRef = useRef(null); - - const requestPassword = useCallback( - async (hasPassword: boolean): Promise => { - const fingerprint = wallet?.fingerprint; - - // Case 1: No password, no biometric → no auth needed - if (!hasPassword && !biometricEnabled) { - return null; - } - - // Case 2: No password, biometric enabled → standalone biometric gate with 5-min cache - if (!hasPassword && biometricEnabled && isMobile) { - const now = performance.now(); - if ( - lastBiometricPromptRef.current !== null && - now - lastBiometricPromptRef.current < BIOMETRIC_CACHE_MS - ) { - return null; // Within cache window, skip prompt - } - - try { - const { authenticate } = await import('@tauri-apps/plugin-biometric'); - await authenticate('Authenticate to continue', { - allowDeviceCredential: false, - }); - lastBiometricPromptRef.current = now; - return null; - } catch { - return undefined; // biometric failed/cancelled - } - } - - // Case 3: Has password, biometric enabled → try keychain first (unless skipped) - if ( - hasPassword && - biometricEnabled && - isMobile && - fingerprint && - !skipKeychainRef.current.has(fingerprint) - ) { - 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 - } - - // Case 4: Has password → show dialog (fallback or no biometric) - if (hasPassword) { - return new Promise((resolve) => { - pendingRef.current = { resolve }; - setOpen(true); - }); - } - - return null; - }, - [biometricEnabled, wallet?.fingerprint], - ); - - const handleSubmit = useCallback( - (password: string) => { - setOpen(false); - const fingerprint = wallet?.fingerprint; - - // Manual password entry: clear skip flag and store in keychain - // The password will be validated by the backend. If it's wrong, the - // "Incorrect password" toast fires. On next requestPassword, keychain - // will return this password, skipKeychainRef kicks in, and dialog shows. - if (fingerprint) { - skipKeychainRef.current.delete(fingerprint); - if (biometricEnabled && isMobile) { - keychainSave(keychainKey(fingerprint), password); - } - } - - pendingRef.current?.resolve(password); - pendingRef.current = null; - }, - [biometricEnabled, wallet?.fingerprint], - ); - - const handleCancel = useCallback(() => { - setOpen(false); - pendingRef.current?.resolve(undefined); // undefined = cancelled - pendingRef.current = null; - }, []); - - const clearKeychainEntry = useCallback(async (fingerprint: number) => { - skipKeychainRef.current.delete(fingerprint); - await keychainRemove(keychainKey(fingerprint)); - }, []); - - const updateKeychainEntry = useCallback( - async (fingerprint: number, newPassword: string) => { - skipKeychainRef.current.delete(fingerprint); - if (biometricEnabled && isMobile) { - await keychainSave(keychainKey(fingerprint), newPassword); - } - }, - [biometricEnabled], - ); - - const clearAllKeychainEntries = useCallback( - async (fingerprints: number[]) => { - for (const fp of fingerprints) { - skipKeychainRef.current.delete(fp); - await keychainRemove(keychainKey(fp)); - } - }, - [], - ); - - return ( - - {children} - - - ); -} -``` - -**Important notes for the implementer:** - -- `PasswordProvider` now depends on `useBiometric()` and `useWallet()`. It must be placed inside both `BiometricProvider` and `WalletProvider` in the component tree. This is a change from the current placement where `PasswordProvider` wraps `WalletProvider`. See Step 3 for the new provider tree. -- **Stale keychain recovery:** When `requestPassword` returns a keychain-retrieved password, it adds the fingerprint to `skipKeychainRef`. If the backend accepts the password (normal flow), the _next_ `requestPassword` will skip keychain and show the dialog. But the user won't see this because the operation succeeded. If the backend _rejects_ the password (stale entry), the "Incorrect password" toast fires, and the next `requestPassword` shows the dialog. The user types the correct password in the dialog, which clears the skip flag and updates the keychain. -- **Why store-on-dialog-submit is acceptable:** The dialog password is stored in the keychain immediately on submit, before backend validation. If the user types a wrong password, it gets stored in the keychain — but `skipKeychainRef` ensures the next call skips keychain and shows the dialog again. When the user eventually types the correct password, it replaces the stale entry. This is a pragmatic trade-off: the one-wrong-password-in-keychain scenario is self-healing. -- The lazy `getKeychain()` import ensures the plugin is only loaded on mobile. On desktop, all keychain operations are no-ops. -- `keychainKey` uses `fingerprint.toString()` explicitly for clarity, though JS number-to-string conversion of u32 values is safe. - -- [ ] **Step 2: Update usePassword hook type** - -Update `src/hooks/usePassword.ts` — the type is already correct since it just re-exports the context, but verify it matches. - -- [ ] **Step 3: Restructure provider tree in App.tsx** - -`PasswordProvider` now depends on `useBiometric()` (from `BiometricProvider` in outer `App`) and `useWallet()` (from `WalletProvider`). `WalletConnectContext` uses `usePassword()`, so `PasswordProvider` must wrap it. The correct ordering is: - -``` -BiometricProvider (outer App) → I18nProvider → WalletProvider → PasswordProvider → WalletConnectProvider -``` - -Update `src/App.tsx` `AppInner`: - -```typescript - - - - - - - - - - - - - -``` - -- [ ] **Step 4: Verify build compiles** - -Run: `pnpm tsc --noEmit` - -- [ ] **Step 5: Commit** - -```bash -git add src/contexts/PasswordContext.tsx src/hooks/usePassword.ts src/App.tsx -git commit -m "feat: refactor PasswordContext to unified auth with keychain integration" -``` - ---- - -## Chunk 3: Update All Call Sites - -Every call site currently has a two-step pattern: - -```typescript -const password = await requestPassword(wallet?.has_password ?? false); -if (password === null && wallet?.has_password) return; -if (!(await promptIfEnabled())) return; -``` - -This becomes: - -```typescript -const password = await requestPassword(wallet?.has_password ?? false); -if (password === undefined) return; -``` - -### Task 4: Update ConfirmationDialog - -**Files:** - -- Modify: `src/components/ConfirmationDialog.tsx` - -- [ ] **Step 1: Remove biometric import and usage** - -Remove `import { useBiometric } from '@/hooks/useBiometric'` and `const { promptIfEnabled } = useBiometric()`. - -- [ ] **Step 2: Update Sign Transaction button (line ~522-545)** - -Replace the auth pattern in the Sign Transaction onClick handler: - -```typescript -onClick={async () => { - const password = await requestPassword( - wallet?.has_password ?? false, - ); - if (password === undefined) return; - - commands - .signCoinSpends({ - coin_spends: - response === null - ? [] - : 'coin_spends' in response - ? response.coin_spends - : response.spend_bundle.coin_spends, - password, - }) - .then((data) => { - setSignature( - data.spend_bundle.aggregated_signature, - ); - toast.success(t`Transaction signed successfully`); - }) - .catch(addError); -}} -``` - -- [ ] **Step 3: Update Submit button (line ~633-645)** - -Replace the auth pattern in the submit flow: - -```typescript -const password = await requestPassword(wallet?.has_password ?? false); -if (password === undefined) return; - -const data = await commands - .signCoinSpends({ - coin_spends: response.coin_spends, - password, - }) - .catch(addError); -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/components/ConfirmationDialog.tsx -git commit -m "refactor: update ConfirmationDialog to unified auth pattern" -``` - ---- - -### Task 5: Update WalletCard - -**Files:** - -- Modify: `src/components/WalletCard.tsx` - -- [ ] **Step 1: Remove biometric import and usage** - -Remove `import { useBiometric } from '@/hooks/useBiometric'` and `const { promptIfEnabled } = useBiometric()`. - -- [ ] **Step 2: Update deleteSelf (line ~83-94)** - -`deleteSelf` currently only uses `promptIfEnabled` (no password). With unified auth, this becomes a `requestPassword` call that triggers the standalone biometric gate when no password is set: - -```typescript -const deleteSelf = async () => { - const password = await requestPassword(info.has_password); - if (password === undefined) return; - - await commands - .deleteKey({ fingerprint: info.fingerprint }) - .then(() => - setKeys(keys.filter((key) => key.fingerprint !== info.fingerprint)), - ) - .catch(addError); - - setIsDeleteOpen(false); -}; -``` - -Note: `deleteKey` doesn't take a password param. The `requestPassword` call here is purely for the biometric gate. The returned password string is unused. - -- [ ] **Step 3: Update View Details effect (line ~172-202)** - -```typescript -useEffect(() => { - (async () => { - if (!isDetailsOpen) { - setSecrets(null); - return; - } - - const password = await requestPassword(info.has_password); - if (password === undefined) { - setIsDetailsOpen(false); - return; - } - - commands - .getSecretKey({ fingerprint: info.fingerprint, password }) - .then((data) => data.secrets !== null && setSecrets(data.secrets)) - .catch(addError); - })(); -}, [ - isDetailsOpen, - info.fingerprint, - info.has_password, - addError, - requestPassword, -]); -``` - -- [ ] **Step 4: Update loginSelf biometric guard (line ~185 area)** - -Check if `loginSelf` or any other function in WalletCard uses `promptIfEnabled`. If the existing code at line 185 (`if (!(await promptIfEnabled()))`) is inside the View Details effect, it's already handled in step 3. Search for any other usage. - -- [ ] **Step 5: Commit** - -```bash -git add src/components/WalletCard.tsx -git commit -m "refactor: update WalletCard to unified auth pattern" -``` - ---- - -### Task 6: Update useOfferProcessor - -**Files:** - -- Modify: `src/hooks/useOfferProcessor.ts` - -- [ ] **Step 1: Remove biometric import and usage** - -Remove `import { useBiometric } from '@/hooks/useBiometric'` and `const { promptIfEnabled } = useBiometric()`. - -- [ ] **Step 2: Update processOffer auth pattern (line ~67-74)** - -```typescript -const password = await requestPassword(wallet?.has_password ?? false); -if (password === undefined) { - throw new Error(t`Authentication was cancelled`); -} -``` - -Remove the separate `promptIfEnabled` check and its error. - -- [ ] **Step 3: Remove promptIfEnabled from dependency array (line ~193)** - -Remove `promptIfEnabled` from the `useCallback` dependency array. - -- [ ] **Step 4: Commit** - -```bash -git add src/hooks/useOfferProcessor.ts -git commit -m "refactor: update useOfferProcessor to unified auth pattern" -``` - ---- - -### Task 7: Update WalletConnect handler and commands - -**Files:** - -- Modify: `src/walletconnect/handler.ts` -- Modify: `src/walletconnect/commands/chip0002.ts` -- Modify: `src/walletconnect/commands/offers.ts` -- Modify: `src/walletconnect/commands/high-level.ts` -- Modify: `src/contexts/WalletConnectContext.tsx` - -- [ ] **Step 1: Update HandlerContext interface** - -In `src/walletconnect/handler.ts`, remove `promptIfEnabled`: - -```typescript -export interface HandlerContext { - requestPassword: (hasPassword: boolean) => Promise; - hasPassword: boolean; -} -``` - -- [ ] **Step 2: Update chip0002.ts commands** - -In `handleSignCoinSpends` and `handleSignMessage`, replace: - -```typescript -const password = await context.requestPassword(context.hasPassword); -if (password === null && context.hasPassword) - throw new Error('Authentication failed'); - -if (!(await context.promptIfEnabled())) { - throw new Error('Authentication failed'); -} -``` - -With: - -```typescript -const password = await context.requestPassword(context.hasPassword); -if (password === undefined) throw new Error('Authentication failed'); -``` - -- [ ] **Step 3: Update offers.ts commands** - -In `handleCreateOffer`, `handleTakeOffer`, `handleCancelOffer`, replace the same two-step pattern with the unified check. Each function has `if (!(await context.promptIfEnabled()))` — remove all of them and update the password check. - -- [ ] **Step 4: Update high-level.ts commands** - -In `handleSend`, `handleSignMessageByAddress`, `handleBulkMintNfts`, and any other function using `context.promptIfEnabled()`, apply the same replacement. - -- [ ] **Step 5: Update WalletConnectContext.tsx** - -Remove `useBiometric` import and `promptIfEnabled` destructuring. Remove `promptIfEnabled` from the context object passed to `handleCommand` and from the `useCallback` dependency array: - -```typescript -const result = await handleCommand(method, request.params.request.params, { - requestPassword, - hasPassword: wallet?.has_password ?? false, -}); -``` - -- [ ] **Step 6: Commit** - -```bash -git add src/walletconnect/handler.ts src/walletconnect/commands/chip0002.ts src/walletconnect/commands/offers.ts src/walletconnect/commands/high-level.ts src/contexts/WalletConnectContext.tsx -git commit -m "refactor: update WalletConnect to unified auth pattern" -``` - ---- - -### Task 8: Update Settings RPC, increaseDerivationIndex, OfferRowCard, and remaining call sites - -**Files:** - -- Modify: `src/pages/Settings.tsx` -- Modify: `src/pages/Offers.tsx` -- Modify: `src/pages/Offer.tsx` -- Modify: `src/components/OfferRowCard.tsx` - -- [ ] **Step 1: Update RpcSettings in Settings.tsx (line ~939-986)** - -Replace `promptIfEnabled` usage in `start` and `toggleRunOnStartup` with `requestPassword`. Since these are sensitive operations but don't require a password, use `requestPassword(false)` which triggers the standalone biometric gate: - -```typescript -function RpcSettings() { - const { addError } = useErrors(); - const { requestPassword } = usePassword(); - // ... existing state ... - - const start = async () => { - const auth = await requestPassword(false); - if (auth === undefined) return; - - commands - .startRpcServer() - .catch(addError) - .then(() => setIsRunning(true)); - }; - - const toggleRunOnStartup = async (checked: boolean) => { - const auth = await requestPassword(false); - if (auth === undefined) return; - - commands - .setRpcRunOnStartup(checked) - .catch(addError) - .then(() => setRunOnStartup(checked)); - }; - // ... -} -``` - -- [ ] **Step 2: Update increaseDerivationIndex in Settings.tsx (line ~1257-1258)** - -The `handler` function in the derivation index section uses the old pattern: - -```typescript -// Old: -if (password === null && key?.has_password) return; -// New: -if (password === undefined) return; -``` - -- [ ] **Step 3: Update OfferRowCard.tsx (line ~67-68)** - -The `cancelHandler` uses the old pattern: - -```typescript -// Old: -const password = await requestPassword(wallet?.has_password ?? false); -if (password === null && wallet?.has_password) return; -// New: -const password = await requestPassword(wallet?.has_password ?? false); -if (password === undefined) return; -``` - -- [ ] **Step 4: Update Offers.tsx auth pattern (line ~192-194)** - -Same replacement: `password === null && wallet?.has_password` → `password === undefined`. - -- [ ] **Step 5: Update Offer.tsx auth pattern (line ~103-104)** - -Same replacement. - -- [ ] **Step 6: Verify no remaining promptIfEnabled call sites** - -Run: `grep -r "promptIfEnabled" src/ --include="*.ts" --include="*.tsx" -l` - -Expected: Only `BiometricContext.tsx` (definition) and `useBiometric.ts` (re-export). No call sites. - -- [ ] **Step 7: Commit** - -```bash -git add src/pages/Settings.tsx src/pages/Offers.tsx src/pages/Offer.tsx src/components/OfferRowCard.tsx -git commit -m "refactor: update remaining call sites to unified auth pattern" -``` - ---- - -## Chunk 4: Settings UI and Keychain Lifecycle - -### Task 9: Move biometric toggle to Security section with context-aware labels - -**Files:** - -- Modify: `src/pages/Settings.tsx` - -- [ ] **Step 1: Remove biometric toggle from GlobalSettings (line ~307-318)** - -Remove the `isMobile && (...)` block containing the biometric toggle from the Preferences section in `GlobalSettings`. - -- [ ] **Step 2: Add biometric toggle to WalletSettings Security section** - -The Security section already exists in WalletSettings (where password management lives). Add the biometric toggle there with context-aware labels: - -```typescript -// Inside the Security section of WalletSettings, after password controls -{isMobile && available && ( - <> - - } - /> - {wallet?.has_password && biometricEnabled && ( -

- {t`Biometric unlock is a convenience — remember your password. There is no way to recover a lost password.`} -

- )} - -)} -``` - -Where `biometricEnabled`, `available`, and `toggleBiometric` come from the existing biometric context, but `toggleBiometric` is updated to clear keychain entries on disable: - -```typescript -const { - enabled: biometricEnabled, - available, - enableIfAvailable, - disable, -} = useBiometric(); -const { clearAllKeychainEntries } = usePassword(); - -const toggleBiometric = async (value: boolean) => { - if (value) { - await enableIfAvailable(); - } else { - await disable(); - // Clear all stored passwords from keychain - if (keys) { - await clearAllKeychainEntries(keys.map((k) => k.fingerprint)); - } - } -}; -``` - -Note: `useBiometric` and `usePassword` need to be available in the WalletSettings component. Check if they're already imported there. - -- [ ] **Step 3: Commit** - -```bash -git add src/pages/Settings.tsx -git commit -m "feat: move biometric toggle to Security section with context-aware labels" -``` - ---- - -### Task 10: Handle keychain lifecycle in password management - -**Files:** - -- Modify: `src/pages/Settings.tsx` (password change/remove handlers) - -**Note:** Stale keychain entry recovery is already handled in the `PasswordContext` code from Task 3 via `skipKeychainRef`. This task only covers the explicit lifecycle events triggered by user actions in Settings. - -- [ ] **Step 1: Update password change handler to update keychain** - -When a user changes their password in Settings, update the keychain entry. Destructure `updateKeychainEntry` from `usePassword()` in the relevant Settings component: - -```typescript -// After successful commands.changePassword(): -await updateKeychainEntry(wallet.fingerprint, newPassword); -``` - -- [ ] **Step 2: Update password remove handler to clear keychain** - -When a user removes their password, clear the keychain entry: - -```typescript -// After successful commands.changePassword(old, ""): -await clearKeychainEntry(wallet.fingerprint); -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/pages/Settings.tsx -git commit -m "feat: handle keychain lifecycle for password changes" -``` - ---- - -### Task 11: Handle wallet deletion keychain cleanup - -**Files:** - -- Modify: `src/components/WalletCard.tsx` or wherever `deleteKey` is called - -- [ ] **Step 1: Clear keychain entry on wallet deletion** - -In the `deleteSelf` handler in `WalletCard.tsx`, after successful deletion: - -```typescript -const { clearKeychainEntry } = usePassword(); - -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); -}; -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/components/WalletCard.tsx -git commit -m "feat: clear keychain entry on wallet deletion" -``` - ---- - -## Chunk 5: Verification and Cleanup - -### Task 12: Verify no remaining direct promptIfEnabled usage - -- [ ] **Step 1: Search for any remaining promptIfEnabled call sites** - -Run: `grep -r "promptIfEnabled" src/ --include="*.ts" --include="*.tsx"` - -Expected: Only `BiometricContext.tsx` (definition) and `useBiometric.ts` (re-export). No call sites. - -- [ ] **Step 2: Search for old auth pattern** - -Run: `grep -r "password === null && wallet" src/ --include="*.ts" --include="*.tsx"` - -Expected: No results. All should use `password === undefined`. - -- [ ] **Step 3: Verify TypeScript compiles** - -Run: `pnpm tsc --noEmit` - -- [ ] **Step 4: Verify build succeeds** - -Run: `pnpm build` - -- [ ] **Step 5: Commit any final cleanup** - -```bash -git add -A -git commit -m "chore: final cleanup for biometric-password bridge" -``` - ---- - -### Task 13: Manual testing checklist - -These are manual verification steps since the biometric bridge requires mobile hardware: - -- [ ] **Step 1: Desktop — password dialog works as before** - - Create wallet, set password, verify operations prompt for password - - Verify cancel returns undefined (operation aborted) - - Verify wrong password shows toast - -- [ ] **Step 2: Desktop — biometric toggle not visible** - - Verify biometric toggle does not appear in Settings on desktop - -- [ ] **Step 3: Mobile — standalone biometric gate** - - Enable biometric, no password set - - Verify sensitive operations trigger biometric prompt - - Verify cancel returns undefined - -- [ ] **Step 4: Mobile — biometric + password (first use)** - - Set password on wallet, enable biometric - - First protected operation → password dialog (no keychain entry yet) - - Type correct password → operation succeeds → password stored in keychain - -- [ ] **Step 5: Mobile — biometric + password (subsequent)** - - Next protected operation → biometric prompt (keychain retrieval) - - On success → operation proceeds without typing password - -- [ ] **Step 6: Mobile — biometric fails → fallback to dialog** - - Cancel biometric prompt → password dialog appears - - Type password → operation succeeds - -- [ ] **Step 7: Mobile — stale keychain entry** - - Change password in Settings - - Next operation should use new keychain entry (updated on change) - -- [ ] **Step 8: Mobile — disable biometric clears keychain** - - Disable biometric toggle - - Next operation should prompt for password (no keychain) - -- [ ] **Step 9: WalletConnect — unified auth works** - - Connect dApp via WalletConnect - - Trigger signing operation → single auth prompt (not two) diff --git a/docs/superpowers/plans/2026-03-15-password-protection.md b/docs/superpowers/plans/2026-03-15-password-protection.md deleted file mode 100644 index 281fbca95..000000000 --- a/docs/superpowers/plans/2026-03-15-password-protection.md +++ /dev/null @@ -1,994 +0,0 @@ -# Password Protection Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add opt-in per-operation password protection to Sage wallet, gating secret key access, transaction signing, and hardened key generation behind user-provided passwords. - -**Architecture:** Layer real passwords onto the existing Argon2 + AES-256-GCM encryption in `sage-keychain`. Add `password: Option` to all API request structs that trigger signing or secret access. Thread the password through `transact()` → `sign()` → `extract_secrets()`. Add `password_protected: bool` to `KeyData::Secret` and expose via `KeyInfo`. Frontend prompts per-operation when the active wallet has a password set. - -**Tech Stack:** Rust (sage-keychain, sage-api, sage crates), TypeScript/React (frontend), Tauri IPC - -**Spec:** `docs/superpowers/specs/2026-03-15-password-protection-design.md` - ---- - -## Chunk 1: Keychain Core — `password_protected` flag and `change_password` - -### Task 1: Add `password_protected` to `KeyData::Secret` - -**Files:** - -- Modify: `crates/sage-keychain/src/key_data.rs:9-20` - -- [ ] **Step 1: Add `password_protected` field to `KeyData::Secret`** - -In `crates/sage-keychain/src/key_data.rs`, add the field to the `Secret` variant: - -```rust -Secret { - #[serde_as(as = "Bytes")] - master_pk: [u8; 48], - entropy: bool, - encrypted: Encrypted, - password_protected: bool, -} -``` - -**IMPORTANT:** `keys.bin` uses `bincode` serialization, NOT JSON. `#[serde(default)]` does NOT work with bincode — adding a field changes the binary layout and breaks deserialization of existing files. We must handle backward compatibility in `from_bytes()`. - -- [ ] **Step 1b: Add backward-compatible deserialization to `from_bytes()`** - -In `crates/sage-keychain/src/keychain.rs`, update `from_bytes()` to try the new format first, then fall back to the old format: - -```rust -/// Legacy KeyData without password_protected field, for backward compat -#[serde_as] -#[derive(Debug, Clone, Serialize, Deserialize)] -#[repr(u8)] -enum LegacyKeyData { - Public { - #[serde_as(as = "Bytes")] - master_pk: [u8; 48], - }, - Secret { - #[serde_as(as = "Bytes")] - master_pk: [u8; 48], - entropy: bool, - encrypted: Encrypted, - }, -} - -impl From for KeyData { - fn from(legacy: LegacyKeyData) -> Self { - match legacy { - LegacyKeyData::Public { master_pk } => KeyData::Public { master_pk }, - LegacyKeyData::Secret { master_pk, entropy, encrypted } => KeyData::Secret { - master_pk, - entropy, - encrypted, - password_protected: false, - }, - } - } -} -``` - -Then update `from_bytes()`: - -```rust -pub fn from_bytes(data: &[u8]) -> Result { - let keys: HashMap = bincode::deserialize(data) - .or_else(|_| { - // Fall back to legacy format without password_protected - let legacy: HashMap = bincode::deserialize(data)?; - Ok(legacy.into_iter().map(|(k, v)| (k, v.into())).collect()) - })?; - Ok(Self { - rng: ChaCha20Rng::from_entropy(), - keys, - }) -} -``` - -Add the necessary imports for `LegacyKeyData` (`serde_with::serde_as`, `Encrypted` etc.). - -- [ ] **Step 2: Verify the project compiles** - -Run: `cargo check -p sage-keychain` -Expected: compilation errors in `keychain.rs` where `KeyData::Secret` is constructed without the new field. - -- [ ] **Step 3: Fix all `KeyData::Secret` construction sites in `keychain.rs`** - -In `crates/sage-keychain/src/keychain.rs`, update `add_secret_key()` (line ~138) and `add_mnemonic()` (line ~166) to include `password_protected`: - -For `add_secret_key()`: - -```rust -self.keys.insert( - fingerprint, - KeyData::Secret { - master_pk: master_pk.to_bytes(), - entropy: false, - encrypted, - password_protected: !password.is_empty(), - }, -); -``` - -For `add_mnemonic()`: - -```rust -self.keys.insert( - fingerprint, - KeyData::Secret { - master_pk: master_pk.to_bytes(), - entropy: true, - encrypted, - password_protected: !password.is_empty(), - }, -); -``` - -- [ ] **Step 4: Add `is_password_protected()` accessor to `Keychain`** - -In `crates/sage-keychain/src/keychain.rs`, add: - -```rust -pub fn is_password_protected(&self, fingerprint: u32) -> bool { - matches!( - self.keys.get(&fingerprint), - Some(KeyData::Secret { password_protected: true, .. }) - ) -} -``` - -- [ ] **Step 5: Verify compilation** - -Run: `cargo check -p sage-keychain` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add crates/sage-keychain/src/key_data.rs crates/sage-keychain/src/keychain.rs -git commit -m "feat: add password_protected flag to KeyData::Secret" -``` - -### Task 2: Add `change_password` to `Keychain` - -**Files:** - -- Modify: `crates/sage-keychain/src/keychain.rs` - -- [ ] **Step 1: Write a test for `change_password`** - -Add at the bottom of `crates/sage-keychain/src/keychain.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use bip39::Mnemonic; - - #[test] - fn test_change_password() { - let mut keychain = Keychain::default(); - let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); - - let fingerprint = keychain.add_mnemonic(&mnemonic, b"").unwrap(); - assert!(!keychain.is_password_protected(fingerprint)); - - // Set a password - keychain.change_password(fingerprint, b"", b"secret123").unwrap(); - assert!(keychain.is_password_protected(fingerprint)); - - // Old password should fail - assert!(keychain.extract_secrets(fingerprint, b"").is_err()); - - // New password should work - let (mnemonic_out, Some(_sk)) = keychain.extract_secrets(fingerprint, b"secret123").unwrap() else { - panic!("expected secret key"); - }; - assert!(mnemonic_out.is_some()); - - // Change to another password - keychain.change_password(fingerprint, b"secret123", b"newpass").unwrap(); - assert!(keychain.extract_secrets(fingerprint, b"secret123").is_err()); - let (_m, Some(_sk)) = keychain.extract_secrets(fingerprint, b"newpass").unwrap() else { - panic!("expected secret key"); - }; - - // Remove password - keychain.change_password(fingerprint, b"newpass", b"").unwrap(); - assert!(!keychain.is_password_protected(fingerprint)); - let (_m, Some(_sk)) = keychain.extract_secrets(fingerprint, b"").unwrap() else { - panic!("expected secret key"); - }; - } - - #[test] - fn test_change_password_wrong_old_password() { - let mut keychain = Keychain::default(); - let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); - - let fingerprint = keychain.add_mnemonic(&mnemonic, b"correct").unwrap(); - - assert!(keychain.change_password(fingerprint, b"wrong", b"newpass").is_err()); - // Original password still works - let (_m, Some(_sk)) = keychain.extract_secrets(fingerprint, b"correct").unwrap() else { - panic!("expected secret key"); - }; - } - - #[test] - fn test_change_password_public_key_fails() { - let mut keychain = Keychain::default(); - let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); - let master_sk = SecretKey::from_seed(&mnemonic.to_seed("")); - let master_pk = master_sk.public_key(); - - let fingerprint = keychain.add_public_key(&master_pk).unwrap(); - - assert!(keychain.change_password(fingerprint, b"", b"pass").is_err()); - } - - #[test] - fn test_password_protected_flag_on_import() { - let mut keychain = Keychain::default(); - let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); - - let fp_no_pass = keychain.add_mnemonic(&mnemonic, b"").unwrap(); - assert!(!keychain.is_password_protected(fp_no_pass)); - - // Need a different mnemonic to avoid KeyExists - let mut keychain2 = Keychain::default(); - let fp_with_pass = keychain2.add_mnemonic(&mnemonic, b"secret").unwrap(); - assert!(keychain2.is_password_protected(fp_with_pass)); - } - - #[test] - fn test_serialization_roundtrip_with_password() { - let mut keychain = Keychain::default(); - let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); - let fingerprint = keychain.add_mnemonic(&mnemonic, b"pass123").unwrap(); - - let bytes = keychain.to_bytes().unwrap(); - let keychain2 = Keychain::from_bytes(&bytes).unwrap(); - - assert!(keychain2.is_password_protected(fingerprint)); - let (_m, Some(_sk)) = keychain2.extract_secrets(fingerprint, b"pass123").unwrap() else { - panic!("expected secret key"); - }; - } - - #[test] - fn test_legacy_format_backward_compat() { - // Simulate a legacy keys.bin by serializing with LegacyKeyData - use std::collections::HashMap; - - let mut keychain = Keychain::default(); - let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); - let fingerprint = keychain.add_mnemonic(&mnemonic, b"").unwrap(); - - // Serialize, then deserialize into legacy format, re-serialize as legacy, - // then verify new from_bytes can read it. - // Alternatively: construct a HashMap manually, - // serialize with bincode, and verify Keychain::from_bytes reads it. - let mut legacy_map: HashMap = HashMap::new(); - if let Some(KeyData::Secret { master_pk, entropy, encrypted, .. }) = keychain.keys.get(&fingerprint) { - legacy_map.insert(fingerprint, LegacyKeyData::Secret { - master_pk: *master_pk, - entropy: *entropy, - encrypted: encrypted.clone(), - }); - } - let legacy_bytes = bincode::serialize(&legacy_map).unwrap(); - - // This should succeed via the fallback path - let restored = Keychain::from_bytes(&legacy_bytes).unwrap(); - assert!(!restored.is_password_protected(fingerprint)); - let (_m, Some(_sk)) = restored.extract_secrets(fingerprint, b"").unwrap() else { - panic!("expected secret key"); - }; - } -} -``` - -- [ ] **Step 2: Run the tests to see them fail** - -Run: `cargo test -p sage-keychain` -Expected: FAIL — `change_password` method does not exist. - -- [ ] **Step 3: Implement `change_password`** - -In `crates/sage-keychain/src/keychain.rs`, add the method to `impl Keychain`: - -```rust -pub fn change_password( - &mut self, - fingerprint: u32, - old_password: &[u8], - new_password: &[u8], -) -> Result<(), KeychainError> { - let key_data = self.keys.get(&fingerprint).ok_or(KeychainError::KeyNotFound)?; - - let (entropy, master_pk, secret_data) = match key_data { - KeyData::Public { .. } => return Err(KeychainError::NoSecretKey), - KeyData::Secret { - entropy, - master_pk, - encrypted, - .. - } => { - // Decrypt once with old password — this verifies the password - // and gives us the raw secret data to re-encrypt - let data = decrypt::(encrypted, old_password)?; - (*entropy, *master_pk, data) - } - }; - - // Re-encrypt the same secret data with new password - let encrypted = encrypt(new_password, &mut self.rng, &secret_data)?; - - self.keys.insert( - fingerprint, - KeyData::Secret { - master_pk, - entropy, - encrypted, - password_protected: !new_password.is_empty(), - }, - ); - - Ok(()) -} -``` - -Also add these error variants to `crates/sage-keychain/src/error.rs` (they do not currently exist): - -```rust -#[error("Key not found")] -KeyNotFound, - -#[error("No secret key")] -NoSecretKey, -``` - -- [ ] **Step 4: Run the tests** - -Run: `cargo test -p sage-keychain` -Expected: All tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add crates/sage-keychain/ -git commit -m "feat: add change_password and keychain tests" -``` - ---- - -## Chunk 2: API Layer — Request struct changes and new endpoint types - -### Task 3: Add `password` field to all protected request structs - -**Files:** - -- Modify: `crates/sage-api/src/requests/keys.rs` -- Modify: `crates/sage-api/src/requests/transactions.rs` -- Modify: `crates/sage-api/src/requests/offers.rs` -- Modify: `crates/sage-api/src/requests/actions.rs` -- Modify: `crates/sage-api/src/requests/wallet_connect.rs` -- Modify: `crates/sage-api/src/requests/action_system.rs` - -- [ ] **Step 1: Add `password: Option` to `GetSecretKey` and `ImportKey`** - -In `crates/sage-api/src/requests/keys.rs`: - -`GetSecretKey` — change from `Copy + Serialize, Deserialize` to just `Clone + Serialize, Deserialize` (since `Option` is not `Copy`), add: - -```rust -pub struct GetSecretKey { - pub fingerprint: u32, - #[serde(default)] - #[cfg_attr(feature = "openapi", schema(nullable = true))] - pub password: Option, -} -``` - -`ImportKey` — add: - -```rust - #[serde(default)] - #[cfg_attr(feature = "openapi", schema(nullable = true))] - pub password: Option, -``` - -- [ ] **Step 2: Add `password` to all transaction request structs** - -In `crates/sage-api/src/requests/transactions.rs`, add to every struct that has `auto_submit: bool` (these all flow through `transact()`): - -```rust - /// Password for signing (required if wallet is password-protected) - #[serde(default)] - #[cfg_attr(feature = "openapi", schema(nullable = true))] - pub password: Option, -``` - -Structs to modify: `SendXch`, `BulkSendXch`, `Combine`, `AutoCombineXch`, `Split`, `AutoCombineCat`, `IssueCat`, `SendCat`, `BulkSendCat`, `MultiSend`, `CreateDid`, `BulkMintNfts`, `TransferNfts`, `AddNftUri`, `AssignNftsToDid`, `TransferDids`, `NormalizeDids`, `MintOption`, `TransferOptions`, `ExerciseOptions`, `FinalizeClawback`, `SignCoinSpends`. - -- [ ] **Step 3: Add `password` to offer request structs** - -In `crates/sage-api/src/requests/offers.rs`, add `password: Option` (same pattern) to: `MakeOffer`, `TakeOffer`, `CancelOffer`, `CancelOffers`. - -- [ ] **Step 4: Add `password` to action request structs** - -In `crates/sage-api/src/requests/actions.rs`, add to `IncreaseDerivationIndex` (search for the struct — it contains `index: u32`). - -- [ ] **Step 5: Add `password` to WalletConnect signing structs** - -In `crates/sage-api/src/requests/wallet_connect.rs`, add `password: Option` to `SignMessageWithPublicKey` and `SignMessageByAddress`. Note these use `#[serde(rename_all = "camelCase")]` so the field will serialize as `password` (single word, no rename needed). - -- [ ] **Step 6: Add `password` to `CreateTransaction`** - -In `crates/sage-api/src/requests/action_system.rs`, add to `CreateTransaction`: - -```rust - /// Password for signing (required if wallet is password-protected) - #[serde(default)] - #[cfg_attr(feature = "openapi", schema(nullable = true))] - pub password: Option, -``` - -- [ ] **Step 7: Verify compilation** - -Run: `cargo check -p sage-api` -Expected: PASS (these are just data structs, no logic changes). - -- [ ] **Step 8: Commit** - -```bash -git add crates/sage-api/src/requests/ -git commit -m "feat: add password field to all protected request structs" -``` - -### Task 4: Add `ChangePassword` request/response and `has_password` to `KeyInfo` - -**Files:** - -- Modify: `crates/sage-api/src/requests/keys.rs` -- Modify: `crates/sage-api/src/types/key_info.rs` - -- [ ] **Step 1: Add `ChangePassword` / `ChangePasswordResponse` to keys.rs** - -In `crates/sage-api/src/requests/keys.rs`, add (following the existing struct pattern with openapi/tauri derives): - -```rust -/// Change the password for a wallet's secret key -#[cfg_attr( - feature = "openapi", - crate::openapi_attr( - tag = "Authentication & Keys", - description = "Change the password used to encrypt a wallet's secret key." - ) -)] -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "tauri", derive(specta::Type))] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -pub struct ChangePassword { - /// Wallet fingerprint - pub fingerprint: u32, - /// Current password (empty string if no password is set) - pub old_password: String, - /// New password (empty string to remove password protection) - pub new_password: String, -} - -/// Response after changing the password -#[cfg_attr(feature = "openapi", crate::openapi_attr(tag = "Authentication & Keys"))] -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[cfg_attr(feature = "tauri", derive(specta::Type))] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -pub struct ChangePasswordResponse {} -``` - -- [ ] **Step 2: Add `has_password` to `KeyInfo`** - -In `crates/sage-api/src/types/key_info.rs`, add to `KeyInfo`: - -```rust -pub struct KeyInfo { - pub name: String, - pub fingerprint: u32, - pub public_key: String, - pub kind: KeyKind, - pub has_secrets: bool, - pub has_password: bool, - pub network_id: String, - pub emoji: Option, -} -``` - -- [ ] **Step 3: Register `ChangePassword` in the endpoint macro system** - -The endpoint macro system reads from `crates/sage-api/endpoints.json`. Each entry maps an endpoint name to a boolean indicating whether it is async (`true`) or sync (`false`). - -Add `change_password` to `crates/sage-api/endpoints.json`: - -```json - "is_asset_owned": true, - "change_password": false -``` - -`change_password` is `false` (sync) because it only calls `keychain.change_password()` and `save_keychain()` — no async operations. - -Also ensure `ChangePassword` and `ChangePasswordResponse` are re-exported through the `sage_api` module hierarchy. The types in `crates/sage-api/src/requests/keys.rs` should be auto-exported via `pub use requests::*` in `crates/sage-api/src/lib.rs`. Verify by checking how `GetSecretKey` and `GetSecretKeyResponse` are exported. - -- [ ] **Step 4: Verify compilation** - -Run: `cargo check -p sage-api` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add crates/sage-api/ -git commit -m "feat: add ChangePassword endpoint and has_password to KeyInfo" -``` - ---- - -## Chunk 3: Backend Endpoints — Thread passwords through the call chain - -### Task 5: Update `sign()` and `transact()` to accept passwords - -**Files:** - -- Modify: `crates/sage/src/utils/spends.rs` -- Modify: `crates/sage/src/endpoints/transactions.rs` - -- [ ] **Step 1: Add `password` parameter to `sign()`** - -In `crates/sage/src/utils/spends.rs`, change the signature: - -```rust -pub(crate) async fn sign( - &self, - coin_spends: Vec, - partial: bool, - password: &[u8], -) -> Result { - let wallet = self.wallet()?; - - let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, password)? - else { - return Err(Error::NoSigningKey); - }; - // ... rest unchanged -``` - -- [ ] **Step 2: Add `password` parameter to `transact()` and `transact_with()`** - -In `crates/sage/src/endpoints/transactions.rs`, update: - -```rust -pub(crate) async fn transact( - &self, - coin_spends: Vec, - auto_submit: bool, - password: &[u8], -) -> Result { - self.transact_with(coin_spends, auto_submit, ConfirmationInfo::default(), password) - .await -} - -pub(crate) async fn transact_with( - &self, - coin_spends: Vec, - auto_submit: bool, - info: ConfirmationInfo, - password: &[u8], -) -> Result { - if auto_submit { - let spend_bundle = self.sign(coin_spends.clone(), false, password).await?; - self.submit(spend_bundle).await?; - } - // ... rest unchanged -``` - -- [ ] **Step 3: Extract password and pass it in all transaction endpoints (same file)** - -Do NOT commit yet — complete all call site fixes in one go to keep the branch compilable. - -- [ ] **Step 4: Define the password extraction pattern** - -At the top of `crates/sage/src/endpoints/transactions.rs` (or in a shared util), the pattern for every endpoint is the same: - -```rust -let password = req.password.unwrap_or_default().into_bytes(); -``` - -Use this inline in each endpoint. No helper function needed — it's one line. - -- [ ] **Step 5: Update every transaction endpoint** - -For each of the 21 transaction endpoint methods, add the password extraction and pass it to `transact()` or `transact_with()`. The pattern for every endpoint is the same. Example for `send_xch`: - -```rust -pub async fn send_xch(&self, req: SendXch) -> Result { - let wallet = self.wallet()?; - let password = req.password.unwrap_or_default().into_bytes(); - let puzzle_hash = self.parse_address(req.address)?; - let amount = parse_amount(req.amount)?; - let fee = parse_amount(req.fee)?; - let memos = parse_memos(req.memos)?; - - let coin_spends = wallet - .send_xch(vec![(puzzle_hash, amount)], fee, memos, req.clawback) - .await?; - self.transact(coin_spends, req.auto_submit, &password).await -} -``` - -Apply the same pattern to all 21 endpoints: extract `password` from `req`, pass `&password` as the last arg to `self.transact()` or `self.transact_with()`. - -Also update `sign_coin_spends` which calls `self.sign()` directly: - -```rust -let password = req.password.unwrap_or_default().into_bytes(); -let spend_bundle = self.sign(coin_spends, req.partial, &password).await?; -``` - -- [ ] **Step 6: Continue to remaining endpoints (do NOT commit yet)** - -### Task 6: Thread password through offers, actions, wallet_connect, and keys - -**Files:** - -- Modify: `crates/sage/src/endpoints/offers.rs` -- Modify: `crates/sage/src/endpoints/actions.rs` -- Modify: `crates/sage/src/endpoints/wallet_connect.rs` -- Modify: `crates/sage/src/endpoints/keys.rs` -- Modify: `crates/sage/src/endpoints/action_system.rs` (for `create_transaction`) - -- [ ] **Step 1: Update `offers.rs`** - -`make_offer()` and `take_offer()` call `extract_secrets` directly. `cancel_offer()` and `cancel_offers()` call `transact()`. Update all four: - -For `make_offer` and `take_offer`: - -```rust -let password = req.password.unwrap_or_default().into_bytes(); -// ... -let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, &password)? -``` - -For `cancel_offer` and `cancel_offers`: - -```rust -let password = req.password.unwrap_or_default().into_bytes(); -// ... pass &password to self.transact() -``` - -- [ ] **Step 2: Update `actions.rs`** - -In `increase_derivation_index()`: - -```rust -let password = req.password.unwrap_or_default().into_bytes(); -// ... -let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, &password)? -``` - -- [ ] **Step 3: Update `wallet_connect.rs`** - -Both `sign_message_with_public_key` and `sign_message_by_address`: - -```rust -let password = req.password.unwrap_or_default().into_bytes(); -// ... -let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, &password)? -``` - -- [ ] **Step 4: Update `keys.rs`** - -`import_key()` — pass password to `add_secret_key` and `add_mnemonic`: - -```rust -let password_bytes = req.password.unwrap_or_default().into_bytes(); -// ... -self.keychain.add_secret_key(&master_sk, &password_bytes)? -// ... -self.keychain.add_mnemonic(&mnemonic, &password_bytes)? -``` - -`get_secret_key()` — pass password to `extract_secrets`: - -```rust -let password = req.password.unwrap_or_default().into_bytes(); -let (mnemonic, Some(secret_key)) = self.keychain.extract_secrets(req.fingerprint, &password)? -``` - -`get_key()` and `get_keys()` — populate `has_password` on `KeyInfo`: - -```rust -has_password: self.keychain.is_password_protected(fingerprint), -``` - -- [ ] **Step 5: Update `action_system.rs`** - -In the `create_transaction()` method: - -```rust -let password = req.password.unwrap_or_default().into_bytes(); -// ... pass &password to self.transact_with() -``` - -- [ ] **Step 6: Verify full compilation** - -Run: `cargo check -p sage` -Expected: PASS — all `b""` usages replaced, all call sites updated. - -- [ ] **Step 7: Run existing tests** - -Run: `cargo test -p sage-rpc` -Expected: FAIL — existing tests (like `test_send_xch`) pass `SendXch { ... }` without a `password` field. Since `password` has `#[serde(default)]`, deserialization should still work. But if tests construct structs directly, they'll need `password: None` added. Check and fix. - -- [ ] **Step 8: Fix any test compilation issues** - -In `crates/sage-rpc/src/tests.rs`, add `password: None` to any struct literal that now requires it (e.g., `SendXch`, `ImportKey`). - -- [ ] **Step 9: Run tests again** - -Run: `cargo test -p sage-rpc` -Expected: PASS - -- [ ] **Step 10: Commit (all call chain changes in one compilable commit)** - -```bash -git add crates/sage/src/utils/spends.rs crates/sage/src/endpoints/ crates/sage-rpc/src/tests.rs -git commit -m "feat: thread password through all protected endpoints" -``` - -### Task 7: Add `change_password` endpoint - -**Files:** - -- Modify: `crates/sage/src/endpoints/keys.rs` - -- [ ] **Step 1: Implement `change_password` endpoint** - -In `crates/sage/src/endpoints/keys.rs`, add: - -```rust -pub fn change_password(&mut self, req: ChangePassword) -> Result { - let old_password = req.old_password.into_bytes(); - let new_password = req.new_password.into_bytes(); - self.keychain.change_password(req.fingerprint, &old_password, &new_password)?; - self.save_keychain()?; - Ok(ChangePasswordResponse {}) -} -``` - -Make sure `ChangePassword` and `ChangePasswordResponse` are imported at the top. - -- [ ] **Step 2: Verify endpoint is registered** - -`change_password` should already be registered in `crates/sage-api/endpoints.json` from Task 4, Step 3. Verify it appears there as `"change_password": false`. - -- [ ] **Step 3: Verify compilation** - -Run: `cargo check -p sage` -Expected: PASS - -- [ ] **Step 4: Commit** - -```bash -git add crates/sage/src/endpoints/keys.rs -git commit -m "feat: add change_password endpoint" -``` - ---- - -## Chunk 4: Integration Tests - -### Task 8: Add integration tests for password protection - -**Files:** - -- Modify: `crates/sage-rpc/src/tests.rs` - -- [ ] **Step 1: Add test for password-protected key import and secret retrieval** - -```rust -#[tokio::test] -async fn test_password_protected_import() -> Result<()> { - let mut app = TestApp::new().await?; - - // Import with password - let response = app.import_key(ImportKey { - key: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string(), - name: "Test".to_string(), - save_secrets: true, - password: Some("mypassword".to_string()), - // ... other required fields with defaults - }).await?; - - let fingerprint = response.fingerprint; - - // Verify has_password is true - let key = app.get_key(GetKey { fingerprint: Some(fingerprint) }).await?.key.unwrap(); - assert!(key.has_password); - - // Getting secret without password should fail - let result = app.get_secret_key(GetSecretKey { - fingerprint, - password: None, - }).await; - assert!(result.is_err()); - - // Getting secret with correct password should work - let result = app.get_secret_key(GetSecretKey { - fingerprint, - password: Some("mypassword".to_string()), - }).await?; - assert!(result.secrets.is_some()); - - Ok(()) -} -``` - -- [ ] **Step 2: Add test for password-protected transaction signing** - -```rust -#[tokio::test] -async fn test_password_protected_send() -> Result<()> { - let mut app = TestApp::new().await?; - - // Setup with password - use the test helper but import with password - // This may need adjusting based on the TestApp::setup_bls helper - let fingerprint = app.setup_bls_with_password(1000, "secret").await?; - - app.wait_for_coins().await; - - // Send without password should fail - let result = app.send_xch(SendXch { - address: "txch1...".to_string(), // use a valid test address - amount: Amount::u64(100), - fee: Amount::u64(0), - memos: vec![], - clawback: None, - auto_submit: true, - password: None, - }).await; - assert!(result.is_err()); - - // Send with password should succeed - let result = app.send_xch(SendXch { - address: "txch1...".to_string(), - amount: Amount::u64(100), - fee: Amount::u64(0), - memos: vec![], - clawback: None, - auto_submit: true, - password: Some("secret".to_string()), - }).await; - assert!(result.is_ok()); - - Ok(()) -} -``` - -Note: The exact test setup will depend on how `TestApp::setup_bls` works. You may need to add a `setup_bls_with_password` helper that imports a key with a password, or modify the import call in the test directly. - -- [ ] **Step 3: Add test for change_password** - -```rust -#[tokio::test] -async fn test_change_password() -> Result<()> { - let mut app = TestApp::new().await?; - - let fingerprint = app.setup_bls(0).await?; - - // Initially no password - let key = app.get_key(GetKey { fingerprint: Some(fingerprint) }).await?.key.unwrap(); - assert!(!key.has_password); - - // Set password - app.change_password(ChangePassword { - fingerprint, - old_password: "".to_string(), - new_password: "secret".to_string(), - }).await?; - - // Now has_password should be true - let key = app.get_key(GetKey { fingerprint: Some(fingerprint) }).await?.key.unwrap(); - assert!(key.has_password); - - // Old empty password should fail - let result = app.get_secret_key(GetSecretKey { - fingerprint, - password: None, - }).await; - assert!(result.is_err()); - - // New password should work - let result = app.get_secret_key(GetSecretKey { - fingerprint, - password: Some("secret".to_string()), - }).await?; - assert!(result.secrets.is_some()); - - Ok(()) -} -``` - -- [ ] **Step 4: Run all tests** - -Run: `cargo test -p sage-keychain && cargo test -p sage-rpc` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add crates/sage-rpc/src/tests.rs -git commit -m "test: add integration tests for password protection" -``` - ---- - -## Chunk 5: Tauri Command Layer and Frontend (Skeleton) - -### Task 9: Ensure Tauri commands compile with new request structs - -**Files:** - -- Modify: `src-tauri/src/commands.rs` (if needed — the macro should auto-generate) - -- [ ] **Step 1: Check if the Tauri command layer auto-generates from the endpoint macro** - -The `impl_endpoints_tauri!` macro in `src-tauri/src/commands.rs` auto-generates Tauri commands from the endpoint definitions. If `change_password` was added to the endpoint list in the macro, it should auto-generate. - -Run: `cargo check -p sage-desktop` (or whatever the Tauri package name is) -Expected: PASS if macro handles everything. If not, manually add the `change_password` command. - -- [ ] **Step 2: Verify the full workspace compiles** - -Run: `cargo check --workspace` -Expected: PASS - -- [ ] **Step 3: Commit if any changes needed** - -```bash -git add src-tauri/ -git commit -m "feat: wire change_password through Tauri command layer" -``` - -### Task 10: Frontend — this task is a placeholder for frontend work - -The frontend implementation is a significant body of work that depends on the Sage React app structure. The key pieces are: - -1. **Password prompt dialog component** — a reusable modal that collects a password -2. **Wiring** — every protected Tauri `invoke()` call needs to collect the password first (if `has_password` is true for the active wallet) and include it in the request -3. **Settings UI** — "Set Password", "Change Password", "Remove Password" buttons -4. **Biometric toggle** — future work, depends on selecting a Tauri keyring/biometric plugin - -This task should be planned separately once the backend is complete and tested, as it requires exploring the React app structure, identifying all `invoke()` call sites, and designing the prompt flow. - -- [ ] **Step 1: Document the frontend contract** - -Create a brief document listing: - -- All Tauri command names that now accept `password` -- The `has_password` field on `KeyInfo` for conditional prompting -- The `change_password` command for settings UI - -- [ ] **Step 2: Commit** - -```bash -git commit --allow-empty -m "docs: frontend password protection contract ready" -``` From 5d0ab95bcfc7bc2a8fc9a48f9dd933c4abe810be Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 08:41:18 -0500 Subject: [PATCH 26/53] refactor: move biometric toggle back to global settings 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 --- src/pages/Settings.tsx | 80 +++++++++++++++++------------------------- 1 file changed, 32 insertions(+), 48 deletions(-) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index da58e9f19..b62af61f2 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -263,6 +263,26 @@ function GlobalSettings() { const { expiry, setExpiry } = useDefaultOfferExpiry(); const { clawback, setClawback } = useDefaultClawback(); const { setFee } = useDefaultFee(); + const { clearAllKeychainEntries } = usePassword(); + const { + enabled: biometricEnabled, + available, + enableIfAvailable, + disable, + } = useBiometric(); + + const isMobile = platform() === 'ios' || platform() === 'android'; + + const toggleBiometric = async (value: boolean) => { + if (value) { + await enableIfAvailable(); + } else { + await disable(); + // Clear all stored passwords from keychain when biometric is disabled + const keysData = await commands.getKeys({}); + await clearAllKeychainEntries(keysData.keys.map((k) => k.fingerprint)); + } + }; const [defaultWalletConfig, setDefaultWalletConfig] = useState(null); @@ -310,6 +330,18 @@ function GlobalSettings() { } /> + {isMobile && available && ( + + } + /> + )} @@ -1004,26 +1036,7 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { requestPassword, clearKeychainEntry, updateKeychainEntry, - clearAllKeychainEntries, } = usePassword(); - const { - enabled: biometricEnabled, - available, - enableIfAvailable, - disable, - } = useBiometric(); - const isMobile = platform() === 'ios' || platform() === 'android'; - - const toggleBiometric = async (value: boolean) => { - if (value) { - await enableIfAvailable(); - } else { - await disable(); - // Clear all stored passwords from keychain when biometric is disabled - const keysData = await commands.getKeys({}); - await clearAllKeychainEntries(keysData.keys.map((k) => k.fingerprint)); - } - }; const walletState = useWalletState(); @@ -1428,35 +1441,6 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { ) } /> - {isMobile && available && ( - <> - - } - /> - {key?.has_password && biometricEnabled && ( -
-

- {t`Biometric unlock is a convenience — remember your password. There is no way to recover a lost password.`} -

-
- )} - - )}
)} From 815e5945ca862eaac0a645f00dd73790beb6262f Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 08:55:32 -0500 Subject: [PATCH 27/53] add merge rule for android:theme to override plugin rules --- src-tauri/gen/android/app/src/main/AndroidManifest.xml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index d9d5222e2..15161cc2b 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -11,7 +12,8 @@ android:roundIcon="@mipmap/ic_launcher_round" android:label="@string/app_name" android:theme="@style/Theme.sage_tauri" - android:usesCleartextTraffic="${usesCleartextTraffic}"> + android:usesCleartextTraffic="${usesCleartextTraffic}" + tools:replace="android:theme"> Date: Mon, 16 Mar 2026 11:17:46 -0500 Subject: [PATCH 28/53] =?UTF-8?q?fix:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20keychain=20skip=20flag=20and=20delete=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/WalletCard.tsx | 5 ++++- src/contexts/PasswordContext.tsx | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/components/WalletCard.tsx b/src/components/WalletCard.tsx index a3ceb6f8a..5d8bb6e63 100644 --- a/src/components/WalletCard.tsx +++ b/src/components/WalletCard.tsx @@ -80,7 +80,10 @@ export function WalletCard({ const deleteSelf = async () => { const password = await requestPassword(info.has_password); - if (password === undefined) return; + if (password === undefined) { + setIsDeleteOpen(false); + return; + } await commands .deleteKey({ fingerprint: info.fingerprint }) .then(async () => { diff --git a/src/contexts/PasswordContext.tsx b/src/contexts/PasswordContext.tsx index 03abc787b..3e4e211be 100644 --- a/src/contexts/PasswordContext.tsx +++ b/src/contexts/PasswordContext.tsx @@ -82,6 +82,10 @@ export function PasswordProvider({ children }: { children: ReactNode }) { // Stale keychain recovery: skip keychain for fingerprints that returned bad passwords const skipKeychainRef = useRef>(new Set()); + // Tracks whether the last requestPassword call returned a keychain password. + // If we're called again (meaning the previous operation succeeded), we clear the skip flag. + const lastKeychainFingerprintRef = useRef(null); + // Biometric caching for standalone gate (Case 2) const lastBiometricPromptRef = useRef(null); @@ -89,6 +93,14 @@ export function PasswordProvider({ children }: { children: ReactNode }) { async (hasPassword: boolean): Promise => { const fingerprint = wallet?.fingerprint; + // If the previous call returned a keychain password and we're being called + // again, the backend accepted it. Clear the skip flag so keychain works next time. + const lastKcFp = lastKeychainFingerprintRef.current; + lastKeychainFingerprintRef.current = null; + if (lastKcFp !== null) { + skipKeychainRef.current.delete(lastKcFp); + } + // Case 1: No password, no biometric → no auth needed if (!hasPassword && !biometricEnabled) { return null; @@ -126,8 +138,10 @@ export function PasswordProvider({ children }: { children: ReactNode }) { ) { const stored = await keychainGet(keychainKey(fingerprint)); if (stored !== null) { - // Mark as pending validation — if backend rejects, next call skips keychain + // Mark as pending validation — if backend rejects, next call skips keychain. + // If backend accepts, the next requestPassword clears the skip flag. skipKeychainRef.current.add(fingerprint); + lastKeychainFingerprintRef.current = fingerprint; return stored; } // Fall through to password dialog if keychain retrieval fails From bd1a53f2dcc8b5552404404fab8621acb0626dc3 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 11:51:13 -0500 Subject: [PATCH 29/53] fix: surface KeyNotFound and NoSecretKey errors as toasts These unauthorized errors were silently swallowed because they don't contain "decrypt" in the reason string. They indicate wallet-level issues (missing key, watch-only wallet) that the user should see, unlike NotLoggedIn/NoSigningKey which are expected during transitions. Co-Authored-By: Claude Opus 4.6 --- src/contexts/ErrorContext.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/contexts/ErrorContext.tsx b/src/contexts/ErrorContext.tsx index d608d6599..0e2aa57cc 100644 --- a/src/contexts/ErrorContext.tsx +++ b/src/contexts/ErrorContext.tsx @@ -31,11 +31,15 @@ export function ErrorProvider({ children }: { children: ReactNode }) { const addError = useCallback((error: CustomError) => { if (error.kind === 'unauthorized') { - // Wrong password errors surface as a toast so the user knows what happened - if (error.reason?.includes('decrypt')) { + const reason = error.reason ?? ''; + if (reason.includes('decrypt')) { + // Wrong password — AES decryption failed toast.error(t`Incorrect password`); + } else if (reason.includes('not found') || reason.includes('No secret')) { + // KeyNotFound or NoSecretKey — wallet-level issue, not a transition + toast.error(error.reason); } - // Other unauthorized errors are expected during wallet transitions + // NotLoggedIn / NoSigningKey during wallet transitions are silently ignored return; } setErrors((prevErrors) => [...prevErrors, error]); From bf8d7c6970e824a592add2dceaee7d4c7c20c371 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 11:56:45 -0500 Subject: [PATCH 30/53] =?UTF-8?q?docs:=20update=20spec=20=E2=80=94=20biome?= =?UTF-8?q?tric=20toggle=20stays=20in=20global=20Preferences?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The biometric toggle is a global setting (not per-wallet), so it belongs in GlobalSettings Preferences, not the per-wallet Security section. Updated spec to match implementation. Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-03-15-password-protection-design.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-03-15-password-protection-design.md b/docs/superpowers/specs/2026-03-15-password-protection-design.md index 4fe10b864..0cc754955 100644 --- a/docs/superpowers/specs/2026-03-15-password-protection-design.md +++ b/docs/superpowers/specs/2026-03-15-password-protection-design.md @@ -294,13 +294,9 @@ Wrong password errors (`ErrorKind::Unauthorized` with reason containing "decrypt #### Settings UI changes -The existing biometric toggle in Settings moves into the **Security** section alongside password controls: +The biometric toggle remains in the **Preferences** section of Global Settings (not per-wallet Security) because it is a global setting that applies to all wallets. It is only visible on mobile when biometric hardware is available and enrolled. -- When `has_password` is false: toggle label "Biometric Authentication" / "Require biometrics for sensitive actions" (standalone gate, current behavior) -- When `has_password` is true: toggle label "Biometric Unlock" / "Use Face ID / Touch ID instead of typing your password" -- Toggle only visible on mobile when biometric hardware is available and enrolled - -A warning is shown when enabling biometric with a password: "Biometric unlock is a convenience — remember your password. There is no way to recover a lost password." +When the toggle is disabled, all `sage-password-*` keychain entries are cleared. #### Design decisions From 6eedcd67303d1b2dcca215816af850fe0f35ccd9 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 12:39:18 -0500 Subject: [PATCH 31/53] fix: add error handling to toggleBiometric in GlobalSettings Wraps the async enable/disable/keychain cleanup in try/catch so failures don't leave the UI in an inconsistent state. Co-Authored-By: Claude Opus 4.6 --- src/pages/Settings.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index b62af61f2..24bf8f1fe 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -274,13 +274,17 @@ function GlobalSettings() { const isMobile = platform() === 'ios' || platform() === 'android'; const toggleBiometric = async (value: boolean) => { - if (value) { - await enableIfAvailable(); - } else { - await disable(); - // Clear all stored passwords from keychain when biometric is disabled - const keysData = await commands.getKeys({}); - await clearAllKeychainEntries(keysData.keys.map((k) => k.fingerprint)); + try { + if (value) { + await enableIfAvailable(); + } else { + await disable(); + // Clear all stored passwords from keychain when biometric is disabled + const keysData = await commands.getKeys({}); + await clearAllKeychainEntries(keysData.keys.map((k) => k.fingerprint)); + } + } catch (error) { + addError(error as CustomError); } }; From 8b9a8885da27fc1bd0376eca48327e3892b70f12 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 12:44:17 -0500 Subject: [PATCH 32/53] =?UTF-8?q?docs:=20fix=20spec=20=E2=80=94=20cancel?= =?UTF-8?q?=20resolves=20undefined,=20not=20null?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- docs/superpowers/specs/2026-03-15-password-protection-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-03-15-password-protection-design.md b/docs/superpowers/specs/2026-03-15-password-protection-design.md index 0cc754955..0cb8f64fa 100644 --- a/docs/superpowers/specs/2026-03-15-password-protection-design.md +++ b/docs/superpowers/specs/2026-03-15-password-protection-design.md @@ -234,7 +234,7 @@ A reusable modal dialog rendered by `PasswordProvider`. Features: - Auto-focuses the password input on open - Clears password state on open/close - Supports Enter key to submit -- Cancel closes the dialog and resolves the promise with `null` +- Cancel closes the dialog and resolves the promise with `undefined` (auth cancelled) #### usePassword hook (`src/hooks/usePassword.ts`) From e659e672402f026fc795c66e0187708261b2ad52 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 12:50:47 -0500 Subject: [PATCH 33/53] fix: accept target fingerprint in requestPassword 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 --- src/components/WalletCard.tsx | 4 ++-- src/contexts/PasswordContext.tsx | 13 +++++++------ src/walletconnect/handler.ts | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/components/WalletCard.tsx b/src/components/WalletCard.tsx index 5d8bb6e63..9a2b74ef4 100644 --- a/src/components/WalletCard.tsx +++ b/src/components/WalletCard.tsx @@ -79,7 +79,7 @@ export function WalletCard({ const { currentTheme } = useTheme(); const deleteSelf = async () => { - const password = await requestPassword(info.has_password); + const password = await requestPassword(info.has_password, info.fingerprint); if (password === undefined) { setIsDeleteOpen(false); return; @@ -177,7 +177,7 @@ export function WalletCard({ return; } - const password = await requestPassword(info.has_password); + const password = await requestPassword(info.has_password, info.fingerprint); if (password === undefined) { setIsDetailsOpen(false); return; diff --git a/src/contexts/PasswordContext.tsx b/src/contexts/PasswordContext.tsx index 3e4e211be..906796350 100644 --- a/src/contexts/PasswordContext.tsx +++ b/src/contexts/PasswordContext.tsx @@ -53,10 +53,11 @@ const BIOMETRIC_CACHE_MS = 5 * 60 * 1000; interface PasswordRequest { resolve: (password: string | null | undefined) => void; + fingerprint: number | undefined; } export interface PasswordContextType { - requestPassword: (hasPassword: boolean) => Promise; + requestPassword: (hasPassword: boolean, fingerprint?: number) => Promise; clearKeychainEntry: (fingerprint: number) => Promise; updateKeychainEntry: ( fingerprint: number, @@ -90,8 +91,8 @@ export function PasswordProvider({ children }: { children: ReactNode }) { const lastBiometricPromptRef = useRef(null); const requestPassword = useCallback( - async (hasPassword: boolean): Promise => { - const fingerprint = wallet?.fingerprint; + async (hasPassword: boolean, targetFingerprint?: number): Promise => { + const fingerprint = targetFingerprint ?? wallet?.fingerprint; // If the previous call returned a keychain password and we're being called // again, the backend accepted it. Clear the skip flag so keychain works next time. @@ -150,7 +151,7 @@ export function PasswordProvider({ children }: { children: ReactNode }) { // Case 4: Has password → show dialog (fallback or no biometric) if (hasPassword) { return new Promise((resolve) => { - pendingRef.current = { resolve }; + pendingRef.current = { resolve, fingerprint }; setOpen(true); }); } @@ -163,7 +164,7 @@ export function PasswordProvider({ children }: { children: ReactNode }) { const handleSubmit = useCallback( (password: string) => { setOpen(false); - const fingerprint = wallet?.fingerprint; + const fingerprint = pendingRef.current?.fingerprint; // Manual password entry: clear skip flag and store in keychain if (fingerprint) { @@ -176,7 +177,7 @@ export function PasswordProvider({ children }: { children: ReactNode }) { pendingRef.current?.resolve(password); pendingRef.current = null; }, - [biometricEnabled, wallet?.fingerprint], + [biometricEnabled], ); const handleCancel = useCallback(() => { diff --git a/src/walletconnect/handler.ts b/src/walletconnect/handler.ts index 1f86bfc49..1969e65c0 100644 --- a/src/walletconnect/handler.ts +++ b/src/walletconnect/handler.ts @@ -24,7 +24,7 @@ import { } from './commands/offers'; export interface HandlerContext { - requestPassword: (hasPassword: boolean) => Promise; + requestPassword: (hasPassword: boolean, fingerprint?: number) => Promise; hasPassword: boolean; } From 445679e7d804abe1a835e933b52d22d5e3d9ca0f Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 12:53:33 -0500 Subject: [PATCH 34/53] =?UTF-8?q?fix:=20remove=20skipKeychainRef=20?= =?UTF-8?q?=E2=80=94=20simplify=20stale=20password=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/contexts/PasswordContext.tsx | 38 +++++--------------------------- 1 file changed, 6 insertions(+), 32 deletions(-) diff --git a/src/contexts/PasswordContext.tsx b/src/contexts/PasswordContext.tsx index 906796350..f8daaf31d 100644 --- a/src/contexts/PasswordContext.tsx +++ b/src/contexts/PasswordContext.tsx @@ -80,13 +80,6 @@ export function PasswordProvider({ children }: { children: ReactNode }) { const { enabled: biometricEnabled } = useBiometric(); const { wallet } = useWallet(); - // Stale keychain recovery: skip keychain for fingerprints that returned bad passwords - const skipKeychainRef = useRef>(new Set()); - - // Tracks whether the last requestPassword call returned a keychain password. - // If we're called again (meaning the previous operation succeeded), we clear the skip flag. - const lastKeychainFingerprintRef = useRef(null); - // Biometric caching for standalone gate (Case 2) const lastBiometricPromptRef = useRef(null); @@ -94,14 +87,6 @@ export function PasswordProvider({ children }: { children: ReactNode }) { async (hasPassword: boolean, targetFingerprint?: number): Promise => { const fingerprint = targetFingerprint ?? wallet?.fingerprint; - // If the previous call returned a keychain password and we're being called - // again, the backend accepted it. Clear the skip flag so keychain works next time. - const lastKcFp = lastKeychainFingerprintRef.current; - lastKeychainFingerprintRef.current = null; - if (lastKcFp !== null) { - skipKeychainRef.current.delete(lastKcFp); - } - // Case 1: No password, no biometric → no auth needed if (!hasPassword && !biometricEnabled) { return null; @@ -129,20 +114,13 @@ export function PasswordProvider({ children }: { children: ReactNode }) { } } - // Case 3: Has password, biometric enabled → try keychain first (unless skipped) - if ( - hasPassword && - biometricEnabled && - isMobile && - fingerprint && - !skipKeychainRef.current.has(fingerprint) - ) { + // Case 3: Has password, biometric enabled → try keychain first + // If keychain returns a stale password, the backend will reject it and the + // "Incorrect password" toast fires. The user can cancel the biometric prompt + // on the next attempt to fall through to the password dialog. + if (hasPassword && biometricEnabled && isMobile && fingerprint) { const stored = await keychainGet(keychainKey(fingerprint)); if (stored !== null) { - // Mark as pending validation — if backend rejects, next call skips keychain. - // If backend accepts, the next requestPassword clears the skip flag. - skipKeychainRef.current.add(fingerprint); - lastKeychainFingerprintRef.current = fingerprint; return stored; } // Fall through to password dialog if keychain retrieval fails @@ -166,9 +144,8 @@ export function PasswordProvider({ children }: { children: ReactNode }) { setOpen(false); const fingerprint = pendingRef.current?.fingerprint; - // Manual password entry: clear skip flag and store in keychain + // Manual password entry: store in keychain for future biometric use if (fingerprint) { - skipKeychainRef.current.delete(fingerprint); if (biometricEnabled && isMobile) { keychainSave(keychainKey(fingerprint), password); } @@ -187,13 +164,11 @@ export function PasswordProvider({ children }: { children: ReactNode }) { }, []); const clearKeychainEntry = useCallback(async (fingerprint: number) => { - skipKeychainRef.current.delete(fingerprint); await keychainRemove(keychainKey(fingerprint)); }, []); const updateKeychainEntry = useCallback( async (fingerprint: number, newPassword: string) => { - skipKeychainRef.current.delete(fingerprint); if (biometricEnabled && isMobile) { await keychainSave(keychainKey(fingerprint), newPassword); } @@ -204,7 +179,6 @@ export function PasswordProvider({ children }: { children: ReactNode }) { const clearAllKeychainEntries = useCallback( async (fingerprints: number[]) => { for (const fp of fingerprints) { - skipKeychainRef.current.delete(fp); await keychainRemove(keychainKey(fp)); } }, From c88b3f9b5bfc2f3aab3bbef366659def1870d8ad Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 13:04:04 -0500 Subject: [PATCH 35/53] fix: skip-after-failure for stale keychain passwords 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 --- src/contexts/ErrorContext.tsx | 2 ++ src/contexts/PasswordContext.tsx | 44 +++++++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/contexts/ErrorContext.tsx b/src/contexts/ErrorContext.tsx index 0e2aa57cc..688a2071f 100644 --- a/src/contexts/ErrorContext.tsx +++ b/src/contexts/ErrorContext.tsx @@ -11,6 +11,7 @@ import { t } from '@lingui/core/macro'; import { createContext, ReactNode, useCallback, useState } from 'react'; import { toast } from 'react-toastify'; import { ErrorKind } from '../bindings'; +import { notifyDecryptError } from './PasswordContext'; export interface CustomError { kind: ErrorKind | 'walletconnect' | 'upload' | 'invalid' | 'dexie'; @@ -35,6 +36,7 @@ export function ErrorProvider({ children }: { children: ReactNode }) { if (reason.includes('decrypt')) { // Wrong password — AES decryption failed toast.error(t`Incorrect password`); + notifyDecryptError(); } else if (reason.includes('not found') || reason.includes('No secret')) { // KeyNotFound or NoSecretKey — wallet-level issue, not a transition toast.error(error.reason); diff --git a/src/contexts/PasswordContext.tsx b/src/contexts/PasswordContext.tsx index f8daaf31d..942154a40 100644 --- a/src/contexts/PasswordContext.tsx +++ b/src/contexts/PasswordContext.tsx @@ -51,6 +51,15 @@ async function keychainRemove(key: string): Promise { // Biometric caching interval (5 minutes), matching existing BiometricContext behavior const BIOMETRIC_CACHE_MS = 5 * 60 * 1000; +// Module-level callback for cross-context communication. +// ErrorContext calls notifyDecryptError when a wrong-password error occurs. +// PasswordContext uses lastKeychainFingerprintRef to know which entry is stale. +let onDecryptErrorCallback: (() => void) | null = null; + +export function notifyDecryptError() { + onDecryptErrorCallback?.(); +} + interface PasswordRequest { resolve: (password: string | null | undefined) => void; fingerprint: number | undefined; @@ -80,6 +89,21 @@ export function PasswordProvider({ children }: { children: ReactNode }) { const { enabled: biometricEnabled } = useBiometric(); const { wallet } = useWallet(); + // Skip keychain for fingerprints where the stored password was rejected. + // Set when ErrorContext reports a decrypt error after a keychain retrieval, + // cleared by handleSubmit when the user types a correct password via dialog. + const skipKeychainRef = useRef>(new Set()); + const lastKeychainFingerprintRef = useRef(null); + + // Register the module-level callback so ErrorContext can mark entries stale + onDecryptErrorCallback = () => { + const fp = lastKeychainFingerprintRef.current; + if (fp !== null) { + skipKeychainRef.current.add(fp); + lastKeychainFingerprintRef.current = null; + } + }; + // Biometric caching for standalone gate (Case 2) const lastBiometricPromptRef = useRef(null); @@ -114,13 +138,19 @@ export function PasswordProvider({ children }: { children: ReactNode }) { } } - // Case 3: Has password, biometric enabled → try keychain first - // If keychain returns a stale password, the backend will reject it and the - // "Incorrect password" toast fires. The user can cancel the biometric prompt - // on the next attempt to fall through to the password dialog. - if (hasPassword && biometricEnabled && isMobile && fingerprint) { + // Case 3: Has password, biometric enabled → try keychain first (unless stale) + // If the backend rejects, ErrorContext calls notifyDecryptError() which + // marks the entry stale. Next requestPassword skips keychain → shows dialog. + if ( + hasPassword && + biometricEnabled && + isMobile && + fingerprint && + !skipKeychainRef.current.has(fingerprint) + ) { const stored = await keychainGet(keychainKey(fingerprint)); if (stored !== null) { + lastKeychainFingerprintRef.current = fingerprint; return stored; } // Fall through to password dialog if keychain retrieval fails @@ -144,8 +174,10 @@ export function PasswordProvider({ children }: { children: ReactNode }) { setOpen(false); const fingerprint = pendingRef.current?.fingerprint; - // Manual password entry: store in keychain for future biometric use + // Manual password entry: clear stale flag and store in keychain + lastKeychainFingerprintRef.current = null; if (fingerprint) { + skipKeychainRef.current.delete(fingerprint); if (biometricEnabled && isMobile) { keychainSave(keychainKey(fingerprint), password); } From 3a612e48b9d43eb3d01f6d9320693c23dfd96ef3 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 16:24:57 -0500 Subject: [PATCH 36/53] refactor: make biometric and password mutually exclusive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/contexts/PasswordContext.tsx | 180 +++---------------------------- 1 file changed, 17 insertions(+), 163 deletions(-) diff --git a/src/contexts/PasswordContext.tsx b/src/contexts/PasswordContext.tsx index 942154a40..4f065f0a6 100644 --- a/src/contexts/PasswordContext.tsx +++ b/src/contexts/PasswordContext.tsx @@ -1,123 +1,45 @@ import { PasswordDialog } from '@/components/dialogs/PasswordDialog'; import { useBiometric } from '@/hooks/useBiometric'; -import { useWallet } from '@/contexts/WalletContext'; import { platform } from '@tauri-apps/plugin-os'; import { createContext, ReactNode, useCallback, useRef, useState } from 'react'; const isMobile = platform() === 'ios' || platform() === 'android'; -// Lazy-initialized keychain module (mobile only) -let keychainPromise: Promise | null = - null; -function getKeychain() { - if (!isMobile) return null; - if (!keychainPromise) keychainPromise = import('tauri-plugin-keychain'); - return keychainPromise; -} - -async function keychainGet(key: string): Promise { - const mod = getKeychain(); - if (!mod) return null; - try { - const { getItem } = await mod; - return await getItem(key); - } catch { - return null; - } -} - -async function keychainSave(key: string, password: string): Promise { - const mod = getKeychain(); - if (!mod) return; - try { - const { saveItem } = await mod; - await saveItem(key, password); - } catch { - // Silently fail — keychain storage is best-effort - } -} - -async function keychainRemove(key: string): Promise { - const mod = getKeychain(); - if (!mod) return; - try { - const { removeItem } = await mod; - await removeItem(key); - } catch { - // Silently fail - } -} - -// Biometric caching interval (5 minutes), matching existing BiometricContext behavior +// Biometric caching interval (5 minutes) const BIOMETRIC_CACHE_MS = 5 * 60 * 1000; -// Module-level callback for cross-context communication. -// ErrorContext calls notifyDecryptError when a wrong-password error occurs. -// PasswordContext uses lastKeychainFingerprintRef to know which entry is stale. -let onDecryptErrorCallback: (() => void) | null = null; - -export function notifyDecryptError() { - onDecryptErrorCallback?.(); -} - interface PasswordRequest { resolve: (password: string | null | undefined) => void; - fingerprint: number | undefined; } export interface PasswordContextType { requestPassword: (hasPassword: boolean, fingerprint?: number) => Promise; - clearKeychainEntry: (fingerprint: number) => Promise; - updateKeychainEntry: ( - fingerprint: number, - newPassword: string, - ) => Promise; - clearAllKeychainEntries: (fingerprints: number[]) => Promise; } export const PasswordContext = createContext( undefined, ); -function keychainKey(fingerprint: number): string { - return `sage-password-${fingerprint.toString()}`; -} - export function PasswordProvider({ children }: { children: ReactNode }) { const [open, setOpen] = useState(false); const pendingRef = useRef(null); const { enabled: biometricEnabled } = useBiometric(); - const { wallet } = useWallet(); - - // Skip keychain for fingerprints where the stored password was rejected. - // Set when ErrorContext reports a decrypt error after a keychain retrieval, - // cleared by handleSubmit when the user types a correct password via dialog. - const skipKeychainRef = useRef>(new Set()); - const lastKeychainFingerprintRef = useRef(null); - // Register the module-level callback so ErrorContext can mark entries stale - onDecryptErrorCallback = () => { - const fp = lastKeychainFingerprintRef.current; - if (fp !== null) { - skipKeychainRef.current.add(fp); - lastKeychainFingerprintRef.current = null; - } - }; - - // Biometric caching for standalone gate (Case 2) + // Biometric caching for standalone gate const lastBiometricPromptRef = useRef(null); const requestPassword = useCallback( async (hasPassword: boolean, targetFingerprint?: number): Promise => { - const fingerprint = targetFingerprint ?? wallet?.fingerprint; - - // Case 1: No password, no biometric → no auth needed - if (!hasPassword && !biometricEnabled) { - return null; + // Case 1: Has password → password takes precedence, show dialog + if (hasPassword) { + return new Promise((resolve) => { + pendingRef.current = { resolve }; + setOpen(true); + }); } // Case 2: No password, biometric enabled → standalone biometric gate with 5-min cache - if (!hasPassword && biometricEnabled && isMobile) { + if (biometricEnabled && isMobile) { const now = performance.now(); if ( lastBiometricPromptRef.current !== null && @@ -138,94 +60,26 @@ export function PasswordProvider({ children }: { children: ReactNode }) { } } - // Case 3: Has password, biometric enabled → try keychain first (unless stale) - // If the backend rejects, ErrorContext calls notifyDecryptError() which - // marks the entry stale. Next requestPassword skips keychain → shows dialog. - if ( - hasPassword && - biometricEnabled && - isMobile && - fingerprint && - !skipKeychainRef.current.has(fingerprint) - ) { - const stored = await keychainGet(keychainKey(fingerprint)); - if (stored !== null) { - lastKeychainFingerprintRef.current = fingerprint; - return stored; - } - // Fall through to password dialog if keychain retrieval fails - } - - // Case 4: Has password → show dialog (fallback or no biometric) - if (hasPassword) { - return new Promise((resolve) => { - pendingRef.current = { resolve, fingerprint }; - setOpen(true); - }); - } - + // Case 3: No password, no biometric → no auth needed return null; }, - [biometricEnabled, wallet?.fingerprint], - ); - - const handleSubmit = useCallback( - (password: string) => { - setOpen(false); - const fingerprint = pendingRef.current?.fingerprint; - - // Manual password entry: clear stale flag and store in keychain - lastKeychainFingerprintRef.current = null; - if (fingerprint) { - skipKeychainRef.current.delete(fingerprint); - if (biometricEnabled && isMobile) { - keychainSave(keychainKey(fingerprint), password); - } - } - - pendingRef.current?.resolve(password); - pendingRef.current = null; - }, [biometricEnabled], ); - const handleCancel = useCallback(() => { + const handleSubmit = useCallback((password: string) => { setOpen(false); - pendingRef.current?.resolve(undefined); + pendingRef.current?.resolve(password); pendingRef.current = null; }, []); - const clearKeychainEntry = useCallback(async (fingerprint: number) => { - await keychainRemove(keychainKey(fingerprint)); + const handleCancel = useCallback(() => { + setOpen(false); + pendingRef.current?.resolve(undefined); + pendingRef.current = null; }, []); - const updateKeychainEntry = useCallback( - async (fingerprint: number, newPassword: string) => { - if (biometricEnabled && isMobile) { - await keychainSave(keychainKey(fingerprint), newPassword); - } - }, - [biometricEnabled], - ); - - const clearAllKeychainEntries = useCallback( - async (fingerprints: number[]) => { - for (const fp of fingerprints) { - await keychainRemove(keychainKey(fp)); - } - }, - [], - ); - return ( - + {children} Date: Mon, 16 Mar 2026 16:25:00 -0500 Subject: [PATCH 37/53] refactor: remove notifyDecryptError bridge from ErrorContext Co-Authored-By: Claude Sonnet 4.6 --- src/contexts/ErrorContext.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/contexts/ErrorContext.tsx b/src/contexts/ErrorContext.tsx index 688a2071f..0e2aa57cc 100644 --- a/src/contexts/ErrorContext.tsx +++ b/src/contexts/ErrorContext.tsx @@ -11,7 +11,6 @@ import { t } from '@lingui/core/macro'; import { createContext, ReactNode, useCallback, useState } from 'react'; import { toast } from 'react-toastify'; import { ErrorKind } from '../bindings'; -import { notifyDecryptError } from './PasswordContext'; export interface CustomError { kind: ErrorKind | 'walletconnect' | 'upload' | 'invalid' | 'dexie'; @@ -36,7 +35,6 @@ export function ErrorProvider({ children }: { children: ReactNode }) { if (reason.includes('decrypt')) { // Wrong password — AES decryption failed toast.error(t`Incorrect password`); - notifyDecryptError(); } else if (reason.includes('not found') || reason.includes('No secret')) { // KeyNotFound or NoSecretKey — wallet-level issue, not a transition toast.error(error.reason); From e614a55b36d781d4be64ae7405c2d4e3c88981a6 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 16:25:38 -0500 Subject: [PATCH 38/53] refactor: remove keychain lifecycle from Settings Co-Authored-By: Claude Sonnet 4.6 --- src/pages/Settings.tsx | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 24bf8f1fe..288db6fc3 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -263,7 +263,6 @@ function GlobalSettings() { const { expiry, setExpiry } = useDefaultOfferExpiry(); const { clawback, setClawback } = useDefaultClawback(); const { setFee } = useDefaultFee(); - const { clearAllKeychainEntries } = usePassword(); const { enabled: biometricEnabled, available, @@ -279,9 +278,6 @@ function GlobalSettings() { await enableIfAvailable(); } else { await disable(); - // Clear all stored passwords from keychain when biometric is disabled - const keysData = await commands.getKeys({}); - await clearAllKeychainEntries(keysData.keys.map((k) => k.fingerprint)); } } catch (error) { addError(error as CustomError); @@ -1036,11 +1032,7 @@ function RpcSettings() { function WalletSettings({ fingerprint }: { fingerprint: number }) { const { addError } = useErrors(); - const { - requestPassword, - clearKeychainEntry, - updateKeychainEntry, - } = usePassword(); + const { requestPassword } = usePassword(); const walletState = useWalletState(); @@ -1250,14 +1242,6 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { setPasswordDialogOpen(false); - // Update keychain entries based on the operation - if (passwordDialogMode === 'change') { - await updateKeychainEntry(fingerprint, newPassword); - } else if (passwordDialogMode === 'remove') { - await clearKeychainEntry(fingerprint); - } - // 'set' mode: no keychain action needed — store-on-first-use will handle it - // Refresh key info to update has_password const data = await commands.getKey({ fingerprint }); setKey(data.key); From a3f1da8361af44ac0bbf63b140f2d85dc2db3784 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 16:25:50 -0500 Subject: [PATCH 39/53] refactor: remove keychain cleanup from wallet deletion Co-Authored-By: Claude Sonnet 4.6 --- src/components/WalletCard.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/WalletCard.tsx b/src/components/WalletCard.tsx index 9a2b74ef4..201fdd80c 100644 --- a/src/components/WalletCard.tsx +++ b/src/components/WalletCard.tsx @@ -66,7 +66,7 @@ export function WalletCard({ const navigate = useNavigate(); const { addError } = useErrors(); const { setWallet } = useWallet(); - const { requestPassword, clearKeychainEntry } = usePassword(); + const { requestPassword } = usePassword(); const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isDetailsOpen, setIsDetailsOpen] = useState(false); @@ -86,8 +86,7 @@ export function WalletCard({ } await commands .deleteKey({ fingerprint: info.fingerprint }) - .then(async () => { - await clearKeychainEntry(info.fingerprint); + .then(() => { setKeys(keys.filter((key) => key.fingerprint !== info.fingerprint)); }) .catch(addError); From a2a6e105ebdd22545d26988b6a74e7420fd58b38 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 17:09:13 -0500 Subject: [PATCH 40/53] chore: remove tauri-plugin-keychain dependency Co-Authored-By: Claude Opus 4.6 --- package.json | 1 - pnpm-lock.yaml | 120 +++-------------------------- src-tauri/Cargo.toml | 1 - src-tauri/capabilities/mobile.json | 3 +- src-tauri/src/lib.rs | 3 +- 5 files changed, 11 insertions(+), 117 deletions(-) diff --git a/package.json b/package.json index 89eccfcbd..7d0f9fc84 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,6 @@ "spoiled": "^0.4.0", "tailwind-merge": "^2.6.1", "tailwindcss-animate": "^1.0.7", - "tauri-plugin-keychain": "^2.0.1", "tauri-plugin-safe-area-insets": "^0.1.0", "tauri-plugin-sage": "file:tauri-plugin-sage", "theme-o-rama": "0.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fccac259..21d66fca9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,9 +179,6 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.19(yaml@2.5.1)) - tauri-plugin-keychain: - specifier: ^2.0.1 - version: 2.0.1(rollup@4.57.1) tauri-plugin-safe-area-insets: specifier: ^0.1.0 version: 0.1.0 @@ -1402,33 +1399,6 @@ packages: '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - '@rollup/plugin-node-resolve@15.3.1': - resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^2.78.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - - '@rollup/plugin-terser@0.4.4': - resolution: {integrity: sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - - '@rollup/pluginutils@5.3.0': - resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -1789,9 +1759,6 @@ packages: '@types/react@18.3.28': resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} - '@types/resolve@1.20.2': - resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} - '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -2316,10 +2283,6 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -2488,9 +2451,6 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} - estree-walker@2.0.2: - resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2779,9 +2739,6 @@ packages: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} - is-module@1.0.0: - resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} - is-negative-zero@2.0.3: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} @@ -3271,9 +3228,6 @@ packages: radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} - randombytes@2.1.0: - resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} - react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -3446,9 +3400,6 @@ packages: engines: {node: '>=10'} hasBin: true - serialize-javascript@6.0.2: - resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} - set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3499,10 +3450,6 @@ packages: slow-redact@0.3.2: resolution: {integrity: sha512-MseHyi2+E/hBRqdOi5COy6wZ7j7DxXRz9NkseavNYSvvWC06D8a5cidVZX3tcG5eCW3NIyVU4zT63hw0Q486jw==} - smob@1.6.1: - resolution: {integrity: sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==} - engines: {node: '>=20.0.0'} - sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} @@ -3591,9 +3538,6 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - tauri-plugin-keychain@2.0.1: - resolution: {integrity: sha512-Avbg7XEEl8AVf04wEBhtoKV6Iv7k5g4NLKY0zbxGSDilgnCMszjc1oVgLqNMTNiYLJ6YUj+Pn/IyE5F/olEj1w==} - tauri-plugin-safe-area-insets@0.1.0: resolution: {integrity: sha512-NhtxSQdU7+laTJiO4WZFlTB1CBzRlNE5EuHflGAGXS55fiwOkkaWztfNishFQO+eZtb9i5jLHX3Yho48lnqcRQ==} @@ -4338,6 +4282,7 @@ snapshots: dependencies: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 + optional: true '@jridgewell/sourcemap-codec@1.5.5': {} @@ -4976,32 +4921,6 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.27': {} - '@rollup/plugin-node-resolve@15.3.1(rollup@4.57.1)': - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - '@types/resolve': 1.20.2 - deepmerge: 4.3.1 - is-module: 1.0.0 - resolve: 1.22.11 - optionalDependencies: - rollup: 4.57.1 - - '@rollup/plugin-terser@0.4.4(rollup@4.57.1)': - dependencies: - serialize-javascript: 6.0.2 - smob: 1.6.1 - terser: 5.46.0 - optionalDependencies: - rollup: 4.57.1 - - '@rollup/pluginutils@5.3.0(rollup@4.57.1)': - dependencies: - '@types/estree': 1.0.8 - estree-walker: 2.0.2 - picomatch: 4.0.3 - optionalDependencies: - rollup: 4.57.1 - '@rollup/rollup-android-arm-eabi@4.57.1': optional: true @@ -5269,8 +5188,6 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 - '@types/resolve@1.20.2': {} - '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -5892,7 +5809,8 @@ snapshots: dependencies: base-x: 5.0.1 - buffer-from@1.1.2: {} + buffer-from@1.1.2: + optional: true buffer@5.7.1: dependencies: @@ -5999,7 +5917,8 @@ snapshots: commander@10.0.1: {} - commander@2.20.3: {} + commander@2.20.3: + optional: true commander@4.1.1: {} @@ -6067,8 +5986,6 @@ snapshots: deep-is@0.1.4: {} - deepmerge@4.3.1: {} - defaults@1.0.4: dependencies: clone: 1.0.4 @@ -6398,8 +6315,6 @@ snapshots: estraverse@5.3.0: {} - estree-walker@2.0.2: {} - esutils@2.0.3: {} eventemitter3@5.0.1: {} @@ -6698,8 +6613,6 @@ snapshots: is-map@2.0.3: {} - is-module@1.0.0: {} - is-negative-zero@2.0.3: {} is-number-object@1.1.1: @@ -7156,10 +7069,6 @@ snapshots: radix3@1.1.2: {} - randombytes@2.1.0: - dependencies: - safe-buffer: 5.2.1 - react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -7361,10 +7270,6 @@ snapshots: semver@7.7.4: {} - serialize-javascript@6.0.2: - dependencies: - randombytes: 2.1.0 - set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -7429,8 +7334,6 @@ snapshots: slow-redact@0.3.2: {} - smob@1.6.1: {} - sonic-boom@4.2.0: dependencies: atomic-sleep: 1.0.0 @@ -7441,8 +7344,10 @@ snapshots: dependencies: buffer-from: 1.1.2 source-map: 0.6.1 + optional: true - source-map@0.6.1: {} + source-map@0.6.1: + optional: true source-map@0.7.6: {} @@ -7563,14 +7468,6 @@ snapshots: - tsx - yaml - tauri-plugin-keychain@2.0.1(rollup@4.57.1): - dependencies: - '@rollup/plugin-node-resolve': 15.3.1(rollup@4.57.1) - '@rollup/plugin-terser': 0.4.4(rollup@4.57.1) - '@tauri-apps/api': 2.10.1 - transitivePeerDependencies: - - rollup - tauri-plugin-safe-area-insets@0.1.0: dependencies: '@tauri-apps/api': 2.10.1 @@ -7585,6 +7482,7 @@ snapshots: acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 + optional: true text-table@0.2.0: {} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b5370b753..9412c3830 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -52,7 +52,6 @@ tauri-plugin-biometric = { workspace = true } tauri-plugin-barcode-scanner = { workspace = true } tauri-plugin-safe-area-insets = { workspace = true } tauri-plugin-sage = { workspace = true } -tauri-plugin-keychain = "2" [build-dependencies] tauri-build = { workspace = true, features = [] } diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json index 4ddae1bc5..07f0f17b4 100644 --- a/src-tauri/capabilities/mobile.json +++ b/src-tauri/capabilities/mobile.json @@ -8,7 +8,6 @@ "barcode-scanner:default", "biometric:default", "sage:default", - "sharesheet:allow-share-text", - "keychain:default" + "sharesheet:allow-share-text" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fece3964f..e37d25550 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -174,8 +174,7 @@ pub fn run() { .plugin(tauri_plugin_safe_area_insets::init()) .plugin(tauri_plugin_biometric::init()) .plugin(tauri_plugin_sharesheet::init()) - .plugin(tauri_plugin_sage::init()) - .plugin(tauri_plugin_keychain::init()); + .plugin(tauri_plugin_sage::init()); } tauri_builder From ed297463498cf8be5c37275c464ee52ddc018bdc Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 17:14:40 -0500 Subject: [PATCH 41/53] refactor: remove unused fingerprint parameter from requestPassword Co-Authored-By: Claude Opus 4.6 --- src/components/WalletCard.tsx | 4 ++-- src/contexts/PasswordContext.tsx | 4 ++-- src/walletconnect/handler.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/WalletCard.tsx b/src/components/WalletCard.tsx index 201fdd80c..72d77916b 100644 --- a/src/components/WalletCard.tsx +++ b/src/components/WalletCard.tsx @@ -79,7 +79,7 @@ export function WalletCard({ const { currentTheme } = useTheme(); const deleteSelf = async () => { - const password = await requestPassword(info.has_password, info.fingerprint); + const password = await requestPassword(info.has_password); if (password === undefined) { setIsDeleteOpen(false); return; @@ -176,7 +176,7 @@ export function WalletCard({ return; } - const password = await requestPassword(info.has_password, info.fingerprint); + const password = await requestPassword(info.has_password); if (password === undefined) { setIsDetailsOpen(false); return; diff --git a/src/contexts/PasswordContext.tsx b/src/contexts/PasswordContext.tsx index 4f065f0a6..41c8d3722 100644 --- a/src/contexts/PasswordContext.tsx +++ b/src/contexts/PasswordContext.tsx @@ -13,7 +13,7 @@ interface PasswordRequest { } export interface PasswordContextType { - requestPassword: (hasPassword: boolean, fingerprint?: number) => Promise; + requestPassword: (hasPassword: boolean) => Promise; } export const PasswordContext = createContext( @@ -29,7 +29,7 @@ export function PasswordProvider({ children }: { children: ReactNode }) { const lastBiometricPromptRef = useRef(null); const requestPassword = useCallback( - async (hasPassword: boolean, targetFingerprint?: number): Promise => { + async (hasPassword: boolean): Promise => { // Case 1: Has password → password takes precedence, show dialog if (hasPassword) { return new Promise((resolve) => { diff --git a/src/walletconnect/handler.ts b/src/walletconnect/handler.ts index 1969e65c0..1f86bfc49 100644 --- a/src/walletconnect/handler.ts +++ b/src/walletconnect/handler.ts @@ -24,7 +24,7 @@ import { } from './commands/offers'; export interface HandlerContext { - requestPassword: (hasPassword: boolean, fingerprint?: number) => Promise; + requestPassword: (hasPassword: boolean) => Promise; hasPassword: boolean; } From d0272426d5ffa8d701307eebe1b750e272e9e228 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 17:14:57 -0500 Subject: [PATCH 42/53] =?UTF-8?q?docs:=20update=20spec=20=E2=80=94=20biome?= =?UTF-8?q?tric=20and=20password=20are=20mutually=20exclusive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- ...6-biometric-password-mutual-exclusivity.md | 335 ++++++++++++++++++ .../2026-03-15-password-protection-design.md | 86 ++--- 2 files changed, 360 insertions(+), 61 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-16-biometric-password-mutual-exclusivity.md diff --git a/docs/superpowers/plans/2026-03-16-biometric-password-mutual-exclusivity.md b/docs/superpowers/plans/2026-03-16-biometric-password-mutual-exclusivity.md new file mode 100644 index 000000000..ee60377fe --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-biometric-password-mutual-exclusivity.md @@ -0,0 +1,335 @@ +# Biometric-Password Mutual Exclusivity Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make biometric and password mutually exclusive — password takes precedence, removing all keychain password storage infrastructure. + +**Architecture:** Biometric is a standalone gate for no-password wallets. Password wallets always show the password dialog. The two never interact. Remove `tauri-plugin-keychain`, the `notifyDecryptError` bridge, all skip/stale tracking refs, and all keychain lifecycle methods. + +**Tech Stack:** TypeScript/React (Tauri v2), Rust (Cargo dependencies) + +--- + +## Chunk 1: Remove keychain infrastructure and simplify PasswordContext + +### Task 1: Simplify PasswordContext — remove all keychain code + +**Files:** +- Modify: `src/contexts/PasswordContext.tsx` + +- [ ] **Step 1: Remove keychain imports and helpers** + +Remove the entire keychain section (lines 9–49): the lazy-loaded `keychainPromise`, `getKeychain()`, `keychainGet()`, `keychainSave()`, `keychainRemove()` functions. + +Also remove the `keychainKey()` helper (line 82–84). + +- [ ] **Step 2: Remove the notifyDecryptError bridge** + +Remove the module-level callback infrastructure (lines 54–61): `onDecryptErrorCallback`, `notifyDecryptError()` export. + +- [ ] **Step 3: Remove keychain lifecycle methods from context type** + +Update `PasswordContextType` to remove `clearKeychainEntry`, `updateKeychainEntry`, and `clearAllKeychainEntries`. The interface should only have: + +```typescript +export interface PasswordContextType { + requestPassword: (hasPassword: boolean, fingerprint?: number) => Promise; +} +``` + +- [ ] **Step 4: Remove stale-tracking refs and callback registration from provider** + +Inside `PasswordProvider`, remove: +- `skipKeychainRef` +- `lastKeychainFingerprintRef` +- The `onDecryptErrorCallback = () => { ... }` registration block + +- [ ] **Step 5: Simplify the requestPassword decision tree** + +Replace the 4-case decision tree with 3 mutually exclusive cases: + +```typescript +const requestPassword = useCallback( + async (hasPassword: boolean, targetFingerprint?: number): Promise => { + // Case 1: Has password → password takes precedence, show dialog + if (hasPassword) { + return new Promise((resolve) => { + pendingRef.current = { resolve }; + setOpen(true); + }); + } + + // Case 2: No password, biometric enabled → standalone biometric gate with 5-min cache + if (biometricEnabled && isMobile) { + const now = performance.now(); + if ( + lastBiometricPromptRef.current !== null && + now - lastBiometricPromptRef.current < BIOMETRIC_CACHE_MS + ) { + return null; // Within cache window, skip prompt + } + + try { + const { authenticate } = await import('@tauri-apps/plugin-biometric'); + await authenticate('Authenticate to continue', { + allowDeviceCredential: false, + }); + lastBiometricPromptRef.current = now; + return null; + } catch { + return undefined; // biometric failed/cancelled + } + } + + // Case 3: No password, no biometric → no auth needed + return null; + }, + [biometricEnabled], +); +``` + +Note: `targetFingerprint` parameter is no longer used (was only needed for keychain lookup) but keep it in the signature for now since call sites pass it. It's harmless and avoids touching all call sites. + +- [ ] **Step 6: Simplify handleSubmit and PasswordRequest — remove keychain save logic** + +These changes must be done together with Step 5 to avoid transient type errors. + +Replace handleSubmit — note the dependency array changes from `[biometricEnabled]` to `[]` since keychain save is removed: + +```typescript +const handleSubmit = useCallback((password: string) => { + setOpen(false); + pendingRef.current?.resolve(password); + pendingRef.current = null; +}, []); +``` + +Remove the `fingerprint` field from the `PasswordRequest` interface: + +```typescript +interface PasswordRequest { + resolve: (password: string | null | undefined) => void; +} +``` + +- [ ] **Step 7: Remove lifecycle methods from provider** + +Remove the `clearKeychainEntry`, `updateKeychainEntry`, and `clearAllKeychainEntries` useCallback definitions. + +Update the context value to only provide `requestPassword`: + +```typescript + +``` + +- [ ] **Step 8: Remove useWallet import and usage** + +Remove `import { useWallet } from '@/contexts/WalletContext';` (line 3) and `const { wallet } = useWallet();` (line 90) from `PasswordProvider`. These were only used for `wallet?.fingerprint` as the keychain default fingerprint, which is no longer needed. + +Keep `useBiometric` — it IS still needed for `biometricEnabled` in Case 2. + +- [ ] **Step 9: Commit** + +```bash +git add src/contexts/PasswordContext.tsx +git commit -m "refactor: make biometric and password mutually exclusive + +Remove all keychain password storage — password wallets always use the +dialog, biometric is a standalone gate for no-password wallets only." +``` + +### Task 2: Remove notifyDecryptError from ErrorContext + +**Files:** +- Modify: `src/contexts/ErrorContext.tsx` + +- [ ] **Step 1: Remove the notifyDecryptError import and call** + +Remove the import line: +```typescript +import { notifyDecryptError } from './PasswordContext'; +``` + +Remove the `notifyDecryptError()` call from the `addError` handler. Keep the toast: +```typescript +if (reason.includes('decrypt')) { + toast.error(t`Incorrect password`); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/contexts/ErrorContext.tsx +git commit -m "refactor: remove notifyDecryptError bridge from ErrorContext" +``` + +### Task 3: Remove keychain usage from Settings + +**Files:** +- Modify: `src/pages/Settings.tsx` + +- [ ] **Step 1: Remove keychain calls from GlobalSettings toggleBiometric** + +In `GlobalSettings`, remove `clearAllKeychainEntries` from the `usePassword()` destructure. Simplify `toggleBiometric` to remove the keychain cleanup: + +```typescript +const toggleBiometric = async (value: boolean) => { + try { + if (value) { + await enableIfAvailable(); + } else { + await disable(); + } + } catch (error) { + addError(error as CustomError); + } +}; +``` + +- [ ] **Step 2: Remove keychain calls from WalletSettings password handlers** + +In `WalletSettings`, remove `clearKeychainEntry` and `updateKeychainEntry` from the `usePassword()` destructure. + +Remove the keychain update block in `handlePasswordSubmit` (the section after `setPasswordDialogOpen(false)` that calls `updateKeychainEntry` / `clearKeychainEntry`). + +- [ ] **Step 3: Commit** + +```bash +git add src/pages/Settings.tsx +git commit -m "refactor: remove keychain lifecycle from Settings" +``` + +### Task 4: Remove keychain usage from WalletCard + +**Files:** +- Modify: `src/components/WalletCard.tsx` + +- [ ] **Step 1: Remove clearKeychainEntry from WalletCard** + +Change the `usePassword()` destructure to only get `requestPassword`: +```typescript +const { requestPassword } = usePassword(); +``` + +In `deleteSelf`, remove the `clearKeychainEntry` call after successful deletion: +```typescript +const deleteSelf = async () => { + const password = await requestPassword(info.has_password, info.fingerprint); + if (password === undefined) { + setIsDeleteOpen(false); + return; + } + await commands + .deleteKey({ fingerprint: info.fingerprint }) + .then(async () => { + setKeys(keys.filter((key) => key.fingerprint !== info.fingerprint)); + }) + .catch(addError); + setIsDeleteOpen(false); +}; +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/components/WalletCard.tsx +git commit -m "refactor: remove keychain cleanup from wallet deletion" +``` + +### Task 5: Remove usePassword hook's keychain exports + +**Files:** +- Modify: `src/hooks/usePassword.ts` + +- [ ] **Step 1: Verify usePassword.ts needs no changes** + +`usePassword.ts` is a thin wrapper that returns the full context. Since `PasswordContextType` was already simplified in Task 1, this file should work as-is with no changes needed. + +- [ ] **Step 2: Verify no other files reference removed methods** + +Run: `grep -r 'clearKeychainEntry\|updateKeychainEntry\|clearAllKeychainEntries\|notifyDecryptError' src/` + +Expected: No matches. + +## Chunk 2: Remove tauri-plugin-keychain dependency + +### Task 6: Remove tauri-plugin-keychain from Rust dependencies + +**Files:** +- Modify: `src-tauri/Cargo.toml` — remove `tauri-plugin-keychain = "2"` line +- Modify: `src-tauri/src/lib.rs` — remove `.plugin(tauri_plugin_keychain::init())` line +- Modify: `src-tauri/capabilities/mobile.json` — remove `"keychain:default"` from permissions array + +- [ ] **Step 1: Remove from Cargo.toml** + +Remove the line `tauri-plugin-keychain = "2"` from `[dependencies]`. + +- [ ] **Step 2: Remove plugin init from lib.rs** + +Remove `.plugin(tauri_plugin_keychain::init())` from the mobile builder chain. + +- [ ] **Step 3: Remove capability from mobile.json** + +Remove `"keychain:default"` from the permissions array (and the trailing comma from the previous entry). + +- [ ] **Step 4: Remove from package.json** + +Remove `"tauri-plugin-keychain": "^2.0.1"` from dependencies. + +- [ ] **Step 5: Update lockfiles** + +Run `pnpm install` to update the JS lockfile. Then run `cargo update` (from `src-tauri/`) to remove the keychain crate from `Cargo.lock`. + +**Important:** All JS-side keychain usages (Tasks 1–5) must be completed before this step — otherwise `pnpm install` will remove the package while dynamic imports still reference it. + +- [ ] **Step 6: Commit** + +```bash +git add src-tauri/Cargo.toml src-tauri/src/lib.rs src-tauri/capabilities/mobile.json package.json pnpm-lock.yaml Cargo.lock +git commit -m "chore: remove tauri-plugin-keychain dependency" +``` + +## Chunk 3: Update spec + +### Task 7: Update the design spec + +**Files:** +- Modify: `docs/superpowers/specs/2026-03-15-password-protection-design.md` + +- [ ] **Step 1: Update the spec** + +Key changes to the spec: +1. **Overview** — Replace "Optionally, users can enable biometric unlock... as a convenience layer that stores the password in the OS keychain" with language about mutual exclusivity +2. **Biometric-Password Bridge section** — Replace entirely. Remove keychain plugin references, store-on-first-use, retrieval flow steps 4-6. Replace with simple mutual exclusivity rule +3. **Decision tree** — Simplify to 3 cases (password→dialog, no password+biometric→biometric gate, no password+no biometric→no auth) +4. **Keychain lifecycle table** — Remove entirely +5. **Settings UI changes** — Remove keychain cleanup on toggle disable +6. **Design decisions** — Update "Graceful degradation" and remove keychain-related decisions +7. **Error Handling** — Remove "Stale keychain entry" and "Device restore" sections +8. **New dependency section** — Remove `tauri-plugin-keychain` references +9. **What's NOT Changing** — Update to note biometric remains as standalone gate only + +- [ ] **Step 2: Commit** + +```bash +git add docs/superpowers/specs/2026-03-15-password-protection-design.md +git commit -m "docs: update spec — biometric and password are mutually exclusive" +``` + +### Task 8: Verify build + +- [ ] **Step 1: Run TypeScript type check** + +Run: `pnpm tsc --noEmit` +Expected: No errors. + +- [ ] **Step 2: Run linter** + +Run: `pnpm lint` +Expected: No errors. + +- [ ] **Step 3: Run Rust check** + +Run: `cargo check` (from `src-tauri/`) +Expected: No errors. diff --git a/docs/superpowers/specs/2026-03-15-password-protection-design.md b/docs/superpowers/specs/2026-03-15-password-protection-design.md index 0cb8f64fa..a3fbb2913 100644 --- a/docs/superpowers/specs/2026-03-15-password-protection-design.md +++ b/docs/superpowers/specs/2026-03-15-password-protection-design.md @@ -2,18 +2,18 @@ **Issue:** [xch-dev/sage#206](https://github.com/xch-dev/sage/issues/206) **Date:** 2026-03-15 -**Status:** Implemented (backend + frontend password); In Progress (biometric-password bridge) +**Status:** Implemented ## Overview -Add opt-in password protection to Sage wallet, requiring authentication for three categories of sensitive operations: displaying secrets, signing transactions/offers, and generating hardened keys. Optionally, users can enable biometric unlock (Touch ID, Face ID, Windows Hello) as a convenience layer that stores the password in the OS keychain. +Add opt-in password protection to Sage wallet, requiring authentication for three categories of sensitive operations: displaying secrets, signing transactions/offers, and generating hardened keys. Biometric unlock (Touch ID, Face ID) is available as a standalone gate for wallets without passwords. Biometric and password are mutually exclusive — password takes precedence. ## Design Decisions - **Per-operation authentication** — every protected operation prompts for the password. No session caching. - **Opt-in** — existing wallets continue working without a password. Users can enable protection via "Set Password." - **Per-key passwords** — each key in the keychain has its own password (or no password). This follows from the existing data model where each `KeyData::Secret` has its own `Encrypted` struct with its own salt. The frontend should use the active wallet's fingerprint to determine which key's password to prompt for. -- **Biometric included in design** — designed as a frontend-only convenience layer on top of the password system. +- **Biometric is mutually exclusive with password** — biometric is a standalone gate for no-password wallets. If a wallet has a password, the password dialog is always shown regardless of biometric settings. The two never interact. ## Architecture @@ -40,31 +40,17 @@ Add a `has_password: bool` field to the `KeyInfo` struct returned by `get_key()` Preferred approach: add `password_hint: Option` or a simple `password_protected: bool` to `KeyData::Secret`. This avoids trial decryption and is serialized into `keys.bin`. Set to `true` when a non-empty password is used at encryption time. Exposed via `KeyInfo` to the frontend. -### Biometric-Password Bridge (Mobile) +### Biometric Gate (Mobile) -Biometric is a frontend-only concern. The backend never knows whether a password came from typing or biometric retrieval. +Biometric is a frontend-only concern, mutually exclusive with password. It serves as a standalone gate for wallets that do not have a password set. -**Plugin:** `tauri-plugin-keychain` — wraps iOS Keychain and Android AccountManager. Key-value API: `saveItem(key, password)`, `getItem(key)`, `removeItem(key)`. Supports multiple entries per app (one per wallet). +**Global setting:** Biometric unlock is a single global toggle (the existing `useLocalStorage('biometric', false)` flag). It is only visible on mobile when biometric hardware is available and enrolled. -**Keychain key format:** `sage-password-{fingerprint}` — one entry per wallet. +**Mutual exclusivity rule:** If a wallet has a password, the password dialog is always shown — biometric is irrelevant. Biometric only applies when `hasPassword` is false. -**Global setting:** Biometric unlock is a single global toggle (the existing `useLocalStorage('biometric', false)` flag). When enabled, it applies to all wallets that have a password. No per-wallet configuration needed. +**No keychain storage:** Passwords are never stored on device. Each password-protected operation prompts via the password dialog. There is no keychain bridge between biometric and password. -**Store-on-first-use:** Passwords are not stored in the keychain at enable time. Instead, the first time a password-protected operation occurs for a given wallet after biometric is enabled, the user types their password normally. On successful backend operation, the password is stored in the keychain. Subsequent operations for that wallet use biometric retrieval. - -**Retrieval flow:** - -1. `requestPassword(hasPassword)` is called at a protected operation call site -2. If `hasPassword` is false and biometric not enabled → return `null` (no auth needed) -3. If `hasPassword` is false and biometric enabled → biometric prompt (standalone gate), return `null` on success, `undefined` on failure -4. If `hasPassword` is true and biometric enabled → attempt `retrieve(sage-password-{fingerprint})` from OS keychain (triggers biometric prompt) -5. If retrieval succeeds → return the stored password (no dialog shown) -6. If retrieval fails (no entry, user cancels, enrollment changed, lockout) → fall back to password dialog -7. If `hasPassword` is true and biometric not enabled → show password dialog - -**The OS keychain uses the platform's secure element** (Secure Enclave on iOS, TEE on Android) without Sage needing to manage SE keys directly. - -> **TODO:** Add macOS Touch ID + Keychain and Windows Hello + Credential Manager support for desktop platforms. +**Biometric caching:** Standalone biometric prompts use a 5-minute cache (`performance.now()` monotonic clock) to avoid prompting repeatedly for rapid successive operations. ## Protected Operations @@ -80,7 +66,7 @@ There are 7 code points where `b""` is passed to `extract_secrets` or `add_mnemo The central signing path is: -``` +```text endpoint method → transact() / transact_with() → sign() → extract_secrets() ``` @@ -197,33 +183,32 @@ New `change_password()` endpoint. #### PasswordContext (`src/contexts/PasswordContext.tsx`) -A React context provider that serves as the **single entry point for all operation authentication** — password, biometric, or both. Provides: +A React context provider that serves as the **single entry point for all operation authentication** — password or biometric (never both). Provides: ```typescript -requestPassword(hasPassword: boolean): Promise +requestPassword(hasPassword: boolean, fingerprint?: number): Promise ``` **Return values:** -- `string` → use this password (typed by user or retrieved from keychain via biometric) +- `string` → use this password (typed by user via dialog) - `null` → no password needed, all auth passed (biometric gate passed or no auth required) - `undefined` → auth cancelled or failed, abort the operation -**Internal decision tree:** +**Internal decision tree (mutually exclusive):** ```text +hasPassword=true → show password dialog (password always takes precedence) +hasPassword=false, biometric enabled → biometric prompt with 5-min cache, return null on success, undefined on fail hasPassword=false, biometric not enabled → return null (no auth needed) -hasPassword=false, biometric enabled → biometric prompt, return null on success, undefined on fail -hasPassword=true, biometric enabled → try keychain retrieval, fall back to dialog on failure -hasPassword=true, biometric not enabled → show password dialog cancelled at any point → return undefined ``` -On desktop (no keychain available), the biometric path is skipped — behaves as if biometric is not enabled. +On desktop (no biometric available), the biometric path is skipped — behaves as if biometric is not enabled. Uses a `useRef`-based pending promise pattern to bridge the dialog UI with the async call site. -**Provider placement:** Inside `I18nProvider` and `WalletProvider` (required because `PasswordProvider` reads `useWallet()` for the fingerprint and `useBiometric()` for the enabled state). Wraps `WalletConnectProvider` and all downstream providers, so `usePassword()` is available everywhere. +**Provider placement:** Inside `I18nProvider` and `WalletProvider`. Wraps `WalletConnectProvider` and all downstream providers, so `usePassword()` is available everywhere. Provider tree: `BiometricProvider` → `I18nProvider` → `WalletProvider` → `PasswordProvider` → `PeerProvider` → `WalletConnectProvider` → `PriceProvider` → `RouterProvider` @@ -242,7 +227,7 @@ Thin wrapper around `PasswordContext` with a guard that throws if used outside ` #### Call site pattern -Every protected operation follows the same unified pattern — a single call that handles password, biometric, or both: +Every protected operation follows the same unified pattern — a single call that handles password or biometric: ```typescript const password = await requestPassword(wallet?.has_password ?? false); @@ -281,43 +266,26 @@ All three operations call `commands.changePassword()` with appropriate `old_pass Wrong password errors (`ErrorKind::Unauthorized` with reason containing "decrypt") are surfaced as a toast notification "Incorrect password" via the global `ErrorContext.addError` handler. This provides consistent feedback across all password-protected operations without requiring per-call-site error handling. Other unauthorized errors (e.g., wallet transition race conditions) continue to be silently discarded. -#### Keychain lifecycle - -| Event | Action | -| -------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| Password-protected operation succeeds (first time) | Store password in keychain for that wallet's fingerprint | -| Password changed in Settings | Update keychain entry with new password | -| Password removed in Settings | Delete keychain entry; biometric reverts to standalone gate mode | -| Biometric toggle disabled | Delete all `sage-password-*` keychain entries | -| Wallet deleted | Delete that wallet's keychain entry | -| OS biometric enrollment changes | OS invalidates keychain items; next retrieval fails → dialog fallback → re-stored on success | - #### Settings UI changes The biometric toggle remains in the **Preferences** section of Global Settings (not per-wallet Security) because it is a global setting that applies to all wallets. It is only visible on mobile when biometric hardware is available and enrolled. -When the toggle is disabled, all `sage-password-*` keychain entries are cleared. - #### Design decisions - **No password at import time** — users set a password later via Settings. Simpler UX, same security outcome. -- **No session caching** — every protected operation prompts independently. The OS handles its own biometric caching at the system level. Sage does not cache passwords in memory after biometric retrieval. +- **No session caching** — every protected operation prompts independently. Passwords are never stored on device. - **Single dialog instance** — `PasswordProvider` renders one `PasswordDialog` at the provider level, avoiding duplicate dialog instances across components. - **Unified auth entry point** — `requestPassword` subsumes the standalone `promptIfEnabled()` biometric check. Call sites make one auth call instead of two. The `BiometricContext` continues to exist for state management (`enabled`, `available`) but `promptIfEnabled()` is no longer called directly at operation sites. -- **Global biometric setting** — one toggle applies to all wallets. Per-wallet passwords are stored in the OS keychain on first successful use. No per-wallet biometric configuration needed. -- **Graceful degradation** — keychain retrieval failure (enrollment change, lockout, corruption, device restore) always falls back to the password dialog. The biometric path is an optimistic fast path, never a hard requirement. +- **Mutual exclusivity** — biometric and password are mutually exclusive. Password takes precedence. If a wallet has a password, the password dialog is always shown regardless of biometric settings. Biometric is a standalone gate for no-password wallets only. +- **Global biometric setting** — one toggle applies to all wallets. No per-wallet biometric configuration needed. ## Error Handling - **Wrong password**: AES-GCM authentication fails → `KeychainError::Decrypt` → frontend shows "Incorrect password" toast. - **Public-key-only wallets**: `extract_secrets` returns `(None, None)` — no prompt needed. Frontend checks `has_secret_key` and `has_password` to decide. - **Lost password**: No recovery. AES-256-GCM + Argon2 is irreversible without the password. UI warns at password-set time. Matches industry standard (Chia reference wallet, MetaMask). -- **Biometric enrollment changes**: OS invalidates keychain items when biometric enrollment changes. Next retrieval fails → falls back to password dialog → password re-stored in keychain on successful operation. -- **Biometric lockout**: After too many failed OS-level biometric attempts, the OS locks biometric temporarily. `requestPassword` detects retrieval failure and goes straight to the password dialog. -- **Stale keychain entry**: If the user changed their password but the keychain has the old one, the backend rejects with "Could not decrypt." The "Incorrect password" toast fires, the stale entry is cleared, and next attempt falls back to the dialog. -- **Device restore**: Keychain items may not survive a device restore depending on OS backup settings and access control flags. Same handling: retrieval fails → dialog fallback → re-stored on success. +- **Biometric lockout**: After too many failed OS-level biometric attempts, the OS locks biometric temporarily. Only affects no-password wallets using the biometric gate. - **App backgrounded during biometric**: OS may cancel the biometric prompt. Treated as cancellation → `requestPassword` returns `undefined`. -- **WalletConnect while backgrounded**: Biometric prompts cannot appear while backgrounded on iOS. `requestPassword` skips biometric and falls back to dialog (which will appear when the app returns to foreground). ## Migration @@ -325,15 +293,11 @@ Existing keys encrypted with `b""` continue to work — the user simply never ge The `keys.bin` format change (adding `password_protected` to `KeyData::Secret`) requires a deserialization fallback: try new format first, fall back to old format with `password_protected: false`. On next save, the file is written in the new format. -### New dependency (biometric bridge) - -- `tauri-plugin-keychain` — Rust crate (`tauri-plugin-keychain = "2"`) + JS bindings (`tauri-plugin-keychain`) for iOS Keychain / Android AccountManager access. Mobile-only. Added to `src-tauri/Cargo.toml` (mobile target) and `package.json`. -- Mobile capabilities (`src-tauri/capabilities/mobile.json`) — add `keychain:default` permission. - ## What's NOT Changing - `encrypt.rs` — AES-256-GCM + Argon2 implementation is already correct - `keys.bin` encryption scheme — same Argon2 + AES-256-GCM, just with real passwords instead of `b""` - Any sync, peer, or database logic - `SendTransactionImmediately`, `SubmitTransaction`, `ViewCoinSpends` — these operate on pre-signed spend bundles or read-only data and do not call `extract_secrets()` or `sign()` -- Backend — no backend changes for the biometric bridge. It's entirely frontend. +- Backend — no backend changes for the biometric gate. It's entirely frontend. +- Biometric — remains as a standalone gate for no-password wallets. `BiometricContext` provides `enabled`/`available` state; `PasswordContext` handles the actual biometric prompt internally. From 54fd34cb0503b4a014f2aa4e41c451cfaf24bd32 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 17:15:07 -0500 Subject: [PATCH 43/53] chore: update Cargo.lock after removing keychain dependency Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e5b379b0..f4949c3fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6410,7 +6410,6 @@ dependencies = [ "tauri-plugin-clipboard-manager", "tauri-plugin-dialog", "tauri-plugin-fs", - "tauri-plugin-keychain", "tauri-plugin-opener", "tauri-plugin-os", "tauri-plugin-safe-area-insets", @@ -7592,18 +7591,6 @@ dependencies = [ "url", ] -[[package]] -name = "tauri-plugin-keychain" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f23e3f77ea4c7218b7268090b76f822efe729463570d39396c8f80dfc49d6cb6" -dependencies = [ - "serde", - "tauri", - "tauri-plugin", - "thiserror 1.0.69", -] - [[package]] name = "tauri-plugin-opener" version = "2.5.3" From d64ebd633594f3f681d72461e8ad5350e3e3ef4f Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 17:18:59 -0500 Subject: [PATCH 44/53] prettier --- ...6-biometric-password-mutual-exclusivity.md | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-03-16-biometric-password-mutual-exclusivity.md b/docs/superpowers/plans/2026-03-16-biometric-password-mutual-exclusivity.md index ee60377fe..0a12515ae 100644 --- a/docs/superpowers/plans/2026-03-16-biometric-password-mutual-exclusivity.md +++ b/docs/superpowers/plans/2026-03-16-biometric-password-mutual-exclusivity.md @@ -15,6 +15,7 @@ ### Task 1: Simplify PasswordContext — remove all keychain code **Files:** + - Modify: `src/contexts/PasswordContext.tsx` - [ ] **Step 1: Remove keychain imports and helpers** @@ -33,13 +34,17 @@ Update `PasswordContextType` to remove `clearKeychainEntry`, `updateKeychainEntr ```typescript export interface PasswordContextType { - requestPassword: (hasPassword: boolean, fingerprint?: number) => Promise; + requestPassword: ( + hasPassword: boolean, + fingerprint?: number, + ) => Promise; } ``` - [ ] **Step 4: Remove stale-tracking refs and callback registration from provider** Inside `PasswordProvider`, remove: + - `skipKeychainRef` - `lastKeychainFingerprintRef` - The `onDecryptErrorCallback = () => { ... }` registration block @@ -50,7 +55,10 @@ Replace the 4-case decision tree with 3 mutually exclusive cases: ```typescript const requestPassword = useCallback( - async (hasPassword: boolean, targetFingerprint?: number): Promise => { + async ( + hasPassword: boolean, + targetFingerprint?: number, + ): Promise => { // Case 1: Has password → password takes precedence, show dialog if (hasPassword) { return new Promise((resolve) => { @@ -141,16 +149,19 @@ dialog, biometric is a standalone gate for no-password wallets only." ### Task 2: Remove notifyDecryptError from ErrorContext **Files:** + - Modify: `src/contexts/ErrorContext.tsx` - [ ] **Step 1: Remove the notifyDecryptError import and call** Remove the import line: + ```typescript import { notifyDecryptError } from './PasswordContext'; ``` Remove the `notifyDecryptError()` call from the `addError` handler. Keep the toast: + ```typescript if (reason.includes('decrypt')) { toast.error(t`Incorrect password`); @@ -167,6 +178,7 @@ git commit -m "refactor: remove notifyDecryptError bridge from ErrorContext" ### Task 3: Remove keychain usage from Settings **Files:** + - Modify: `src/pages/Settings.tsx` - [ ] **Step 1: Remove keychain calls from GlobalSettings toggleBiometric** @@ -203,16 +215,19 @@ git commit -m "refactor: remove keychain lifecycle from Settings" ### Task 4: Remove keychain usage from WalletCard **Files:** + - Modify: `src/components/WalletCard.tsx` - [ ] **Step 1: Remove clearKeychainEntry from WalletCard** Change the `usePassword()` destructure to only get `requestPassword`: + ```typescript const { requestPassword } = usePassword(); ``` In `deleteSelf`, remove the `clearKeychainEntry` call after successful deletion: + ```typescript const deleteSelf = async () => { const password = await requestPassword(info.has_password, info.fingerprint); @@ -240,6 +255,7 @@ git commit -m "refactor: remove keychain cleanup from wallet deletion" ### Task 5: Remove usePassword hook's keychain exports **Files:** + - Modify: `src/hooks/usePassword.ts` - [ ] **Step 1: Verify usePassword.ts needs no changes** @@ -257,6 +273,7 @@ Expected: No matches. ### Task 6: Remove tauri-plugin-keychain from Rust dependencies **Files:** + - Modify: `src-tauri/Cargo.toml` — remove `tauri-plugin-keychain = "2"` line - Modify: `src-tauri/src/lib.rs` — remove `.plugin(tauri_plugin_keychain::init())` line - Modify: `src-tauri/capabilities/mobile.json` — remove `"keychain:default"` from permissions array @@ -295,11 +312,13 @@ git commit -m "chore: remove tauri-plugin-keychain dependency" ### Task 7: Update the design spec **Files:** + - Modify: `docs/superpowers/specs/2026-03-15-password-protection-design.md` - [ ] **Step 1: Update the spec** Key changes to the spec: + 1. **Overview** — Replace "Optionally, users can enable biometric unlock... as a convenience layer that stores the password in the OS keychain" with language about mutual exclusivity 2. **Biometric-Password Bridge section** — Replace entirely. Remove keychain plugin references, store-on-first-use, retrieval flow steps 4-6. Replace with simple mutual exclusivity rule 3. **Decision tree** — Simplify to 3 cases (password→dialog, no password+biometric→biometric gate, no password+no biometric→no auth) From 7a31aeaefe08e51a398e157e2ef52af6a39a3da9 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 17:24:59 -0500 Subject: [PATCH 45/53] remove plans --- ...6-biometric-password-mutual-exclusivity.md | 354 ------------------ 1 file changed, 354 deletions(-) delete mode 100644 docs/superpowers/plans/2026-03-16-biometric-password-mutual-exclusivity.md diff --git a/docs/superpowers/plans/2026-03-16-biometric-password-mutual-exclusivity.md b/docs/superpowers/plans/2026-03-16-biometric-password-mutual-exclusivity.md deleted file mode 100644 index 0a12515ae..000000000 --- a/docs/superpowers/plans/2026-03-16-biometric-password-mutual-exclusivity.md +++ /dev/null @@ -1,354 +0,0 @@ -# Biometric-Password Mutual Exclusivity Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make biometric and password mutually exclusive — password takes precedence, removing all keychain password storage infrastructure. - -**Architecture:** Biometric is a standalone gate for no-password wallets. Password wallets always show the password dialog. The two never interact. Remove `tauri-plugin-keychain`, the `notifyDecryptError` bridge, all skip/stale tracking refs, and all keychain lifecycle methods. - -**Tech Stack:** TypeScript/React (Tauri v2), Rust (Cargo dependencies) - ---- - -## Chunk 1: Remove keychain infrastructure and simplify PasswordContext - -### Task 1: Simplify PasswordContext — remove all keychain code - -**Files:** - -- Modify: `src/contexts/PasswordContext.tsx` - -- [ ] **Step 1: Remove keychain imports and helpers** - -Remove the entire keychain section (lines 9–49): the lazy-loaded `keychainPromise`, `getKeychain()`, `keychainGet()`, `keychainSave()`, `keychainRemove()` functions. - -Also remove the `keychainKey()` helper (line 82–84). - -- [ ] **Step 2: Remove the notifyDecryptError bridge** - -Remove the module-level callback infrastructure (lines 54–61): `onDecryptErrorCallback`, `notifyDecryptError()` export. - -- [ ] **Step 3: Remove keychain lifecycle methods from context type** - -Update `PasswordContextType` to remove `clearKeychainEntry`, `updateKeychainEntry`, and `clearAllKeychainEntries`. The interface should only have: - -```typescript -export interface PasswordContextType { - requestPassword: ( - hasPassword: boolean, - fingerprint?: number, - ) => Promise; -} -``` - -- [ ] **Step 4: Remove stale-tracking refs and callback registration from provider** - -Inside `PasswordProvider`, remove: - -- `skipKeychainRef` -- `lastKeychainFingerprintRef` -- The `onDecryptErrorCallback = () => { ... }` registration block - -- [ ] **Step 5: Simplify the requestPassword decision tree** - -Replace the 4-case decision tree with 3 mutually exclusive cases: - -```typescript -const requestPassword = useCallback( - async ( - hasPassword: boolean, - targetFingerprint?: number, - ): Promise => { - // Case 1: Has password → password takes precedence, show dialog - if (hasPassword) { - return new Promise((resolve) => { - pendingRef.current = { resolve }; - setOpen(true); - }); - } - - // Case 2: No password, biometric enabled → standalone biometric gate with 5-min cache - if (biometricEnabled && isMobile) { - const now = performance.now(); - if ( - lastBiometricPromptRef.current !== null && - now - lastBiometricPromptRef.current < BIOMETRIC_CACHE_MS - ) { - return null; // Within cache window, skip prompt - } - - try { - const { authenticate } = await import('@tauri-apps/plugin-biometric'); - await authenticate('Authenticate to continue', { - allowDeviceCredential: false, - }); - lastBiometricPromptRef.current = now; - return null; - } catch { - return undefined; // biometric failed/cancelled - } - } - - // Case 3: No password, no biometric → no auth needed - return null; - }, - [biometricEnabled], -); -``` - -Note: `targetFingerprint` parameter is no longer used (was only needed for keychain lookup) but keep it in the signature for now since call sites pass it. It's harmless and avoids touching all call sites. - -- [ ] **Step 6: Simplify handleSubmit and PasswordRequest — remove keychain save logic** - -These changes must be done together with Step 5 to avoid transient type errors. - -Replace handleSubmit — note the dependency array changes from `[biometricEnabled]` to `[]` since keychain save is removed: - -```typescript -const handleSubmit = useCallback((password: string) => { - setOpen(false); - pendingRef.current?.resolve(password); - pendingRef.current = null; -}, []); -``` - -Remove the `fingerprint` field from the `PasswordRequest` interface: - -```typescript -interface PasswordRequest { - resolve: (password: string | null | undefined) => void; -} -``` - -- [ ] **Step 7: Remove lifecycle methods from provider** - -Remove the `clearKeychainEntry`, `updateKeychainEntry`, and `clearAllKeychainEntries` useCallback definitions. - -Update the context value to only provide `requestPassword`: - -```typescript - -``` - -- [ ] **Step 8: Remove useWallet import and usage** - -Remove `import { useWallet } from '@/contexts/WalletContext';` (line 3) and `const { wallet } = useWallet();` (line 90) from `PasswordProvider`. These were only used for `wallet?.fingerprint` as the keychain default fingerprint, which is no longer needed. - -Keep `useBiometric` — it IS still needed for `biometricEnabled` in Case 2. - -- [ ] **Step 9: Commit** - -```bash -git add src/contexts/PasswordContext.tsx -git commit -m "refactor: make biometric and password mutually exclusive - -Remove all keychain password storage — password wallets always use the -dialog, biometric is a standalone gate for no-password wallets only." -``` - -### Task 2: Remove notifyDecryptError from ErrorContext - -**Files:** - -- Modify: `src/contexts/ErrorContext.tsx` - -- [ ] **Step 1: Remove the notifyDecryptError import and call** - -Remove the import line: - -```typescript -import { notifyDecryptError } from './PasswordContext'; -``` - -Remove the `notifyDecryptError()` call from the `addError` handler. Keep the toast: - -```typescript -if (reason.includes('decrypt')) { - toast.error(t`Incorrect password`); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/contexts/ErrorContext.tsx -git commit -m "refactor: remove notifyDecryptError bridge from ErrorContext" -``` - -### Task 3: Remove keychain usage from Settings - -**Files:** - -- Modify: `src/pages/Settings.tsx` - -- [ ] **Step 1: Remove keychain calls from GlobalSettings toggleBiometric** - -In `GlobalSettings`, remove `clearAllKeychainEntries` from the `usePassword()` destructure. Simplify `toggleBiometric` to remove the keychain cleanup: - -```typescript -const toggleBiometric = async (value: boolean) => { - try { - if (value) { - await enableIfAvailable(); - } else { - await disable(); - } - } catch (error) { - addError(error as CustomError); - } -}; -``` - -- [ ] **Step 2: Remove keychain calls from WalletSettings password handlers** - -In `WalletSettings`, remove `clearKeychainEntry` and `updateKeychainEntry` from the `usePassword()` destructure. - -Remove the keychain update block in `handlePasswordSubmit` (the section after `setPasswordDialogOpen(false)` that calls `updateKeychainEntry` / `clearKeychainEntry`). - -- [ ] **Step 3: Commit** - -```bash -git add src/pages/Settings.tsx -git commit -m "refactor: remove keychain lifecycle from Settings" -``` - -### Task 4: Remove keychain usage from WalletCard - -**Files:** - -- Modify: `src/components/WalletCard.tsx` - -- [ ] **Step 1: Remove clearKeychainEntry from WalletCard** - -Change the `usePassword()` destructure to only get `requestPassword`: - -```typescript -const { requestPassword } = usePassword(); -``` - -In `deleteSelf`, remove the `clearKeychainEntry` call after successful deletion: - -```typescript -const deleteSelf = async () => { - const password = await requestPassword(info.has_password, info.fingerprint); - if (password === undefined) { - setIsDeleteOpen(false); - return; - } - await commands - .deleteKey({ fingerprint: info.fingerprint }) - .then(async () => { - setKeys(keys.filter((key) => key.fingerprint !== info.fingerprint)); - }) - .catch(addError); - setIsDeleteOpen(false); -}; -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/components/WalletCard.tsx -git commit -m "refactor: remove keychain cleanup from wallet deletion" -``` - -### Task 5: Remove usePassword hook's keychain exports - -**Files:** - -- Modify: `src/hooks/usePassword.ts` - -- [ ] **Step 1: Verify usePassword.ts needs no changes** - -`usePassword.ts` is a thin wrapper that returns the full context. Since `PasswordContextType` was already simplified in Task 1, this file should work as-is with no changes needed. - -- [ ] **Step 2: Verify no other files reference removed methods** - -Run: `grep -r 'clearKeychainEntry\|updateKeychainEntry\|clearAllKeychainEntries\|notifyDecryptError' src/` - -Expected: No matches. - -## Chunk 2: Remove tauri-plugin-keychain dependency - -### Task 6: Remove tauri-plugin-keychain from Rust dependencies - -**Files:** - -- Modify: `src-tauri/Cargo.toml` — remove `tauri-plugin-keychain = "2"` line -- Modify: `src-tauri/src/lib.rs` — remove `.plugin(tauri_plugin_keychain::init())` line -- Modify: `src-tauri/capabilities/mobile.json` — remove `"keychain:default"` from permissions array - -- [ ] **Step 1: Remove from Cargo.toml** - -Remove the line `tauri-plugin-keychain = "2"` from `[dependencies]`. - -- [ ] **Step 2: Remove plugin init from lib.rs** - -Remove `.plugin(tauri_plugin_keychain::init())` from the mobile builder chain. - -- [ ] **Step 3: Remove capability from mobile.json** - -Remove `"keychain:default"` from the permissions array (and the trailing comma from the previous entry). - -- [ ] **Step 4: Remove from package.json** - -Remove `"tauri-plugin-keychain": "^2.0.1"` from dependencies. - -- [ ] **Step 5: Update lockfiles** - -Run `pnpm install` to update the JS lockfile. Then run `cargo update` (from `src-tauri/`) to remove the keychain crate from `Cargo.lock`. - -**Important:** All JS-side keychain usages (Tasks 1–5) must be completed before this step — otherwise `pnpm install` will remove the package while dynamic imports still reference it. - -- [ ] **Step 6: Commit** - -```bash -git add src-tauri/Cargo.toml src-tauri/src/lib.rs src-tauri/capabilities/mobile.json package.json pnpm-lock.yaml Cargo.lock -git commit -m "chore: remove tauri-plugin-keychain dependency" -``` - -## Chunk 3: Update spec - -### Task 7: Update the design spec - -**Files:** - -- Modify: `docs/superpowers/specs/2026-03-15-password-protection-design.md` - -- [ ] **Step 1: Update the spec** - -Key changes to the spec: - -1. **Overview** — Replace "Optionally, users can enable biometric unlock... as a convenience layer that stores the password in the OS keychain" with language about mutual exclusivity -2. **Biometric-Password Bridge section** — Replace entirely. Remove keychain plugin references, store-on-first-use, retrieval flow steps 4-6. Replace with simple mutual exclusivity rule -3. **Decision tree** — Simplify to 3 cases (password→dialog, no password+biometric→biometric gate, no password+no biometric→no auth) -4. **Keychain lifecycle table** — Remove entirely -5. **Settings UI changes** — Remove keychain cleanup on toggle disable -6. **Design decisions** — Update "Graceful degradation" and remove keychain-related decisions -7. **Error Handling** — Remove "Stale keychain entry" and "Device restore" sections -8. **New dependency section** — Remove `tauri-plugin-keychain` references -9. **What's NOT Changing** — Update to note biometric remains as standalone gate only - -- [ ] **Step 2: Commit** - -```bash -git add docs/superpowers/specs/2026-03-15-password-protection-design.md -git commit -m "docs: update spec — biometric and password are mutually exclusive" -``` - -### Task 8: Verify build - -- [ ] **Step 1: Run TypeScript type check** - -Run: `pnpm tsc --noEmit` -Expected: No errors. - -- [ ] **Step 2: Run linter** - -Run: `pnpm lint` -Expected: No errors. - -- [ ] **Step 3: Run Rust check** - -Run: `cargo check` (from `src-tauri/`) -Expected: No errors. From 37424332aaf121037741d661f4d3ddeb91bb0d49 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 18:23:26 -0500 Subject: [PATCH 46/53] passward promting bug fixes --- .../specs/2026-03-16-protection-matrix.md | 118 ++++++++++++++++++ src/components/OfferRowCard.tsx | 8 -- src/hooks/useOfferProcessor.ts | 1 + src/pages/Offer.tsx | 8 -- src/pages/Offers.tsx | 8 -- src/pages/Settings.tsx | 4 +- 6 files changed, 122 insertions(+), 25 deletions(-) create mode 100644 docs/superpowers/specs/2026-03-16-protection-matrix.md diff --git a/docs/superpowers/specs/2026-03-16-protection-matrix.md b/docs/superpowers/specs/2026-03-16-protection-matrix.md new file mode 100644 index 000000000..b87ee4e40 --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-protection-matrix.md @@ -0,0 +1,118 @@ +# Operation Protection Matrix + +Analysis of biometric vs. password protection across all protected operations in Sage wallet. + +## Architecture + +Password enforcement happens at two distinct layers, depending on `auto_submit`: + +1. **ConfirmationDialog (centralized)** — When `auto_submit: false` (the default for all UI operations), the Rust `transact()` function ignores the password entirely and returns unsigned coin spends. The user reviews the transaction in `ConfirmationDialog`, whose Submit button calls `requestPassword` → `signCoinSpends` → `submitTransaction`. This is the single enforcement point for all normal UI transaction flows. + +2. **Direct `requestPassword` (per-call-site)** — WalletConnect handlers set `auto_submit: true`, which means signing happens inside the Rust command itself. These must call `requestPassword` and pass the password to the command directly. + +Password and biometric are mutually exclusive. `requestPassword(hasPassword)` routes to password dialog OR biometric prompt, never both. A wallet with a password set never triggers biometric. + +## Matrix — UI Operations + +Legend: ✅ = protected, 🔄 = redundant double-prompt, ⚠️ = bug, ❌ = not protected + +| Operation | Protected | Mechanism | Status | Call site | +| --------------------------------------------------- | :-------: | -------------------------------------- | :----: | ------------------------------------------------------------------------ | +| **Transaction Operations (via ConfirmationDialog)** | | | | | +| Send XCH | ✅ | ConfirmationDialog | OK | `Send.tsx:188` | +| Send CAT | ✅ | ConfirmationDialog | OK | `Send.tsx:206` | +| Bulk send XCH | ✅ | ConfirmationDialog | OK | `Send.tsx:181` | +| Bulk send CAT | ✅ | ConfirmationDialog | OK | `Send.tsx:198` | +| Combine coins | ✅ | ConfirmationDialog (via Token.tsx) | OK | `OwnedCoinsCard.tsx:261` | +| Split coins | ✅ | ConfirmationDialog (via Token.tsx) | OK | `OwnedCoinsCard.tsx:310` | +| Auto-combine XCH/CAT | ✅ | ConfirmationDialog (via Token.tsx) | OK | `OwnedCoinsCard.tsx:363` | +| Issue CAT | ✅ | ConfirmationDialog | OK | `IssueToken.tsx:48` | +| Multi-send | — | No UI call site | N/A | Dead code in bindings | +| Sign coin spends (Sign button) | ✅ | Direct `requestPassword` | OK | `ConfirmationDialog.tsx:520` | +| Sign coin spends (Submit button) | ✅ | Direct `requestPassword` | OK | `ConfirmationDialog.tsx:627` | +| **NFTs / DIDs** | | | | | +| Bulk mint NFTs | ✅ | ConfirmationDialog | OK | `MintNft.tsx:140` | +| Transfer NFTs | ✅ | ConfirmationDialog | OK | `MultiSelectActions.tsx:135` | +| Burn NFTs | ✅ | ConfirmationDialog | OK | `MultiSelectActions.tsx:168` | +| Add NFT URI | — | No UI call site | N/A | Dead code in bindings | +| Assign NFTs to DID | ✅ | ConfirmationDialog | OK | `MultiSelectActions.tsx:152` | +| Create DID | ✅ | ConfirmationDialog | OK | `CreateProfile.tsx:46` | +| Transfer DIDs | ✅ | ConfirmationDialog | OK | `DidList.tsx:166` | +| Burn DIDs | ✅ | ConfirmationDialog | OK | `DidList.tsx:182` | +| Normalize DIDs | ✅ | ConfirmationDialog | OK | `DidList.tsx:198` | +| **Options** | | | | | +| Mint option | ✅ | ConfirmationDialog | OK | `MintOption.tsx:91` | +| Transfer options | ✅ | ConfirmationDialog | OK | `useOptionActions.tsx:63` | +| Exercise options | ✅ | ConfirmationDialog | OK | `useOptionActions.tsx:43` | +| Burn options | ✅ | ConfirmationDialog | OK | `useOptionActions.tsx:83` | +| **Clawback** | | | | | +| Claw back coins | ✅ | ConfirmationDialog (via Token.tsx) | OK | `ClawbackCoinsCard.tsx:215` | +| Finalize clawback | ✅ | ConfirmationDialog (via Token.tsx) | OK | `ClawbackCoinsCard.tsx:260` | +| **Offers** | | | | | +| Make offer (split-NFT path) | ✅ | Direct `requestPassword` | OK | `useOfferProcessor.ts:116` — password forwarded | +| Make offer (single/non-split) | ✅ | Direct `requestPassword` | ⚠️ BUG | `useOfferProcessor.ts:160` — password obtained at line 65 but not passed | +| Take offer | ✅ | `requestPassword` + ConfirmationDialog | 🔄 | `Offer.tsx:103` — double-prompted | +| Cancel offer | ✅ | `requestPassword` + ConfirmationDialog | 🔄 | `OfferRowCard.tsx:67` — double-prompted | +| Cancel all offers | ✅ | `requestPassword` + ConfirmationDialog | 🔄 | `Offers.tsx:193` — double-prompted | +| **Secrets / Key Management** | | | | | +| View mnemonic / secret key | ✅ | Direct `requestPassword` | OK | `WalletCard.tsx:179` | +| Delete wallet key | ✅ | Direct `requestPassword` (gate) | OK | `WalletCard.tsx:82` | +| Import key (secret/mnemonic) | ✅ | Password set at import time | OK | Encrypt-at-import only | +| Set / Change / Remove password | ✅ | Inline form (not `requestPassword`) | OK | `Settings.tsx:1238` | +| **Key Derivation** | | | | | +| Increase derivation (hardened) | ✅ | Direct `requestPassword` | OK | `Settings.tsx:1274` | +| Increase derivation (unhardened) | ❌ | None | OK | No private key needed | +| **Other Privileged Actions** | | | | | +| Enable/disable biometric toggle | ✅ | `requestPassword(false)` | OK | `Settings.tsx:972/989` | +| Start/stop RPC server | ✅ | `requestPassword(false)` | OK | `Settings.tsx:975/993` | +| **Unprotected (by design)** | | | | | +| View balances / addresses / NFTs | ❌ | None | OK | Read-only | +| Submit pre-signed transaction | ❌ | None | OK | No key access needed | +| Login / logout wallet | ❌ | None | OK | No secret access | +| Rename / resync / emoji | ❌ | None | OK | Metadata only | + +## Matrix — WalletConnect Operations + +All WC handlers use `auto_submit: true` (except `signCoinSpends`), so password is required at the call site. All are correctly wired via `HandlerContext.requestPassword`. + +| Operation | Protected | auto_submit | Status | Call site | +| ------------------------------------- | :-------: | :---------: | :----: | --------------------- | +| `chia_send` (XCH/CAT) | ✅ | `true` | OK | `high-level.ts:54/64` | +| `chia_bulkMintNfts` | ✅ | `true` | OK | `high-level.ts:100` | +| `chia_createOffer` | ✅ | not set | OK | `offers.ts:12` | +| `chia_takeOffer` | ✅ | `true` | OK | `offers.ts:41` | +| `chia_cancelOffer` | ✅ | `true` | OK | `offers.ts:58` | +| `chip0002_signCoinSpends` | ✅ | `false` | OK | `chip0002.ts:81` | +| `chip0002_signMessage` | ✅ | N/A | OK | `chip0002.ts:105` | +| `chia_signMessageByAddress` | ✅ | N/A | OK | `high-level.ts` | +| WC read-only (connect, chainId, etc.) | ❌ | N/A | OK | No signing | + +## Issues + +### Bug: `makeOffer` non-split path drops password + +- **File:** `src/hooks/useOfferProcessor.ts:160` +- `requestPassword` is correctly called at line 65 and stored in `password`. +- The split-NFT path at line 116 passes `password` to `makeOffer`. +- The single/non-split path at line 160 omits `password` from the call. +- **Impact:** Single-offer creation silently fails on password-protected wallets. + +### Redundancy: Offer operations double-prompt + +`takeOffer` (`Offer.tsx:103`), `cancelOffer` (`OfferRowCard.tsx:67`), and `cancelOffers` (`Offers.tsx:193`) all call `requestPassword` before the command AND pass the result through `ConfirmationDialog`. Since `auto_submit` is not set, the password passed to the command is ignored by `transact()`. The user gets prompted twice: once before the confirmation dialog opens, then again on Submit. + +**Recommendation:** Remove `requestPassword` from these call sites and let `ConfirmationDialog` handle it, matching the pattern used by `Send.tsx`, `IssueToken.tsx`, `CreateProfile.tsx`, and all other transaction pages. + +### Fixed: Stale `has_password` in WalletContext + +**Root cause of the original "incorrect password" bug.** `WalletContext` fetched `wallet.has_password` once at login. After setting a password in Settings, the global context was never updated, so `ConfirmationDialog` always saw `has_password: false` and called `requestPassword(false)` — which returns `null` immediately on desktop. + +**Fix applied:** `Settings.tsx` `WalletSettings` now calls `setGlobalWallet(data.key)` after a successful `changePassword`, keeping the global context in sync. + +### Design considerations + +1. **`deleteKey` is unspecced** — protected in the UI but not mentioned in the spec. +2. **Biometric toggle auth gap** — calls `requestPassword(false)` unconditionally, so on a password-protected wallet it skips all auth. +3. **Import path is password-only** — biometric is never an option at import time. +4. **Session caching asymmetry** — biometric caches for 5 minutes; password never caches. +5. **Dead code** — `multiSend` and `addNftUri` exist in bindings but have no UI call sites. diff --git a/src/components/OfferRowCard.tsx b/src/components/OfferRowCard.tsx index b56afd049..d7ac85191 100644 --- a/src/components/OfferRowCard.tsx +++ b/src/components/OfferRowCard.tsx @@ -12,9 +12,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { useWallet } from '@/contexts/WalletContext'; import { useErrors } from '@/hooks/useErrors'; -import { usePassword } from '@/hooks/usePassword'; import { amount } from '@/lib/formTypes'; import { toMojos } from '@/lib/utils'; import { useWalletState } from '@/state'; @@ -44,8 +42,6 @@ interface OfferRowCardProps { export function OfferRowCard({ record, refresh }: OfferRowCardProps) { const walletState = useWalletState(); const { addError } = useErrors(); - const { requestPassword } = usePassword(); - const { wallet } = useWallet(); const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isCancelOpen, setIsCancelOpen] = useState(false); @@ -64,16 +60,12 @@ export function OfferRowCard({ record, refresh }: OfferRowCardProps) { const [response, setResponse] = useState(null); const cancelHandler = async (values: z.infer) => { - const password = await requestPassword(wallet?.has_password ?? false); - if (password === undefined) return; - const fee = toMojos(values.fee, walletState.sync.unit.precision); commands .cancelOffer({ offer_id: record.offer_id, fee, - password, }) .then((result) => { setResponse(result); diff --git a/src/hooks/useOfferProcessor.ts b/src/hooks/useOfferProcessor.ts index 320acb0e5..9634409a8 100644 --- a/src/hooks/useOfferProcessor.ts +++ b/src/hooks/useOfferProcessor.ts @@ -165,6 +165,7 @@ export function useOfferProcessor({ walletState.sync.unit.precision, ), expires_at_second: expiresAtSecond, + password, }); if (!isCancelled.current) { setCreatedOffers([data.offer]); diff --git a/src/pages/Offer.tsx b/src/pages/Offer.tsx index c147b5088..f71ae8f65 100644 --- a/src/pages/Offer.tsx +++ b/src/pages/Offer.tsx @@ -14,9 +14,7 @@ import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { FeeAmountInput } from '@/components/ui/masked-input'; import { CustomError } from '@/contexts/ErrorContext'; -import { useWallet } from '@/contexts/WalletContext'; import { useErrors } from '@/hooks/useErrors'; -import { usePassword } from '@/hooks/usePassword'; import { resolveOfferData } from '@/lib/offerData'; import { toMojos } from '@/lib/utils'; import { useWalletState } from '@/state'; @@ -28,8 +26,6 @@ import { useNavigate, useParams } from 'react-router-dom'; export function Offer() { const { offer } = useParams(); const { addError } = useErrors(); - const { requestPassword } = usePassword(); - const { wallet } = useWallet(); const walletState = useWalletState(); const navigate = useNavigate(); @@ -100,13 +96,9 @@ export function Offer() { } try { - const password = await requestPassword(wallet?.has_password ?? false); - if (password === undefined) return; - const result = await commands.takeOffer({ offer: resolvedOffer, fee: toMojos(fee || '0', walletState.sync.unit.precision), - password, }); setResponse(result); } catch (error) { diff --git a/src/pages/Offers.tsx b/src/pages/Offers.tsx index 11ffc7861..fabdad0f7 100644 --- a/src/pages/Offers.tsx +++ b/src/pages/Offers.tsx @@ -24,9 +24,7 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; -import { useWallet } from '@/contexts/WalletContext'; import { useErrors } from '@/hooks/useErrors'; -import { usePassword } from '@/hooks/usePassword'; import { useScannerOrClipboard } from '@/hooks/useScannerOrClipboard'; import { amount } from '@/lib/formTypes'; import { toMojos } from '@/lib/utils'; @@ -58,8 +56,6 @@ export function Offers() { const offerState = useOfferState(); const walletState = useWalletState(); const { addError } = useErrors(); - const { requestPassword } = usePassword(); - const { wallet } = useWallet(); const [offerString, setOfferString] = useState(''); const [dialogOpen, setDialogOpen] = useState(false); const [offers, setOffers] = useState([]); @@ -190,9 +186,6 @@ export function Offers() { }; const cancelAllHandler = async (values: z.infer) => { - const password = await requestPassword(wallet?.has_password ?? false); - if (password === undefined) return; - const fee = toMojos(values.fee, walletState.sync.unit.precision); const activeOffers = filteredOffers.filter( (offer) => offer.status === 'active', @@ -205,7 +198,6 @@ export function Offers() { .cancelOffers({ offer_ids: activeOffers.map((offer) => offer.offer_id), fee, - password, }) .then((response) => { setCancelAllResponse(response); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 288db6fc3..1025efa14 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1033,6 +1033,7 @@ function RpcSettings() { function WalletSettings({ fingerprint }: { fingerprint: number }) { const { addError } = useErrors(); const { requestPassword } = usePassword(); + const { setWallet: setGlobalWallet } = useWallet(); const walletState = useWalletState(); @@ -1242,9 +1243,10 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { setPasswordDialogOpen(false); - // Refresh key info to update has_password + // Refresh key info to update has_password in local and global state const data = await commands.getKey({ fingerprint }); setKey(data.key); + setGlobalWallet(data.key); toast.success( passwordDialogMode === 'set' From 1c08645bd67190b7ad712cf1352d553699b39fce Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 18:28:34 -0500 Subject: [PATCH 47/53] passward protect delete key --- .../2026-03-15-password-protection-design.md | 12 ++++- .../specs/2026-03-16-protection-matrix.md | 45 +++++++++---------- src/components/WalletCard.tsx | 15 +++++++ 3 files changed, 46 insertions(+), 26 deletions(-) diff --git a/docs/superpowers/specs/2026-03-15-password-protection-design.md b/docs/superpowers/specs/2026-03-15-password-protection-design.md index a3fbb2913..f0fd7a48c 100644 --- a/docs/superpowers/specs/2026-03-15-password-protection-design.md +++ b/docs/superpowers/specs/2026-03-15-password-protection-design.md @@ -86,7 +86,15 @@ endpoint method → transact() / transact_with() → sign() → extract_secrets( Plus `cancel_offer`, `cancel_offers`, and `create_transaction` (action system) which also flow through `transact()` / `transact_with()`. -### 3. Generate hardened keys (1 site) +### 3. Delete wallet key + +Password-protected wallets require password verification before deletion. Since the Rust `delete_key()` endpoint does not accept a password, verification is performed on the frontend by calling `get_secret_key()` first — if decryption fails, the delete is blocked. + +| Call site | Function | +| --------------------- | --------------------------------------------------- | +| `WalletCard.tsx:81` | `deleteSelf()` — verifies via `getSecretKey` before `deleteKey` | + +### 4. Generate hardened keys (1 site) | Call site | Function | | ------------------------------------------ | ----------------------------- | @@ -239,7 +247,7 @@ The separate `promptIfEnabled()` biometric call is removed from all call sites. | File | Operations | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `ConfirmationDialog.tsx` | `signCoinSpends` (Sign Transaction button, Submit button) | -| `WalletCard.tsx` | `getSecretKey` (View Details dialog) | +| `WalletCard.tsx` | `getSecretKey` (View Details dialog), `deleteKey` (password verified via `getSecretKey` before deletion) | | `Settings.tsx` | `increaseDerivationIndex` (when hardened keys enabled) | | `Offers.tsx` | `cancelOffers` (Cancel All Active) | | `OfferRowCard.tsx` | `cancelOffer` (individual offer cancel) | diff --git a/docs/superpowers/specs/2026-03-16-protection-matrix.md b/docs/superpowers/specs/2026-03-16-protection-matrix.md index b87ee4e40..408e3a206 100644 --- a/docs/superpowers/specs/2026-03-16-protection-matrix.md +++ b/docs/superpowers/specs/2026-03-16-protection-matrix.md @@ -50,13 +50,13 @@ Legend: ✅ = protected, 🔄 = redundant double-prompt, ⚠️ = bug, ❌ = not | Finalize clawback | ✅ | ConfirmationDialog (via Token.tsx) | OK | `ClawbackCoinsCard.tsx:260` | | **Offers** | | | | | | Make offer (split-NFT path) | ✅ | Direct `requestPassword` | OK | `useOfferProcessor.ts:116` — password forwarded | -| Make offer (single/non-split) | ✅ | Direct `requestPassword` | ⚠️ BUG | `useOfferProcessor.ts:160` — password obtained at line 65 but not passed | -| Take offer | ✅ | `requestPassword` + ConfirmationDialog | 🔄 | `Offer.tsx:103` — double-prompted | -| Cancel offer | ✅ | `requestPassword` + ConfirmationDialog | 🔄 | `OfferRowCard.tsx:67` — double-prompted | -| Cancel all offers | ✅ | `requestPassword` + ConfirmationDialog | 🔄 | `Offers.tsx:193` — double-prompted | +| Make offer (single/non-split) | ✅ | Direct `requestPassword` | OK | `useOfferProcessor.ts:160` — fixed: password now forwarded | +| Take offer | ✅ | ConfirmationDialog | OK | `Offer.tsx` — fixed: removed redundant pre-prompt | +| Cancel offer | ✅ | ConfirmationDialog | OK | `OfferRowCard.tsx` — fixed: removed redundant pre-prompt | +| Cancel all offers | ✅ | ConfirmationDialog | OK | `Offers.tsx` — fixed: removed redundant pre-prompt | | **Secrets / Key Management** | | | | | | View mnemonic / secret key | ✅ | Direct `requestPassword` | OK | `WalletCard.tsx:179` | -| Delete wallet key | ✅ | Direct `requestPassword` (gate) | OK | `WalletCard.tsx:82` | +| Delete wallet key | ✅ | `requestPassword` + `getSecretKey` verify | OK | `WalletCard.tsx:82` — password verified via decryption before delete | | Import key (secret/mnemonic) | ✅ | Password set at import time | OK | Encrypt-at-import only | | Set / Change / Remove password | ✅ | Inline form (not `requestPassword`) | OK | `Settings.tsx:1238` | | **Key Derivation** | | | | | @@ -87,32 +87,29 @@ All WC handlers use `auto_submit: true` (except `signCoinSpends`), so password i | `chia_signMessageByAddress` | ✅ | N/A | OK | `high-level.ts` | | WC read-only (connect, chainId, etc.) | ❌ | N/A | OK | No signing | -## Issues +## Fixed Issues -### Bug: `makeOffer` non-split path drops password +### Fixed: Stale `has_password` in WalletContext -- **File:** `src/hooks/useOfferProcessor.ts:160` -- `requestPassword` is correctly called at line 65 and stored in `password`. -- The split-NFT path at line 116 passes `password` to `makeOffer`. -- The single/non-split path at line 160 omits `password` from the call. -- **Impact:** Single-offer creation silently fails on password-protected wallets. +**Root cause of the original "incorrect password" bug.** `WalletContext` fetched `wallet.has_password` once at login. After setting a password in Settings, the global context was never updated, so `ConfirmationDialog` always saw `has_password: false` and called `requestPassword(false)` — which returns `null` immediately on desktop. -### Redundancy: Offer operations double-prompt +**Fix:** `Settings.tsx` `WalletSettings` now calls `setGlobalWallet(data.key)` after a successful `changePassword`, keeping the global context in sync. -`takeOffer` (`Offer.tsx:103`), `cancelOffer` (`OfferRowCard.tsx:67`), and `cancelOffers` (`Offers.tsx:193`) all call `requestPassword` before the command AND pass the result through `ConfirmationDialog`. Since `auto_submit` is not set, the password passed to the command is ignored by `transact()`. The user gets prompted twice: once before the confirmation dialog opens, then again on Submit. +### Fixed: `makeOffer` non-split path dropped password -**Recommendation:** Remove `requestPassword` from these call sites and let `ConfirmationDialog` handle it, matching the pattern used by `Send.tsx`, `IssueToken.tsx`, `CreateProfile.tsx`, and all other transaction pages. +`useOfferProcessor.ts:160` — `requestPassword` was correctly called at line 65 but the `password` was not forwarded to `makeOffer` in the single/non-split path. Now passed. -### Fixed: Stale `has_password` in WalletContext +### Fixed: Offer operations double-prompted -**Root cause of the original "incorrect password" bug.** `WalletContext` fetched `wallet.has_password` once at login. After setting a password in Settings, the global context was never updated, so `ConfirmationDialog` always saw `has_password: false` and called `requestPassword(false)` — which returns `null` immediately on desktop. +`takeOffer`, `cancelOffer`, and `cancelOffers` all called `requestPassword` before the command AND passed the result through `ConfirmationDialog`. Since `auto_submit` is not set, the password was ignored by `transact()`. Removed the redundant pre-prompts; `ConfirmationDialog` now handles password for these operations, matching all other transaction pages. + +### Fixed: `deleteKey` password not verified -**Fix applied:** `Settings.tsx` `WalletSettings` now calls `setGlobalWallet(data.key)` after a successful `changePassword`, keeping the global context in sync. +Previously `deleteKey` called `requestPassword` as a gate but never verified the entered password — any input was accepted. Now verifies via `getSecretKey` (decryption) before allowing deletion. Added to the spec as a protected operation. -### Design considerations +## Remaining Design Considerations -1. **`deleteKey` is unspecced** — protected in the UI but not mentioned in the spec. -2. **Biometric toggle auth gap** — calls `requestPassword(false)` unconditionally, so on a password-protected wallet it skips all auth. -3. **Import path is password-only** — biometric is never an option at import time. -4. **Session caching asymmetry** — biometric caches for 5 minutes; password never caches. -5. **Dead code** — `multiSend` and `addNftUri` exist in bindings but have no UI call sites. +1. **Biometric toggle auth gap** — calls `requestPassword(false)` unconditionally, so on a password-protected wallet it skips all auth. +2. **Import path is password-only** — biometric is never an option at import time. +3. **Session caching asymmetry** — biometric caches for 5 minutes; password never caches. +4. **Dead code** — `multiSend` and `addNftUri` exist in bindings but have no UI call sites. diff --git a/src/components/WalletCard.tsx b/src/components/WalletCard.tsx index 72d77916b..3da9e7c5a 100644 --- a/src/components/WalletCard.tsx +++ b/src/components/WalletCard.tsx @@ -84,6 +84,21 @@ export function WalletCard({ setIsDeleteOpen(false); return; } + + // Verify password before allowing deletion + if (info.has_password) { + try { + await commands.getSecretKey({ + fingerprint: info.fingerprint, + password, + }); + } catch (error) { + addError(error as CustomError); + setIsDeleteOpen(false); + return; + } + } + await commands .deleteKey({ fingerprint: info.fingerprint }) .then(() => { From f139988cb62febe3625354cc2efe79b8b1242caf Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 18:39:15 -0500 Subject: [PATCH 48/53] removed cruft --- .../specs/2026-03-16-protection-matrix.md | 29 +++---------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/docs/superpowers/specs/2026-03-16-protection-matrix.md b/docs/superpowers/specs/2026-03-16-protection-matrix.md index 408e3a206..114ccd90d 100644 --- a/docs/superpowers/specs/2026-03-16-protection-matrix.md +++ b/docs/superpowers/specs/2026-03-16-protection-matrix.md @@ -27,14 +27,14 @@ Legend: ✅ = protected, 🔄 = redundant double-prompt, ⚠️ = bug, ❌ = not | Split coins | ✅ | ConfirmationDialog (via Token.tsx) | OK | `OwnedCoinsCard.tsx:310` | | Auto-combine XCH/CAT | ✅ | ConfirmationDialog (via Token.tsx) | OK | `OwnedCoinsCard.tsx:363` | | Issue CAT | ✅ | ConfirmationDialog | OK | `IssueToken.tsx:48` | -| Multi-send | — | No UI call site | N/A | Dead code in bindings | +| Multi-send | — | No frontend binding | N/A | Rust-only; no TypeScript binding or UI | | Sign coin spends (Sign button) | ✅ | Direct `requestPassword` | OK | `ConfirmationDialog.tsx:520` | | Sign coin spends (Submit button) | ✅ | Direct `requestPassword` | OK | `ConfirmationDialog.tsx:627` | | **NFTs / DIDs** | | | | | | Bulk mint NFTs | ✅ | ConfirmationDialog | OK | `MintNft.tsx:140` | | Transfer NFTs | ✅ | ConfirmationDialog | OK | `MultiSelectActions.tsx:135` | | Burn NFTs | ✅ | ConfirmationDialog | OK | `MultiSelectActions.tsx:168` | -| Add NFT URI | — | No UI call site | N/A | Dead code in bindings | +| Add NFT URI | ✅ | ConfirmationDialog | OK | `NftCard.tsx:236` | | Assign NFTs to DID | ✅ | ConfirmationDialog | OK | `MultiSelectActions.tsx:152` | | Create DID | ✅ | ConfirmationDialog | OK | `CreateProfile.tsx:46` | | Transfer DIDs | ✅ | ConfirmationDialog | OK | `DidList.tsx:166` | @@ -87,29 +87,8 @@ All WC handlers use `auto_submit: true` (except `signCoinSpends`), so password i | `chia_signMessageByAddress` | ✅ | N/A | OK | `high-level.ts` | | WC read-only (connect, chainId, etc.) | ❌ | N/A | OK | No signing | -## Fixed Issues - -### Fixed: Stale `has_password` in WalletContext - -**Root cause of the original "incorrect password" bug.** `WalletContext` fetched `wallet.has_password` once at login. After setting a password in Settings, the global context was never updated, so `ConfirmationDialog` always saw `has_password: false` and called `requestPassword(false)` — which returns `null` immediately on desktop. - -**Fix:** `Settings.tsx` `WalletSettings` now calls `setGlobalWallet(data.key)` after a successful `changePassword`, keeping the global context in sync. - -### Fixed: `makeOffer` non-split path dropped password - -`useOfferProcessor.ts:160` — `requestPassword` was correctly called at line 65 but the `password` was not forwarded to `makeOffer` in the single/non-split path. Now passed. - -### Fixed: Offer operations double-prompted - -`takeOffer`, `cancelOffer`, and `cancelOffers` all called `requestPassword` before the command AND passed the result through `ConfirmationDialog`. Since `auto_submit` is not set, the password was ignored by `transact()`. Removed the redundant pre-prompts; `ConfirmationDialog` now handles password for these operations, matching all other transaction pages. - -### Fixed: `deleteKey` password not verified - -Previously `deleteKey` called `requestPassword` as a gate but never verified the entered password — any input was accepted. Now verifies via `getSecretKey` (decryption) before allowing deletion. Added to the spec as a protected operation. ## Remaining Design Considerations -1. **Biometric toggle auth gap** — calls `requestPassword(false)` unconditionally, so on a password-protected wallet it skips all auth. -2. **Import path is password-only** — biometric is never an option at import time. -3. **Session caching asymmetry** — biometric caches for 5 minutes; password never caches. -4. **Dead code** — `multiSend` and `addNftUri` exist in bindings but have no UI call sites. +1. **Session caching asymmetry** — biometric caches for 5 minutes; password never caches. + From f6eb1fceaf8566282b36514debc764c10f73ec7b Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 18:40:18 -0500 Subject: [PATCH 49/53] prettier --- .../2026-03-15-password-protection-design.md | 8 +- .../specs/2026-03-16-protection-matrix.md | 112 +++++++++--------- 2 files changed, 59 insertions(+), 61 deletions(-) diff --git a/docs/superpowers/specs/2026-03-15-password-protection-design.md b/docs/superpowers/specs/2026-03-15-password-protection-design.md index f0fd7a48c..8b5e9c6cd 100644 --- a/docs/superpowers/specs/2026-03-15-password-protection-design.md +++ b/docs/superpowers/specs/2026-03-15-password-protection-design.md @@ -90,9 +90,9 @@ Plus `cancel_offer`, `cancel_offers`, and `create_transaction` (action system) w Password-protected wallets require password verification before deletion. Since the Rust `delete_key()` endpoint does not accept a password, verification is performed on the frontend by calling `get_secret_key()` first — if decryption fails, the delete is blocked. -| Call site | Function | -| --------------------- | --------------------------------------------------- | -| `WalletCard.tsx:81` | `deleteSelf()` — verifies via `getSecretKey` before `deleteKey` | +| Call site | Function | +| ------------------- | --------------------------------------------------------------- | +| `WalletCard.tsx:81` | `deleteSelf()` — verifies via `getSecretKey` before `deleteKey` | ### 4. Generate hardened keys (1 site) @@ -247,7 +247,7 @@ The separate `promptIfEnabled()` biometric call is removed from all call sites. | File | Operations | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `ConfirmationDialog.tsx` | `signCoinSpends` (Sign Transaction button, Submit button) | -| `WalletCard.tsx` | `getSecretKey` (View Details dialog), `deleteKey` (password verified via `getSecretKey` before deletion) | +| `WalletCard.tsx` | `getSecretKey` (View Details dialog), `deleteKey` (password verified via `getSecretKey` before deletion) | | `Settings.tsx` | `increaseDerivationIndex` (when hardened keys enabled) | | `Offers.tsx` | `cancelOffers` (Cancel All Active) | | `OfferRowCard.tsx` | `cancelOffer` (individual offer cancel) | diff --git a/docs/superpowers/specs/2026-03-16-protection-matrix.md b/docs/superpowers/specs/2026-03-16-protection-matrix.md index 114ccd90d..49c7b1b30 100644 --- a/docs/superpowers/specs/2026-03-16-protection-matrix.md +++ b/docs/superpowers/specs/2026-03-16-protection-matrix.md @@ -8,7 +8,7 @@ Password enforcement happens at two distinct layers, depending on `auto_submit`: 1. **ConfirmationDialog (centralized)** — When `auto_submit: false` (the default for all UI operations), the Rust `transact()` function ignores the password entirely and returns unsigned coin spends. The user reviews the transaction in `ConfirmationDialog`, whose Submit button calls `requestPassword` → `signCoinSpends` → `submitTransaction`. This is the single enforcement point for all normal UI transaction flows. -2. **Direct `requestPassword` (per-call-site)** — WalletConnect handlers set `auto_submit: true`, which means signing happens inside the Rust command itself. These must call `requestPassword` and pass the password to the command directly. +2. **Direct `requestPassword` (per-call-site)** — WalletConnect handlers set `auto_submit: true`, which means signing happens inside the Rust command itself. These call `requestPassword` and pass the password to the command directly. Password and biometric are mutually exclusive. `requestPassword(hasPassword)` routes to password dialog OR biometric prompt, never both. A wallet with a password set never triggers biometric. @@ -16,60 +16,60 @@ Password and biometric are mutually exclusive. `requestPassword(hasPassword)` ro Legend: ✅ = protected, 🔄 = redundant double-prompt, ⚠️ = bug, ❌ = not protected -| Operation | Protected | Mechanism | Status | Call site | -| --------------------------------------------------- | :-------: | -------------------------------------- | :----: | ------------------------------------------------------------------------ | -| **Transaction Operations (via ConfirmationDialog)** | | | | | -| Send XCH | ✅ | ConfirmationDialog | OK | `Send.tsx:188` | -| Send CAT | ✅ | ConfirmationDialog | OK | `Send.tsx:206` | -| Bulk send XCH | ✅ | ConfirmationDialog | OK | `Send.tsx:181` | -| Bulk send CAT | ✅ | ConfirmationDialog | OK | `Send.tsx:198` | -| Combine coins | ✅ | ConfirmationDialog (via Token.tsx) | OK | `OwnedCoinsCard.tsx:261` | -| Split coins | ✅ | ConfirmationDialog (via Token.tsx) | OK | `OwnedCoinsCard.tsx:310` | -| Auto-combine XCH/CAT | ✅ | ConfirmationDialog (via Token.tsx) | OK | `OwnedCoinsCard.tsx:363` | -| Issue CAT | ✅ | ConfirmationDialog | OK | `IssueToken.tsx:48` | -| Multi-send | — | No frontend binding | N/A | Rust-only; no TypeScript binding or UI | -| Sign coin spends (Sign button) | ✅ | Direct `requestPassword` | OK | `ConfirmationDialog.tsx:520` | -| Sign coin spends (Submit button) | ✅ | Direct `requestPassword` | OK | `ConfirmationDialog.tsx:627` | -| **NFTs / DIDs** | | | | | -| Bulk mint NFTs | ✅ | ConfirmationDialog | OK | `MintNft.tsx:140` | -| Transfer NFTs | ✅ | ConfirmationDialog | OK | `MultiSelectActions.tsx:135` | -| Burn NFTs | ✅ | ConfirmationDialog | OK | `MultiSelectActions.tsx:168` | -| Add NFT URI | ✅ | ConfirmationDialog | OK | `NftCard.tsx:236` | -| Assign NFTs to DID | ✅ | ConfirmationDialog | OK | `MultiSelectActions.tsx:152` | -| Create DID | ✅ | ConfirmationDialog | OK | `CreateProfile.tsx:46` | -| Transfer DIDs | ✅ | ConfirmationDialog | OK | `DidList.tsx:166` | -| Burn DIDs | ✅ | ConfirmationDialog | OK | `DidList.tsx:182` | -| Normalize DIDs | ✅ | ConfirmationDialog | OK | `DidList.tsx:198` | -| **Options** | | | | | -| Mint option | ✅ | ConfirmationDialog | OK | `MintOption.tsx:91` | -| Transfer options | ✅ | ConfirmationDialog | OK | `useOptionActions.tsx:63` | -| Exercise options | ✅ | ConfirmationDialog | OK | `useOptionActions.tsx:43` | -| Burn options | ✅ | ConfirmationDialog | OK | `useOptionActions.tsx:83` | -| **Clawback** | | | | | -| Claw back coins | ✅ | ConfirmationDialog (via Token.tsx) | OK | `ClawbackCoinsCard.tsx:215` | -| Finalize clawback | ✅ | ConfirmationDialog (via Token.tsx) | OK | `ClawbackCoinsCard.tsx:260` | -| **Offers** | | | | | -| Make offer (split-NFT path) | ✅ | Direct `requestPassword` | OK | `useOfferProcessor.ts:116` — password forwarded | -| Make offer (single/non-split) | ✅ | Direct `requestPassword` | OK | `useOfferProcessor.ts:160` — fixed: password now forwarded | -| Take offer | ✅ | ConfirmationDialog | OK | `Offer.tsx` — fixed: removed redundant pre-prompt | -| Cancel offer | ✅ | ConfirmationDialog | OK | `OfferRowCard.tsx` — fixed: removed redundant pre-prompt | -| Cancel all offers | ✅ | ConfirmationDialog | OK | `Offers.tsx` — fixed: removed redundant pre-prompt | -| **Secrets / Key Management** | | | | | -| View mnemonic / secret key | ✅ | Direct `requestPassword` | OK | `WalletCard.tsx:179` | -| Delete wallet key | ✅ | `requestPassword` + `getSecretKey` verify | OK | `WalletCard.tsx:82` — password verified via decryption before delete | -| Import key (secret/mnemonic) | ✅ | Password set at import time | OK | Encrypt-at-import only | -| Set / Change / Remove password | ✅ | Inline form (not `requestPassword`) | OK | `Settings.tsx:1238` | -| **Key Derivation** | | | | | -| Increase derivation (hardened) | ✅ | Direct `requestPassword` | OK | `Settings.tsx:1274` | -| Increase derivation (unhardened) | ❌ | None | OK | No private key needed | -| **Other Privileged Actions** | | | | | -| Enable/disable biometric toggle | ✅ | `requestPassword(false)` | OK | `Settings.tsx:972/989` | -| Start/stop RPC server | ✅ | `requestPassword(false)` | OK | `Settings.tsx:975/993` | -| **Unprotected (by design)** | | | | | -| View balances / addresses / NFTs | ❌ | None | OK | Read-only | -| Submit pre-signed transaction | ❌ | None | OK | No key access needed | -| Login / logout wallet | ❌ | None | OK | No secret access | -| Rename / resync / emoji | ❌ | None | OK | Metadata only | +| Operation | Protected | Mechanism | Status | Call site | +| --------------------------------------------------- | :-------: | ----------------------------------------- | :----: | -------------------------------------------------------------------- | +| **Transaction Operations (via ConfirmationDialog)** | | | | | +| Send XCH | ✅ | ConfirmationDialog | OK | `Send.tsx:188` | +| Send CAT | ✅ | ConfirmationDialog | OK | `Send.tsx:206` | +| Bulk send XCH | ✅ | ConfirmationDialog | OK | `Send.tsx:181` | +| Bulk send CAT | ✅ | ConfirmationDialog | OK | `Send.tsx:198` | +| Combine coins | ✅ | ConfirmationDialog (via Token.tsx) | OK | `OwnedCoinsCard.tsx:261` | +| Split coins | ✅ | ConfirmationDialog (via Token.tsx) | OK | `OwnedCoinsCard.tsx:310` | +| Auto-combine XCH/CAT | ✅ | ConfirmationDialog (via Token.tsx) | OK | `OwnedCoinsCard.tsx:363` | +| Issue CAT | ✅ | ConfirmationDialog | OK | `IssueToken.tsx:48` | +| Multi-send | — | No frontend binding | N/A | Rust-only; no TypeScript binding or UI | +| Sign coin spends (Sign button) | ✅ | Direct `requestPassword` | OK | `ConfirmationDialog.tsx:520` | +| Sign coin spends (Submit button) | ✅ | Direct `requestPassword` | OK | `ConfirmationDialog.tsx:627` | +| **NFTs / DIDs** | | | | | +| Bulk mint NFTs | ✅ | ConfirmationDialog | OK | `MintNft.tsx:140` | +| Transfer NFTs | ✅ | ConfirmationDialog | OK | `MultiSelectActions.tsx:135` | +| Burn NFTs | ✅ | ConfirmationDialog | OK | `MultiSelectActions.tsx:168` | +| Add NFT URI | ✅ | ConfirmationDialog | OK | `NftCard.tsx:236` | +| Assign NFTs to DID | ✅ | ConfirmationDialog | OK | `MultiSelectActions.tsx:152` | +| Create DID | ✅ | ConfirmationDialog | OK | `CreateProfile.tsx:46` | +| Transfer DIDs | ✅ | ConfirmationDialog | OK | `DidList.tsx:166` | +| Burn DIDs | ✅ | ConfirmationDialog | OK | `DidList.tsx:182` | +| Normalize DIDs | ✅ | ConfirmationDialog | OK | `DidList.tsx:198` | +| **Options** | | | | | +| Mint option | ✅ | ConfirmationDialog | OK | `MintOption.tsx:91` | +| Transfer options | ✅ | ConfirmationDialog | OK | `useOptionActions.tsx:63` | +| Exercise options | ✅ | ConfirmationDialog | OK | `useOptionActions.tsx:43` | +| Burn options | ✅ | ConfirmationDialog | OK | `useOptionActions.tsx:83` | +| **Clawback** | | | | | +| Claw back coins | ✅ | ConfirmationDialog (via Token.tsx) | OK | `ClawbackCoinsCard.tsx:215` | +| Finalize clawback | ✅ | ConfirmationDialog (via Token.tsx) | OK | `ClawbackCoinsCard.tsx:260` | +| **Offers** | | | | | +| Make offer (split-NFT path) | ✅ | Direct `requestPassword` | OK | `useOfferProcessor.ts:116` — password forwarded | +| Make offer (single/non-split) | ✅ | Direct `requestPassword` | OK | `useOfferProcessor.ts:160` — fixed: password now forwarded | +| Take offer | ✅ | ConfirmationDialog | OK | `Offer.tsx` — fixed: removed redundant pre-prompt | +| Cancel offer | ✅ | ConfirmationDialog | OK | `OfferRowCard.tsx` — fixed: removed redundant pre-prompt | +| Cancel all offers | ✅ | ConfirmationDialog | OK | `Offers.tsx` — fixed: removed redundant pre-prompt | +| **Secrets / Key Management** | | | | | +| View mnemonic / secret key | ✅ | Direct `requestPassword` | OK | `WalletCard.tsx:179` | +| Delete wallet key | ✅ | `requestPassword` + `getSecretKey` verify | OK | `WalletCard.tsx:82` — password verified via decryption before delete | +| Import key (secret/mnemonic) | ✅ | Password set at import time | OK | Encrypt-at-import only | +| Set / Change / Remove password | ✅ | Inline form (not `requestPassword`) | OK | `Settings.tsx:1238` | +| **Key Derivation** | | | | | +| Increase derivation (hardened) | ✅ | Direct `requestPassword` | OK | `Settings.tsx:1274` | +| Increase derivation (unhardened) | ❌ | None | OK | No private key needed | +| **Other Privileged Actions** | | | | | +| Enable/disable biometric toggle | ✅ | `requestPassword(false)` | OK | `Settings.tsx:972/989` | +| Start/stop RPC server | ✅ | `requestPassword(false)` | OK | `Settings.tsx:975/993` | +| **Unprotected (by design)** | | | | | +| View balances / addresses / NFTs | ❌ | None | OK | Read-only | +| Submit pre-signed transaction | ❌ | None | OK | No key access needed | +| Login / logout wallet | ❌ | None | OK | No secret access | +| Rename / resync / emoji | ❌ | None | OK | Metadata only | ## Matrix — WalletConnect Operations @@ -87,8 +87,6 @@ All WC handlers use `auto_submit: true` (except `signCoinSpends`), so password i | `chia_signMessageByAddress` | ✅ | N/A | OK | `high-level.ts` | | WC read-only (connect, chainId, etc.) | ❌ | N/A | OK | No signing | - ## Remaining Design Considerations 1. **Session caching asymmetry** — biometric caches for 5 minutes; password never caches. - From a3195ce821f94611211f2f049bfbe8d33248a15b Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 16 Mar 2026 19:02:30 -0500 Subject: [PATCH 50/53] prettier --- docs/superpowers/specs/2026-03-16-protection-matrix.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-03-16-protection-matrix.md b/docs/superpowers/specs/2026-03-16-protection-matrix.md index 49c7b1b30..964e16775 100644 --- a/docs/superpowers/specs/2026-03-16-protection-matrix.md +++ b/docs/superpowers/specs/2026-03-16-protection-matrix.md @@ -55,17 +55,15 @@ Legend: ✅ = protected, 🔄 = redundant double-prompt, ⚠️ = bug, ❌ = not | Cancel offer | ✅ | ConfirmationDialog | OK | `OfferRowCard.tsx` — fixed: removed redundant pre-prompt | | Cancel all offers | ✅ | ConfirmationDialog | OK | `Offers.tsx` — fixed: removed redundant pre-prompt | | **Secrets / Key Management** | | | | | -| View mnemonic / secret key | ✅ | Direct `requestPassword` | OK | `WalletCard.tsx:179` | +| View mnemonic / secret key | ✅ | Direct `requestPassword` | OK | `WalletCard.tsx:194` | | Delete wallet key | ✅ | `requestPassword` + `getSecretKey` verify | OK | `WalletCard.tsx:82` — password verified via decryption before delete | | Import key (secret/mnemonic) | ✅ | Password set at import time | OK | Encrypt-at-import only | | Set / Change / Remove password | ✅ | Inline form (not `requestPassword`) | OK | `Settings.tsx:1238` | | **Key Derivation** | | | | | -| Increase derivation (hardened) | ✅ | Direct `requestPassword` | OK | `Settings.tsx:1274` | +| Increase derivation (hardened) | ✅ | Direct `requestPassword` | OK | `Settings.tsx:1269` | | Increase derivation (unhardened) | ❌ | None | OK | No private key needed | -| **Other Privileged Actions** | | | | | -| Enable/disable biometric toggle | ✅ | `requestPassword(false)` | OK | `Settings.tsx:972/989` | -| Start/stop RPC server | ✅ | `requestPassword(false)` | OK | `Settings.tsx:975/993` | | **Unprotected (by design)** | | | | | +| Enable/disable biometric toggle | ❌ | None | OK | No-op on password-protected wallets (mutual exclusivity) | | View balances / addresses / NFTs | ❌ | None | OK | Read-only | | Submit pre-signed transaction | ❌ | None | OK | No key access needed | | Login / logout wallet | ❌ | None | OK | No secret access | From bc56bab81b16b09ca33a4b94e45ed6d2c88a8b7a Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 20 Mar 2026 17:13:33 -0500 Subject: [PATCH 51/53] factor out new password dialog --- .../dialogs/PasswordManagementDialog.tsx | 193 +++++++++++++++++ src/pages/Settings.tsx | 205 +++--------------- 2 files changed, 221 insertions(+), 177 deletions(-) create mode 100644 src/components/dialogs/PasswordManagementDialog.tsx diff --git a/src/components/dialogs/PasswordManagementDialog.tsx b/src/components/dialogs/PasswordManagementDialog.tsx new file mode 100644 index 000000000..0c8acba47 --- /dev/null +++ b/src/components/dialogs/PasswordManagementDialog.tsx @@ -0,0 +1,193 @@ +import { CustomError } from '@/contexts/ErrorContext'; +import { commands } from '@/bindings'; +import { t } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import { LoaderCircleIcon } from 'lucide-react'; +import { useState } from 'react'; +import { Button } from '../ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../ui/dialog'; +import { Input } from '../ui/input'; +import { Label } from '../ui/label'; + +export type PasswordDialogMode = 'set' | 'change' | 'remove'; + +export interface PasswordManagementDialogProps { + open: boolean; + mode: PasswordDialogMode; + fingerprint: number; + onClose: () => void; + onSuccess: () => void; +} + +export function PasswordManagementDialog({ + open, + mode, + fingerprint, + onClose, + onSuccess, +}: PasswordManagementDialogProps) { + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + const resetState = () => { + setOldPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setError(null); + }; + + const handleClose = () => { + resetState(); + onClose(); + }; + + const handleSubmit = async () => { + setError(null); + + if (mode !== 'remove' && newPassword !== confirmPassword) { + setError(t`Passwords do not match`); + return; + } + + if (mode !== 'remove' && newPassword.length === 0) { + setError(t`Password cannot be empty`); + return; + } + + setPending(true); + try { + await commands.changePassword({ + fingerprint, + old_password: mode === 'set' ? '' : oldPassword, + new_password: mode === 'remove' ? '' : newPassword, + }); + + resetState(); + onSuccess(); + } catch (err) { + const customErr = err as CustomError; + setError(customErr.reason || t`Incorrect password`); + } finally { + setPending(false); + } + }; + + return ( + !isOpen && handleClose()}> + + + + {mode === 'set' && Set Password} + {mode === 'change' && Change Password} + {mode === 'remove' && Remove Password} + + + {mode === 'set' && ( + + Set a password to protect transaction signing and secret key + access. There is no way to recover a lost password. + + )} + {mode === 'change' && ( + Enter your current password and choose a new one. + )} + {mode === 'remove' && ( + + Enter your current password to remove password protection. Your + wallet secrets will no longer require a password. + + )} + + +
+ {mode !== 'set' && ( +
+ + setOldPassword(e.target.value)} + placeholder={t`Enter current password`} + onKeyDown={(e) => { + if (e.key === 'Enter' && mode === 'remove') { + e.preventDefault(); + handleSubmit(); + } + }} + /> +
+ )} + {mode !== 'remove' && ( + <> +
+ + setNewPassword(e.target.value)} + placeholder={t`Enter password`} + /> +
+
+ + setConfirmPassword(e.target.value)} + placeholder={t`Confirm password`} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSubmit(); + } + }} + /> +
+ + )} + {error &&

{error}

} +
+ + + + +
+
+ ); +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 1025efa14..b78ccf4c9 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,4 +1,8 @@ import Container from '@/components/Container'; +import { + PasswordManagementDialog, + PasswordDialogMode, +} from '@/components/dialogs/PasswordManagementDialog'; import { ResyncDialog } from '@/components/dialogs/ResyncDialog'; import Header from '@/components/Header'; import Layout from '@/components/Layout'; @@ -1054,14 +1058,8 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { const [unhardened, setUnhardened] = useState(true); const [hardened, setHardened] = useState(true); const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); - const [passwordDialogMode, setPasswordDialogMode] = useState< - 'set' | 'change' | 'remove' - >('set'); - const [oldPassword, setOldPassword] = useState(''); - const [newPassword, setNewPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [passwordPending, setPasswordPending] = useState(false); - const [passwordError, setPasswordError] = useState(null); + const [passwordDialogMode, setPasswordDialogMode] = + useState('set'); const saveChangeAddress = (address: string) => { const trimmedAddress = address.trim(); @@ -1211,56 +1209,26 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { }, }); - const openPasswordDialog = (mode: 'set' | 'change' | 'remove') => { + const openPasswordDialog = (mode: PasswordDialogMode) => { setPasswordDialogMode(mode); - setOldPassword(''); - setNewPassword(''); - setConfirmPassword(''); - setPasswordError(null); setPasswordDialogOpen(true); }; - const handlePasswordSubmit = async () => { - setPasswordError(null); - - if (passwordDialogMode !== 'remove' && newPassword !== confirmPassword) { - setPasswordError(t`Passwords do not match`); - return; - } - - if (passwordDialogMode !== 'remove' && newPassword.length === 0) { - setPasswordError(t`Password cannot be empty`); - return; - } - - setPasswordPending(true); - try { - await commands.changePassword({ - fingerprint, - old_password: passwordDialogMode === 'set' ? '' : oldPassword, - new_password: passwordDialogMode === 'remove' ? '' : newPassword, - }); - - setPasswordDialogOpen(false); - - // Refresh key info to update has_password in local and global state - const data = await commands.getKey({ fingerprint }); - setKey(data.key); - setGlobalWallet(data.key); - - toast.success( - passwordDialogMode === 'set' - ? t`Password set successfully` - : passwordDialogMode === 'change' - ? t`Password changed successfully` - : t`Password removed successfully`, - ); - } catch (error) { - const err = error as CustomError; - setPasswordError(err.reason || t`Incorrect password`); - } finally { - setPasswordPending(false); - } + const handlePasswordSuccess = async () => { + setPasswordDialogOpen(false); + + // Refresh key info to update has_password in local and global state + const data = await commands.getKey({ fingerprint }); + setKey(data.key); + setGlobalWallet(data.key); + + toast.success( + passwordDialogMode === 'set' + ? t`Password set successfully` + : passwordDialogMode === 'change' + ? t`Password changed successfully` + : t`Password removed successfully`, + ); }; const handler = async (values: z.infer) => { @@ -1663,130 +1631,13 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { }} /> - !open && setPasswordDialogOpen(false)} - > - - - - {passwordDialogMode === 'set' && Set Password} - {passwordDialogMode === 'change' && ( - Change Password - )} - {passwordDialogMode === 'remove' && ( - Remove Password - )} - - - {passwordDialogMode === 'set' && ( - - Set a password to protect transaction signing and secret key - access. There is no way to recover a lost password. - - )} - {passwordDialogMode === 'change' && ( - Enter your current password and choose a new one. - )} - {passwordDialogMode === 'remove' && ( - - Enter your current password to remove password protection. - Your wallet secrets will no longer require a password. - - )} - - -
- {passwordDialogMode !== 'set' && ( -
- - setOldPassword(e.target.value)} - placeholder={t`Enter current password`} - onKeyDown={(e) => { - if (e.key === 'Enter' && passwordDialogMode === 'remove') { - e.preventDefault(); - handlePasswordSubmit(); - } - }} - /> -
- )} - {passwordDialogMode !== 'remove' && ( - <> -
- - setNewPassword(e.target.value)} - placeholder={t`Enter password`} - /> -
-
- - setConfirmPassword(e.target.value)} - placeholder={t`Confirm password`} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handlePasswordSubmit(); - } - }} - /> -
- - )} - {passwordError && ( -

{passwordError}

- )} -
- - - - -
-
+ mode={passwordDialogMode} + fingerprint={fingerprint} + onClose={() => setPasswordDialogOpen(false)} + onSuccess={handlePasswordSuccess} + /> From 0fdff0e25770588db40b5dccedd8418922e2f1c7 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 20 Mar 2026 17:24:22 -0500 Subject: [PATCH 52/53] require seed phrase or key to create password --- .../dialogs/PasswordManagementDialog.tsx | 139 +++++++++++++++++- 1 file changed, 131 insertions(+), 8 deletions(-) diff --git a/src/components/dialogs/PasswordManagementDialog.tsx b/src/components/dialogs/PasswordManagementDialog.tsx index 0c8acba47..6daae870a 100644 --- a/src/components/dialogs/PasswordManagementDialog.tsx +++ b/src/components/dialogs/PasswordManagementDialog.tsx @@ -2,8 +2,9 @@ import { CustomError } from '@/contexts/ErrorContext'; import { commands } from '@/bindings'; import { t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; -import { LoaderCircleIcon } from 'lucide-react'; +import { AlertTriangleIcon, LoaderCircleIcon } from 'lucide-react'; import { useState } from 'react'; +import { Alert, AlertDescription, AlertTitle } from '../ui/alert'; import { Button } from '../ui/button'; import { Dialog, @@ -15,6 +16,7 @@ import { } from '../ui/dialog'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; +import { Textarea } from '../ui/textarea'; export type PasswordDialogMode = 'set' | 'change' | 'remove'; @@ -26,6 +28,24 @@ export interface PasswordManagementDialogProps { onSuccess: () => void; } +function normalizeKey(key: string): string { + return key + .trim() + .replace(/[^a-z]/gi, ' ') + .split(/\s+/) + .filter((item) => item.length > 0) + .join(' ') + .toLowerCase(); +} + +function normalizeHex(hex: string): string { + let h = hex.trim().toLowerCase(); + if (h.startsWith('0x')) { + h = h.slice(2); + } + return h; +} + export function PasswordManagementDialog({ open, mode, @@ -36,13 +56,16 @@ export function PasswordManagementDialog({ const [oldPassword, setOldPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); + const [verificationKey, setVerificationKey] = useState(''); const [pending, setPending] = useState(false); + const [verifying, setVerifying] = useState(false); const [error, setError] = useState(null); const resetState = () => { setOldPassword(''); setNewPassword(''); setConfirmPassword(''); + setVerificationKey(''); setError(null); }; @@ -51,6 +74,44 @@ export function PasswordManagementDialog({ onClose(); }; + const verifyBackupKey = async (): Promise => { + setVerifying(true); + try { + const response = await commands.getSecretKey({ + fingerprint, + }); + + if (!response.secrets) { + setError(t`Unable to retrieve wallet secrets for verification.`); + return false; + } + + const input = verificationKey.trim(); + const { mnemonic, secret_key } = response.secrets; + + // Check if input matches the mnemonic + if (mnemonic && normalizeKey(input) === normalizeKey(mnemonic)) { + return true; + } + + // Check if input matches the secret key + if (normalizeHex(input) === normalizeHex(secret_key)) { + return true; + } + + setError( + t`The seed phrase or secret key you entered does not match this wallet. Please verify and try again.`, + ); + return false; + } catch (err) { + const customErr = err as CustomError; + setError(customErr.reason || t`Failed to verify key. Please try again.`); + return false; + } finally { + setVerifying(false); + } + }; + const handleSubmit = async () => { setError(null); @@ -64,6 +125,18 @@ export function PasswordManagementDialog({ return; } + if (mode === 'set') { + if (verificationKey.trim().length === 0) { + setError( + t`You must enter your seed phrase or secret key to verify you have a backup before setting a password.`, + ); + return; + } + + const verified = await verifyBackupKey(); + if (!verified) return; + } + setPending(true); try { await commands.changePassword({ @@ -95,7 +168,7 @@ export function PasswordManagementDialog({ {mode === 'set' && ( Set a password to protect transaction signing and secret key - access. There is no way to recover a lost password. + access. )} {mode === 'change' && ( @@ -110,6 +183,22 @@ export function PasswordManagementDialog({
+ {mode === 'set' && ( + + + + No password recovery + + + + If you lose or forget this password, your wallet and its keys + cannot be recovered without your seed phrase or secret key. + Make sure they are safely stored before continuing. + + + + )} + {mode !== 'set' && (
)} + + {mode === 'set' && ( +
+ +