From 7573ac98a8e750f4c3364c8df4824361d1a948cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Hadam=C4=8Dik?= Date: Thu, 20 Aug 2026 18:57:55 +0200 Subject: [PATCH 01/36] fix(apps): use secure scheme on Windows --- crates/sage-apps/src/runtime/resolve.rs | 16 ++++++++-------- crates/sage-apps/src/runtime/start.rs | 1 + 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/sage-apps/src/runtime/resolve.rs b/crates/sage-apps/src/runtime/resolve.rs index 5180b8afe..88a2a7f5c 100644 --- a/crates/sage-apps/src/runtime/resolve.rs +++ b/crates/sage-apps/src/runtime/resolve.rs @@ -87,11 +87,11 @@ fn is_allowed_app_origin_url(url: &Url, protocol_scheme: &str, origin_id: &str) #[cfg(any(target_os = "windows", test))] fn is_webview2_mapped_app_origin(url: &Url, protocol_scheme: &str, origin_id: &str) -> bool { // WebView2 cannot navigate directly to custom protocols, so Wry maps - // `{scheme}://{host}` to `http://{scheme}.{host}` on Windows. Navigation + // `{scheme}://{host}` to `https://{scheme}.{host}` on Windows. Navigation // callbacks and the current URL expose that mapped URL even though custom // protocol requests are converted back before they reach Sage. let webview2_host = format!("{protocol_scheme}.{origin_id}"); - url.scheme() == "http" && url.port().is_none() && url.host_str() == Some(&webview2_host) + url.scheme() == "https" && url.port().is_none() && url.host_str() == Some(&webview2_host) } pub fn build_entry_src_for( @@ -257,7 +257,7 @@ mod tests { fn allows_the_webview2_mapped_origin_only_on_windows() { assert_eq!( is_allowed_app_origin_url( - &url("http://sage-system-app.task-manager/index.html"), + &url("https://sage-system-app.task-manager/index.html"), "sage-system-app", "task-manager", ), @@ -268,17 +268,17 @@ mod tests { #[test] fn webview2_mapping_matches_only_the_exact_origin() { assert!(is_webview2_mapped_app_origin( - &url("http://sage-system-app.task-manager/index.html"), + &url("https://sage-system-app.task-manager/index.html"), "sage-system-app", "task-manager" )); for candidate in [ - "https://sage-system-app.task-manager/index.html", + "http://sage-system-app.task-manager/index.html", "http://sage-system-app.task-manager.invalid/index.html", - "http://sage-system-app.other/index.html", - "http://other.task-manager/index.html", - "http://sage-system-app.task-manager:444/index.html", + "https://sage-system-app.other/index.html", + "https://other.task-manager/index.html", + "https://sage-system-app.task-manager:444/index.html", ] { assert!( !is_webview2_mapped_app_origin(&url(candidate), "sage-system-app", "task-manager"), diff --git a/crates/sage-apps/src/runtime/start.rs b/crates/sage-apps/src/runtime/start.rs index fd684648e..5439b4ad5 100644 --- a/crates/sage-apps/src/runtime/start.rs +++ b/crates/sage-apps/src/runtime/start.rs @@ -195,6 +195,7 @@ async fn create_runtime_for_app( webview_label.clone(), WebviewUrl::CustomProtocol(build_entry_src(&app, args.query.clone())), ) + .use_https_scheme(cfg!(target_os = "windows")) .transparent(!cfg!(target_os = "linux") || !is_modal) .on_navigation(move |url| { runtime_for_nav.with_runtime(|runtime| is_allowed_app_url(url, &runtime.app())) From d8a929d54662230244bd86b8e808e4133aa6de07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Hadam=C4=8Dik?= Date: Thu, 20 Aug 2026 19:20:44 +0200 Subject: [PATCH 02/36] feat(apps): allow approved image origins in CSP --- crates/sage-apps/src/security/csp.rs | 44 +++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/crates/sage-apps/src/security/csp.rs b/crates/sage-apps/src/security/csp.rs index ae95d6de8..36bb66e35 100644 --- a/crates/sage-apps/src/security/csp.rs +++ b/crates/sage-apps/src/security/csp.rs @@ -8,6 +8,11 @@ fn csp_source_list(items: &[String]) -> String { pub fn build_app_csp(app: &SharedSageApp, network_id: &str) -> String { let mut connect_sources = BTreeSet::from(["'self'".to_string()]); + let mut image_sources = BTreeSet::from([ + "'self'".to_string(), + "blob:".to_string(), + "data:".to_string(), + ]); app.with(|app| { for entry in app @@ -15,7 +20,12 @@ pub fn build_app_csp(app: &SharedSageApp, network_id: &str) -> String { .network() .effective_whitelist_for_network(network_id) { - connect_sources.insert(entry.as_permission_string()); + let source = entry.as_permission_string(); + connect_sources.insert(source.clone()); + + if entry.scheme() == "https" { + image_sources.insert(source); + } } }); @@ -24,11 +34,7 @@ pub fn build_app_csp(app: &SharedSageApp, network_id: &str) -> String { let default_src = csp_source_list(&["'self'".to_string()]); let font_src = csp_source_list(&["'self'".to_string(), "data:".to_string()]); let frame_src = csp_source_list(&["'none'".to_string()]); - let img_src = csp_source_list(&[ - "'self'".to_string(), - "data:".to_string(), - "blob:".to_string(), - ]); + let img_src = csp_source_list(&image_sources.into_iter().collect::>()); let manifest_src = csp_source_list(&["'none'".to_string()]); let media_src = csp_source_list(&[ "'self'".to_string(), @@ -105,13 +111,14 @@ mod tests { fn app_with_network_grants() -> (SharedSageApp, TempDir) { let shared = entry("https", "shared.example.com"); + let shared_websocket = entry("wss", "events.example.com"); let mainnet = entry("https", "mainnet.example.com"); let testnet = entry("https", "testnet.example.com"); let requested = SageRequestedPermissions::new( SageRequestedNetworkPermissions::new( [], - [shared.clone()], + [shared.clone(), shared_websocket.clone()], [ ( "mainnet".to_string(), @@ -131,7 +138,7 @@ mod tests { let granted = SageGrantedPermissions::new( &requested, [], - [shared], + [shared, shared_websocket], BTreeMap::from([ ("mainnet".to_string(), BTreeSet::from([mainnet])), ("testnet11".to_string(), BTreeSet::from([testnet])), @@ -176,4 +183,25 @@ mod tests { assert!(testnet_csp.contains("https://testnet.example.com")); assert!(!testnet_csp.contains("https://mainnet.example.com")); } + + #[test] + fn csp_img_src_allows_https_sources_for_the_active_network() { + let (app, _dir) = app_with_network_grants(); + + let mainnet_csp = build_app_csp(&app, "mainnet"); + let img_src = mainnet_csp + .split(';') + .map(str::trim) + .find_map(|directive| directive.strip_prefix("img-src ")) + .expect("CSP should contain img-src"); + let sources = img_src.split_ascii_whitespace().collect::>(); + + assert!(sources.contains("'self'")); + assert!(sources.contains("blob:")); + assert!(sources.contains("data:")); + assert!(sources.contains("https://shared.example.com")); + assert!(sources.contains("https://mainnet.example.com")); + assert!(!sources.contains("https://testnet.example.com")); + assert!(!sources.contains("wss://events.example.com")); + } } From 311ab2930f36b5a042acf85e9147926e2f15edb0 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 18:25:29 -0500 Subject: [PATCH 03/36] feat(password-gate): add testable password resolve loop --- Cargo.lock | 18 ++ Cargo.toml | 1 + crates/sage-password-gate/Cargo.toml | 23 +++ crates/sage-password-gate/src/lib.rs | 27 +++ crates/sage-password-gate/src/prompter.rs | 26 +++ crates/sage-password-gate/src/resolve.rs | 192 ++++++++++++++++++++++ crates/sage-password-gate/src/types.rs | 36 ++++ src-tauri/Cargo.toml | 1 + src-tauri/src/error.rs | 9 + src-tauri/src/lib.rs | 1 + 10 files changed, 334 insertions(+) create mode 100644 crates/sage-password-gate/Cargo.toml create mode 100644 crates/sage-password-gate/src/lib.rs create mode 100644 crates/sage-password-gate/src/prompter.rs create mode 100644 crates/sage-password-gate/src/resolve.rs create mode 100644 crates/sage-password-gate/src/types.rs diff --git a/Cargo.lock b/Cargo.lock index e36cb9f2c..69717a7e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6664,6 +6664,23 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "sage-password-gate" +version = "0.1.0" +dependencies = [ + "async-trait", + "bip39", + "sage", + "sage-api", + "sage-keychain", + "serde", + "specta", + "tauri", + "tauri-specta", + "tokio", + "uuid", +] + [[package]] name = "sage-rpc" version = "0.13.0" @@ -6706,6 +6723,7 @@ dependencies = [ "sage-api-macro", "sage-apps", "sage-config", + "sage-password-gate", "sage-rpc", "sage-wallet", "serde", diff --git a/Cargo.toml b/Cargo.toml index 8322c24b7..445834ae8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ sage-wallet = { path = "./crates/sage-wallet" } sage-assets = { path = "./crates/sage-assets" } sage-apps = { path = "./crates/sage-apps" } sage-rpc = { path = "./crates/sage-rpc" } +sage-password-gate = { path = "./crates/sage-password-gate" } # Serialization serde = "1.0.204" diff --git a/crates/sage-password-gate/Cargo.toml b/crates/sage-password-gate/Cargo.toml new file mode 100644 index 000000000..51a4e2196 --- /dev/null +++ b/crates/sage-password-gate/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "sage-password-gate" +version = "0.1.0" +edition = "2024" + +[lints] +workspace = true + +[dependencies] +sage = { workspace = true } +sage-api = { workspace = true, features = ["tauri"] } +sage-keychain = { workspace = true } +async-trait = "0.1.89" +serde = { workspace = true, features = ["derive"] } +specta = { workspace = true } +tauri = { workspace = true } +tauri-specta = { workspace = true } +tokio = { workspace = true, features = ["sync"] } +uuid = { version = "1.19.0", features = ["v4"] } + +[dev-dependencies] +bip39 = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync"] } diff --git a/crates/sage-password-gate/src/lib.rs b/crates/sage-password-gate/src/lib.rs new file mode 100644 index 000000000..6e90a557f --- /dev/null +++ b/crates/sage-password-gate/src/lib.rs @@ -0,0 +1,27 @@ +mod prompter; +mod resolve; +mod types; + +use sage_api::ErrorKind; + +pub use prompter::{PasswordVerifier, Prompter}; +pub use resolve::{CANCELLED_REASON, MAX_ATTEMPTS, resolve_with}; +pub use types::{PasswordAttemptError, PasswordOutcome, PasswordRequest}; + +/// This crate's error. Structurally identical to `sage-tauri`'s `Error`, so the +/// host converts with a trivial `From` impl. +#[derive(Debug, Clone)] +pub struct Error { + pub kind: ErrorKind, + pub reason: String, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.reason) + } +} + +impl std::error::Error for Error {} + +pub type Result = std::result::Result; diff --git a/crates/sage-password-gate/src/prompter.rs b/crates/sage-password-gate/src/prompter.rs new file mode 100644 index 000000000..74f8eba7a --- /dev/null +++ b/crates/sage-password-gate/src/prompter.rs @@ -0,0 +1,26 @@ +use async_trait::async_trait; + +use super::types::{PasswordOutcome, PasswordRequest}; +use crate::Result; + +/// Abstracts the round-trip to the frontend so the resolve loop can be +/// unit-tested without a running Tauri app. +#[async_trait] +pub trait Prompter: Send + Sync { + async fn prompt(&self, request: PasswordRequest) -> Result; +} + +/// Checks a candidate password against the keychain. +/// +/// This is a trait rather than a borrowed `&Keychain` on purpose. `Keychain` +/// is not `Clone` and owns a `ChaCha20Rng`; cloning it to escape the app lock +/// would duplicate an RNG stream, which is a nonce-reuse hazard the moment a +/// clone ever encrypts. Instead the production implementation takes the Sage +/// lock briefly for each attempt and drops it before the next prompt, so no +/// lock is ever held across an await. +#[async_trait] +pub trait PasswordVerifier: Send + Sync { + /// `Ok(true)` = correct, `Ok(false)` = wrong password, + /// `Err` = a genuine failure (not a wrong password). + async fn verify(&self, fingerprint: u32, password: &str) -> Result; +} diff --git a/crates/sage-password-gate/src/resolve.rs b/crates/sage-password-gate/src/resolve.rs new file mode 100644 index 000000000..c4058fd80 --- /dev/null +++ b/crates/sage-password-gate/src/resolve.rs @@ -0,0 +1,192 @@ +use sage_api::ErrorKind; + +use super::types::{PasswordAttemptError, PasswordOutcome, PasswordRequest}; +use crate::{Error, PasswordVerifier, Prompter, Result}; + +/// Maximum password entry attempts before the operation is refused. +pub const MAX_ATTEMPTS: u8 = 3; + +/// Reason string used when the user dismisses the prompt, so the frontend can +/// distinguish a deliberate cancel from a genuine auth failure and stay silent. +pub const CANCELLED_REASON: &str = "Password entry cancelled"; + +fn unauthorized(reason: &str) -> Error { + Error { kind: ErrorKind::Unauthorized, reason: reason.to_string() } +} + +/// Prompts the frontend for a password and verifies it against the keychain, +/// retrying up to `MAX_ATTEMPTS` times on an incorrect password. +/// +/// Returns `Ok(None)` when no authentication was required. The caller places +/// the returned value directly into the request's `password` field. +/// +/// This runs *before* `app_state.lock()` is taken. Verifying here keeps a wrong +/// password cheap (one keychain decrypt, no partially built transaction) and +/// avoids awaiting the frontend while holding the app lock. +pub async fn resolve_with( + prompter: &dyn Prompter, + verifier: &dyn PasswordVerifier, + fingerprint: u32, + requires_password: bool, +) -> Result> { + let mut error: Option = None; + + for attempt in 1..=MAX_ATTEMPTS { + let request = PasswordRequest { + request_id: uuid::Uuid::new_v4().to_string(), + fingerprint, + requires_password, + attempt, + error: error.take(), + }; + + match prompter.prompt(request).await? { + PasswordOutcome::NoAuthNeeded => return Ok(None), + PasswordOutcome::Cancelled => return Err(unauthorized(CANCELLED_REASON)), + PasswordOutcome::Password { password } => { + if verifier.verify(fingerprint, &password).await? { + return Ok(Some(password)); + } + error = Some(PasswordAttemptError { + attempts_remaining: MAX_ATTEMPTS - attempt, + }); + } + } + } + + Err(unauthorized("Too many incorrect password attempts")) +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use async_trait::async_trait; + use sage_api::ErrorKind; + use sage_keychain::{Keychain, KeychainError}; + + use super::*; + use crate::types::{PasswordOutcome, PasswordRequest}; + use crate::{Error, PasswordVerifier, Prompter, Result}; + + /// Replays a scripted sequence of outcomes and records what it was asked. + struct MockPrompter { + scripted: Mutex>, + seen: Mutex>, + } + + impl MockPrompter { + fn new(scripted: Vec) -> Self { + Self { + scripted: Mutex::new(scripted.into_iter().rev().collect()), + seen: Mutex::new(Vec::new()), + } + } + + fn seen(&self) -> Vec { + self.seen.lock().unwrap().clone() + } + } + + #[async_trait] + impl Prompter for MockPrompter { + async fn prompt(&self, request: PasswordRequest) -> Result { + self.seen.lock().unwrap().push(request); + Ok(self.scripted.lock().unwrap().pop().expect("prompted more times than scripted")) + } + } + + /// A verifier backed by a real keychain holding one mnemonic key + /// encrypted with `password`. + struct KeychainVerifier { + keychain: Keychain, + } + + #[async_trait] + impl PasswordVerifier for KeychainVerifier { + async fn verify(&self, fingerprint: u32, password: &str) -> Result { + match self.keychain.extract_secrets(fingerprint, password.as_bytes()) { + Ok(_) => Ok(true), + Err(KeychainError::Decrypt) => Ok(false), + Err(err) => Err(Error { kind: ErrorKind::Internal, reason: err.to_string() }), + } + } + } + + fn protected_keychain(password: &str) -> (KeychainVerifier, u32) { + let mut keychain = Keychain::default(); + let mnemonic = bip39::Mnemonic::from_entropy(&[7u8; 32]).unwrap(); + let fingerprint = keychain + .add_mnemonic(&mnemonic, password.as_bytes()) + .expect("failed to add mnemonic"); + (KeychainVerifier { keychain }, fingerprint) + } + + fn pw(s: &str) -> PasswordOutcome { + PasswordOutcome::Password { password: s.to_string() } + } + + #[tokio::test] + async fn correct_password_resolves_on_first_attempt() { + let (verifier, fingerprint) = protected_keychain("hunter2"); + let prompter = MockPrompter::new(vec![pw("hunter2")]); + + let result = resolve_with(&prompter, &verifier, fingerprint, true).await.unwrap(); + + assert_eq!(result, Some("hunter2".to_string())); + let seen = prompter.seen(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].attempt, 1); + assert!(seen[0].requires_password); + assert!(seen[0].error.is_none()); + } + + #[tokio::test] + async fn wrong_then_right_reprompts_with_attempts_remaining() { + let (verifier, fingerprint) = protected_keychain("hunter2"); + let prompter = MockPrompter::new(vec![pw("wrong"), pw("hunter2")]); + + let result = resolve_with(&prompter, &verifier, fingerprint, true).await.unwrap(); + + assert_eq!(result, Some("hunter2".to_string())); + let seen = prompter.seen(); + assert_eq!(seen.len(), 2); + assert_eq!(seen[1].attempt, 2); + assert_eq!(seen[1].error.as_ref().unwrap().attempts_remaining, 2); + } + + #[tokio::test] + async fn three_wrong_attempts_fails_unauthorized() { + let (verifier, fingerprint) = protected_keychain("hunter2"); + let prompter = MockPrompter::new(vec![pw("a"), pw("b"), pw("c")]); + + let error = resolve_with(&prompter, &verifier, fingerprint, true).await.unwrap_err(); + + assert!(matches!(error.kind, ErrorKind::Unauthorized)); + assert_eq!(prompter.seen().len(), MAX_ATTEMPTS as usize); + } + + #[tokio::test] + async fn cancellation_fails_immediately_without_reprompting() { + let (verifier, fingerprint) = protected_keychain("hunter2"); + let prompter = MockPrompter::new(vec![PasswordOutcome::Cancelled]); + + let error = resolve_with(&prompter, &verifier, fingerprint, true).await.unwrap_err(); + + assert!(matches!(error.kind, ErrorKind::Unauthorized)); + assert_eq!(error.reason, CANCELLED_REASON); + assert_eq!(prompter.seen().len(), 1); + } + + #[tokio::test] + async fn no_auth_needed_resolves_to_none() { + let (verifier, fingerprint) = protected_keychain("hunter2"); + let prompter = MockPrompter::new(vec![PasswordOutcome::NoAuthNeeded]); + + let result = resolve_with(&prompter, &verifier, fingerprint, false).await.unwrap(); + + assert_eq!(result, None); + assert_eq!(prompter.seen().len(), 1); + assert!(!prompter.seen()[0].requires_password); + } +} diff --git a/crates/sage-password-gate/src/types.rs b/crates/sage-password-gate/src/types.rs new file mode 100644 index 000000000..1da7d79d9 --- /dev/null +++ b/crates/sage-password-gate/src/types.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +/// How the frontend answered a password request. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PasswordOutcome { + /// The user supplied a password. + Password { password: String }, + /// No authentication was required, or a biometric gate already passed. + NoAuthNeeded, + /// The user dismissed the prompt. + Cancelled, +} + +/// Attached to a re-prompt after an incorrect password. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PasswordAttemptError { + pub attempts_remaining: u8, +} + +/// Emitted to the `main` webview only. Never broadcast. +#[derive(Debug, Clone, Serialize, Deserialize, Type, tauri_specta::Event)] +#[serde(rename_all = "camelCase")] +pub struct PasswordRequest { + pub request_id: String, + pub fingerprint: u32, + /// Advisory: the wallet's stored `password_protected` flag. The frontend + /// still decides between password dialog, biometric gate, and no auth, + /// because Rust does not know whether biometrics are enabled. + pub requires_password: bool, + /// 1-based. Increments on each incorrect-password re-prompt. + pub attempt: u8, + pub error: Option, +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 65ff2513e..992d6514f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ required-features = ["dev-tools"] sage = { workspace = true } sage-api = { workspace = true, features = ["tauri"] } sage-api-macro = { workspace = true } +sage-password-gate = { workspace = true } sage-config = { workspace = true } sage-wallet = { workspace = true } sage-rpc = { workspace = true } diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 027fb32fa..b3587366c 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -19,6 +19,15 @@ impl From for Error { } } +impl From for Error { + fn from(error: sage_password_gate::Error) -> Self { + Self { + kind: error.kind, + reason: error.reason, + } + } +} + impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.reason) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 147a81c2e..9bb95fd96 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,7 @@ mod error; #[cfg(not(mobile))] use sage_apps as apps; +use sage_password_gate as password_gate; #[cfg(not(mobile))] use sage_apps::{ From 4b854092d20c683692a361c90e99103ff0ec3c9e Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 18:40:02 -0500 Subject: [PATCH 04/36] feat(password-gate): wire main-window transport for password requests --- crates/sage-password-gate/src/lib.rs | 142 +++++++++++++++++++++- crates/sage-password-gate/src/prompter.rs | 39 ++++++ src-tauri/src/commands.rs | 11 ++ src-tauri/src/lib.rs | 6 +- src/bindings.ts | 39 ++++++ 5 files changed, 234 insertions(+), 3 deletions(-) diff --git a/crates/sage-password-gate/src/lib.rs b/crates/sage-password-gate/src/lib.rs index 6e90a557f..494175fe2 100644 --- a/crates/sage-password-gate/src/lib.rs +++ b/crates/sage-password-gate/src/lib.rs @@ -4,7 +4,7 @@ mod types; use sage_api::ErrorKind; -pub use prompter::{PasswordVerifier, Prompter}; +pub use prompter::{PasswordVerifier, Prompter, SAGE_WEBVIEW_LABEL}; pub use resolve::{CANCELLED_REASON, MAX_ATTEMPTS, resolve_with}; pub use types::{PasswordAttemptError, PasswordOutcome, PasswordRequest}; @@ -25,3 +25,143 @@ impl std::fmt::Display for Error { impl std::error::Error for Error {} pub type Result = std::result::Result; + +use std::collections::HashMap; + +use tokio::sync::{Mutex, oneshot}; + +/// Tracks in-flight password requests awaiting a frontend reply. +#[derive(Default, Debug)] +pub struct PasswordGateState { + pending: Mutex>>, +} + +impl PasswordGateState { + pub(crate) async fn register(&self, request_id: String, tx: oneshot::Sender) { + self.pending.lock().await.insert(request_id, tx); + } + + pub(crate) async fn cancel(&self, request_id: &str) { + self.pending.lock().await.remove(request_id); + } + + /// Hands an outcome to the waiting resolve loop. Consumes the entry, so a + /// given request id can only be answered once. + pub async fn deliver(&self, request_id: &str, outcome: PasswordOutcome) -> Result<()> { + let sender = self + .pending + .lock() + .await + .remove(request_id) + .ok_or_else(|| Error { + kind: ErrorKind::NotFound, + reason: format!("no pending password request with id {request_id}"), + })?; + + sender.send(outcome).map_err(|_| Error { + kind: ErrorKind::Internal, + reason: "password request was abandoned".to_string(), + }) + } +} + +use std::sync::Arc; + +use sage::Sage; +use tauri::AppHandle; + +/// The host's shared Sage handle. Mirrors `sage_tauri::app_state::AppState`. +pub type SharedSage = Arc>; + +/// Resolves a password for the active wallet, prompting the `main` webview. +/// +/// Reads the wallet fingerprint and its stored `password_protected` flag, then +/// releases the app lock *before* the frontend round-trip. Uses the cheap +/// config flag rather than `Keychain::is_password_protected`, which runs an +/// Argon2 decrypt probe on every call. +pub async fn resolve( + app_handle: &AppHandle, + state: &SharedSage, + gate: &PasswordGateState, +) -> Result> { + let (fingerprint, requires_password) = { + let sage = state.lock().await; + let fingerprint = sage + .wallet() + .map_err(|err| Error { kind: err.kind(), reason: err.to_string() })? + .fingerprint; + let requires_password = sage + .wallet_config + .wallets + .iter() + .find(|wallet| wallet.fingerprint == fingerprint) + .is_some_and(|wallet| wallet.password_protected); + (fingerprint, requires_password) + }; + + let prompter = prompter::TauriPrompter { app_handle, gate }; + let verifier = SageVerifier { state }; + resolve_with(&prompter, &verifier, fingerprint, requires_password).await +} + +/// Verifies a candidate password by taking the Sage lock briefly, then +/// releasing it before the next prompt. Never holds the lock across an await. +struct SageVerifier<'a> { + state: &'a SharedSage, +} + +#[async_trait::async_trait] +impl PasswordVerifier for SageVerifier<'_> { + async fn verify(&self, fingerprint: u32, password: &str) -> Result { + let sage = self.state.lock().await; + match sage.keychain.extract_secrets(fingerprint, password.as_bytes()) { + Ok(_) => Ok(true), + Err(sage_keychain::KeychainError::Decrypt) => Ok(false), + Err(err) => Err(Error { + kind: sage_api::ErrorKind::Internal, + reason: err.to_string(), + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn resolving_a_pending_request_delivers_the_outcome() { + let gate = PasswordGateState::default(); + let (tx, rx) = tokio::sync::oneshot::channel(); + gate.register("req-1".to_string(), tx).await; + + gate.deliver("req-1", PasswordOutcome::NoAuthNeeded) + .await + .expect("delivery should succeed"); + + assert!(matches!(rx.await.unwrap(), PasswordOutcome::NoAuthNeeded)); + } + + #[tokio::test] + async fn delivering_an_unknown_request_id_is_an_error() { + let gate = PasswordGateState::default(); + + let error = gate + .deliver("nope", PasswordOutcome::Cancelled) + .await + .expect_err("unknown id must error"); + + assert!(error.reason.contains("nope")); + } + + #[tokio::test] + async fn a_request_id_can_only_be_delivered_once() { + let gate = PasswordGateState::default(); + let (tx, _rx) = tokio::sync::oneshot::channel(); + gate.register("req-2".to_string(), tx).await; + + gate.deliver("req-2", PasswordOutcome::Cancelled).await.unwrap(); + + assert!(gate.deliver("req-2", PasswordOutcome::Cancelled).await.is_err()); + } +} diff --git a/crates/sage-password-gate/src/prompter.rs b/crates/sage-password-gate/src/prompter.rs index 74f8eba7a..6be6ecea5 100644 --- a/crates/sage-password-gate/src/prompter.rs +++ b/crates/sage-password-gate/src/prompter.rs @@ -24,3 +24,42 @@ pub trait PasswordVerifier: Send + Sync { /// `Err` = a genuine failure (not a wrong password). async fn verify(&self, fingerprint: u32, password: &str) -> Result; } + +use tauri::AppHandle; +use tauri_specta::Event; + +use super::Error; +use crate::PasswordGateState; +use sage_api::ErrorKind; + +/// The Sage React webview. App runtimes are sibling webviews in the same +/// window, so emission MUST target this label — a plain `emit` would deliver +/// the request to app-land. +pub const SAGE_WEBVIEW_LABEL: &str = "main"; + +pub struct TauriPrompter<'a> { + pub app_handle: &'a AppHandle, + pub gate: &'a PasswordGateState, +} + +#[async_trait] +impl Prompter for TauriPrompter<'_> { + async fn prompt(&self, request: PasswordRequest) -> Result { + let request_id = request.request_id.clone(); + let (tx, rx) = tokio::sync::oneshot::channel(); + self.gate.register(request_id.clone(), tx).await; + + if let Err(err) = request.emit_to(self.app_handle, SAGE_WEBVIEW_LABEL) { + self.gate.cancel(&request_id).await; + return Err(Error { + kind: ErrorKind::Internal, + reason: format!("failed to emit password request: {err}"), + }); + } + + rx.await.map_err(|_| Error { + kind: ErrorKind::Internal, + reason: "password request channel closed".to_string(), + }) + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 016532255..4856a5ce0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -12,6 +12,7 @@ use sage_api_macro::impl_endpoints_tauri; #[cfg(not(mobile))] use sage_apps::ensure_initial_sandbox_run; use sage_config::{NetworkConfig, Wallet, WalletDefaults}; +use sage_password_gate::{PasswordGateState, PasswordOutcome}; use sage_rpc::start_rpc; use serde::{Deserialize, Serialize}; use specta::{Type, specta}; @@ -258,3 +259,13 @@ pub async fn get_logs(state: State<'_, AppState>) -> Result> { Ok(log_files) } + +#[command] +#[specta] +pub async fn submit_password_response( + gate: State<'_, PasswordGateState>, + request_id: String, + outcome: PasswordOutcome, +) -> Result<()> { + Ok(gate.deliver(&request_id, outcome).await?) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9bb95fd96..b71a5832d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -148,6 +148,7 @@ macro_rules! sage_commands { commands::move_key, commands::download_cni_offercode, commands::get_logs, + commands::submit_password_response, commands::is_asset_owned, commands::change_password, commands::reconcile_key_protection, @@ -185,7 +186,7 @@ fn specta_builder() -> Builder { apps::apps_get_auto_update_enabled, apps::apps_set_auto_update_enabled, ]) - .events(collect_events![SyncEvent]) + .events(collect_events![SyncEvent, password_gate::PasswordRequest]) } #[cfg(all(debug_assertions, not(mobile)))] @@ -213,7 +214,7 @@ pub fn run() { let builder = Builder::::new() .error_handling(ErrorHandlingMode::Throw) .commands(sage_commands![]) - .events(collect_events![SyncEvent]); + .events(collect_events![SyncEvent, password_gate::PasswordRequest]); let mut tauri_builder = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) @@ -284,6 +285,7 @@ pub fn run() { app.manage(Initialized(Mutex::new(false))); app.manage(RpcTask(Mutex::new(None))); app.manage(app_state); + app.manage(password_gate::PasswordGateState::default()); #[cfg(not(mobile))] { diff --git a/src/bindings.ts b/src/bindings.ts index f02760306..5dfba9ac8 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -359,6 +359,9 @@ async downloadCniOffercode(code: string) : Promise { async getLogs() : Promise { return await TAURI_INVOKE("get_logs"); }, +async submitPasswordResponse(requestId: string, outcome: PasswordOutcome) : Promise { + return await TAURI_INVOKE("submit_password_response", { requestId, outcome }); +}, async isAssetOwned(req: IsAssetOwned) : Promise { return await TAURI_INVOKE("is_asset_owned", { req }); }, @@ -443,8 +446,10 @@ async appsSetAutoUpdateEnabled(enabled: boolean) : Promise { export const events = __makeEvents__<{ +passwordRequest: PasswordRequest, syncEvent: SyncEvent }>({ +passwordRequest: "password-request", syncEvent: "sync-event" }) @@ -2238,6 +2243,40 @@ amount: Amount } export type OptionAssets = { underlying_asset: Asset; underlying_amount: Amount; strike_asset: Asset; strike_amount: Amount; expiration_seconds: number } export type OptionRecord = { launcher_id: string; name: string | null; visible: boolean; coin_id: string; address: string; amount: Amount; underlying_asset: Asset; underlying_amount: Amount; underlying_coin_id: string; strike_asset: Asset; strike_amount: Amount; expiration_seconds: number; created_height: number | null; created_timestamp: number | null } export type OptionSortMode = "name" | "created_height" | "expiration_seconds" +/** + * Attached to a re-prompt after an incorrect password. + */ +export type PasswordAttemptError = { attemptsRemaining: number } +/** + * How the frontend answered a password request. + */ +export type PasswordOutcome = +/** + * The user supplied a password. + */ +{ kind: "password"; password: string } | +/** + * No authentication was required, or a biometric gate already passed. + */ +{ kind: "no_auth_needed" } | +/** + * The user dismissed the prompt. + */ +{ kind: "cancelled" } +/** + * Emitted to the `main` webview only. Never broadcast. + */ +export type PasswordRequest = { requestId: string; fingerprint: number; +/** + * Advisory: the wallet's stored `password_protected` flag. The frontend + * still decides between password dialog, biometric gate, and no auth, + * because Rust does not know whether biometrics are enabled. + */ +requiresPassword: boolean; +/** + * 1-based. Increments on each incorrect-password re-prompt. + */ +attempt: number; error: PasswordAttemptError | null } export type PeerRecord = { ip_addr: string; port: number; peak_height: number; user_managed: boolean } export type PendingTransactionRecord = { transaction_id: string; fee: Amount; submitted_at: number | null; spent: TransactionCoinRecord[]; created: TransactionCoinRecord[] } /** From a5682bf61b6855fa36442df6b5805c1814b6d141 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 18:48:05 -0500 Subject: [PATCH 05/36] fix(password-gate): allow submit_password_response in ACL, add prompt timeout --- crates/sage-password-gate/Cargo.toml | 4 +- crates/sage-password-gate/src/lib.rs | 61 ++++++++++++++++++++++- crates/sage-password-gate/src/prompter.rs | 5 +- crates/sage-password-gate/src/resolve.rs | 7 +++ src-tauri/permissions/main-host.toml | 1 + 5 files changed, 71 insertions(+), 7 deletions(-) diff --git a/crates/sage-password-gate/Cargo.toml b/crates/sage-password-gate/Cargo.toml index 51a4e2196..66e1fcc43 100644 --- a/crates/sage-password-gate/Cargo.toml +++ b/crates/sage-password-gate/Cargo.toml @@ -15,9 +15,9 @@ serde = { workspace = true, features = ["derive"] } specta = { workspace = true } tauri = { workspace = true } tauri-specta = { workspace = true } -tokio = { workspace = true, features = ["sync"] } +tokio = { workspace = true, features = ["sync", "time"] } uuid = { version = "1.19.0", features = ["v4"] } [dev-dependencies] bip39 = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time", "test-util"] } diff --git a/crates/sage-password-gate/src/lib.rs b/crates/sage-password-gate/src/lib.rs index 494175fe2..d8fe10f19 100644 --- a/crates/sage-password-gate/src/lib.rs +++ b/crates/sage-password-gate/src/lib.rs @@ -5,7 +5,7 @@ mod types; use sage_api::ErrorKind; pub use prompter::{PasswordVerifier, Prompter, SAGE_WEBVIEW_LABEL}; -pub use resolve::{CANCELLED_REASON, MAX_ATTEMPTS, resolve_with}; +pub use resolve::{CANCELLED_REASON, MAX_ATTEMPTS, PROMPT_TIMEOUT, resolve_with}; pub use types::{PasswordAttemptError, PasswordOutcome, PasswordRequest}; /// This crate's error. Structurally identical to `sage-tauri`'s `Error`, so the @@ -63,6 +63,37 @@ impl PasswordGateState { reason: "password request was abandoned".to_string(), }) } + + /// Waits for `request_id`'s outcome on `rx`, bounded by `PROMPT_TIMEOUT`. + /// `request_id` must already be registered (see [`Self::register`]) — + /// this only owns the waiting and timeout cleanup, so callers can emit + /// the request to the frontend between registering and calling this, + /// without risking a race where a reply arrives before registration. + /// + /// On timeout, removes the pending entry (so the map never leaks a + /// request no one will ever answer, e.g. because the `main` webview is + /// absent or unresponsive) and returns an `Unauthorized` error so the + /// caller does not proceed. + pub(crate) async fn await_outcome( + &self, + request_id: &str, + rx: oneshot::Receiver, + ) -> Result { + match tokio::time::timeout(PROMPT_TIMEOUT, rx).await { + Ok(Ok(outcome)) => Ok(outcome), + Ok(Err(_)) => Err(Error { + kind: ErrorKind::Internal, + reason: "password request channel closed".to_string(), + }), + Err(_) => { + self.cancel(&request_id).await; + Err(Error { + kind: ErrorKind::Unauthorized, + reason: "Password prompt timed out".to_string(), + }) + } + } + } } use std::sync::Arc; @@ -164,4 +195,32 @@ mod tests { assert!(gate.deliver("req-2", PasswordOutcome::Cancelled).await.is_err()); } + + #[tokio::test] + async fn a_request_that_is_never_answered_times_out_and_is_cleared() { + tokio::time::pause(); + + let gate = Arc::new(PasswordGateState::default()); + let (tx, rx) = tokio::sync::oneshot::channel(); + gate.register("req-timeout".to_string(), tx).await; + + // Keep the sender alive for the whole wait so the failure is a + // genuine timeout, not the channel being dropped. + let waiting_gate = gate.clone(); + let waiter = tokio::spawn(async move { waiting_gate.await_outcome("req-timeout", rx).await }); + + tokio::time::advance(PROMPT_TIMEOUT + std::time::Duration::from_secs(1)).await; + + let error = waiter.await.unwrap().expect_err("must time out"); + assert!(matches!(error.kind, ErrorKind::Unauthorized)); + assert_eq!(error.reason, "Password prompt timed out"); + + // The pending entry must not leak: a late delivery now errors as + // unknown, rather than silently succeeding into a dropped receiver. + let deliver_error = gate + .deliver("req-timeout", PasswordOutcome::Cancelled) + .await + .expect_err("entry should have been cleared on timeout"); + assert!(matches!(deliver_error.kind, ErrorKind::NotFound)); + } } diff --git a/crates/sage-password-gate/src/prompter.rs b/crates/sage-password-gate/src/prompter.rs index 6be6ecea5..24f474338 100644 --- a/crates/sage-password-gate/src/prompter.rs +++ b/crates/sage-password-gate/src/prompter.rs @@ -57,9 +57,6 @@ impl Prompter for TauriPrompter<'_> { }); } - rx.await.map_err(|_| Error { - kind: ErrorKind::Internal, - reason: "password request channel closed".to_string(), - }) + self.gate.await_outcome(&request_id, rx).await } } diff --git a/crates/sage-password-gate/src/resolve.rs b/crates/sage-password-gate/src/resolve.rs index c4058fd80..851ec5cc4 100644 --- a/crates/sage-password-gate/src/resolve.rs +++ b/crates/sage-password-gate/src/resolve.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use sage_api::ErrorKind; use super::types::{PasswordAttemptError, PasswordOutcome, PasswordRequest}; @@ -10,6 +12,11 @@ pub const MAX_ATTEMPTS: u8 = 3; /// distinguish a deliberate cancel from a genuine auth failure and stay silent. pub const CANCELLED_REASON: &str = "Password entry cancelled"; +/// How long the resolve loop waits for a frontend reply before giving up. +/// Generous enough that a human typing a password is never cut off, while +/// still bounding the hang if the `main` webview is absent or unresponsive. +pub const PROMPT_TIMEOUT: Duration = Duration::from_secs(300); + fn unauthorized(reason: &str) -> Error { Error { kind: ErrorKind::Unauthorized, reason: reason.to_string() } } diff --git a/src-tauri/permissions/main-host.toml b/src-tauri/permissions/main-host.toml index 741e81fbc..1a23549e9 100644 --- a/src-tauri/permissions/main-host.toml +++ b/src-tauri/permissions/main-host.toml @@ -18,6 +18,7 @@ commands.allow = [ "get_wallet_address", "change_password", "reconcile_key_protection", + "submit_password_response", "send_xch", "bulk_send_xch", "combine", From 341c78246473fcaafe94d49dbc1535c44c3f53ba Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 18:55:23 -0500 Subject: [PATCH 06/36] feat(password-gate): add maybe_unlock macro token and drift test Adds the drift-proof set of 32 password-gated endpoint names, a maybe_unlock token in impl_endpoints_tauri! that expands to the sage_password_gate::resolve() call for exactly those endpoints, and a test that scans the sage-api request sources for password: Option fields and asserts the JSON matches exactly. Co-Authored-By: Claude Opus 5 --- crates/sage-api/Cargo.toml | 3 ++ crates/sage-api/macro/src/lib.rs | 17 ++++-- crates/sage-api/password-gated.json | 34 ++++++++++++ crates/sage-api/src/lib.rs | 83 +++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 crates/sage-api/password-gated.json diff --git a/crates/sage-api/Cargo.toml b/crates/sage-api/Cargo.toml index fa59b04e5..044c24954 100644 --- a/crates/sage-api/Cargo.toml +++ b/crates/sage-api/Cargo.toml @@ -25,3 +25,6 @@ serde = { workspace = true, features = ["derive"] } tauri-specta = { workspace = true, features = ["derive"], optional = true } specta = { workspace = true, features = ["derive", "bigdecimal"], optional = true } utoipa = { workspace = true, optional = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/sage-api/macro/src/lib.rs b/crates/sage-api/macro/src/lib.rs index 0528c32db..ce9da0c7f 100644 --- a/crates/sage-api/macro/src/lib.rs +++ b/crates/sage-api/macro/src/lib.rs @@ -80,10 +80,14 @@ fn generate(input: &TokenStream, tauri: bool) -> TokenStream { endpoints.extend(tauri_endpoints); } + let password_gated: std::collections::BTreeSet = + serde_json::from_str(include_str!("../../password-gated.json")) + .expect("Invalid password-gated endpoint file"); + let mut output = proc_macro2::TokenStream::new(); for token in input.clone() { - convert(token, &endpoints, None, &mut output); + convert(token, &endpoints, &password_gated, None, &mut output); } output.into() @@ -92,6 +96,7 @@ fn generate(input: &TokenStream, tauri: bool) -> TokenStream { fn convert( tree: TokenTree, endpoints: &IndexMap, + password_gated: &std::collections::BTreeSet, endpoint: Option<&str>, output: &mut proc_macro2::TokenStream, ) { @@ -115,6 +120,12 @@ fn convert( if is_async { output.extend(quote!(.await)); } + } else if ident == "maybe_unlock" { + if password_gated.contains(endpoint) { + output.extend(quote!( + req.password = sage_password_gate::resolve(&app_handle, state.inner(), gate.inner()).await?; + )); + } } else if ident.is_case(Case::Snake) { let ident = proc_macro2::Ident::new( &ident.replace("endpoint", &endpoint.to_case(Case::Snake)), @@ -152,14 +163,14 @@ fn convert( if repeat { for endpoint in endpoints.keys() { for tree in stream.clone() { - convert(tree, endpoints, Some(endpoint), output); + convert(tree, endpoints, password_gated, Some(endpoint), output); } } } else { let mut inner = proc_macro2::TokenStream::new(); for tree in stream { - convert(tree, endpoints, endpoint, &mut inner); + convert(tree, endpoints, password_gated, endpoint, &mut inner); } output.extend(proc_macro2::TokenStream::from(TokenStream::from( diff --git a/crates/sage-api/password-gated.json b/crates/sage-api/password-gated.json new file mode 100644 index 000000000..7f95db820 --- /dev/null +++ b/crates/sage-api/password-gated.json @@ -0,0 +1,34 @@ +[ + "add_nft_uri", + "assign_nfts_to_did", + "auto_combine_cat", + "auto_combine_xch", + "bulk_mint_nfts", + "bulk_send_cat", + "bulk_send_xch", + "cancel_offer", + "cancel_offers", + "combine", + "create_did", + "create_transaction", + "delete_key", + "exercise_options", + "finalize_clawback", + "get_secret_key", + "increase_derivation_index", + "issue_cat", + "make_offer", + "mint_option", + "multi_send", + "normalize_dids", + "send_cat", + "send_xch", + "sign_coin_spends", + "sign_message_by_address", + "sign_message_with_public_key", + "split", + "take_offer", + "transfer_dids", + "transfer_nfts", + "transfer_options" +] diff --git a/crates/sage-api/src/lib.rs b/crates/sage-api/src/lib.rs index 3bacc7ae4..78af9b1e0 100644 --- a/crates/sage-api/src/lib.rs +++ b/crates/sage-api/src/lib.rs @@ -17,3 +17,86 @@ pub use openapi_metadata::*; // Re-export the openapi attribute macro #[cfg(feature = "openapi")] pub use sage_api_macro::openapi as openapi_attr; + +#[cfg(test)] +mod password_gate_drift { + use std::collections::BTreeSet; + + /// The gated set must match, exactly, the request types carrying a + /// `password` field. If this fails you either added a signing endpoint + /// without gating it (a security hole) or gated one that takes no + /// password (a spurious prompt). + #[test] + fn gated_set_matches_request_types_with_password_field() { + let gated: BTreeSet = + serde_json::from_str(include_str!("../password-gated.json")).unwrap(); + + let mut discovered = BTreeSet::new(); + for source in [ + include_str!("requests/action_system.rs"), + include_str!("requests/actions.rs"), + include_str!("requests/keys.rs"), + include_str!("requests/offers.rs"), + include_str!("requests/transactions.rs"), + include_str!("requests/wallet_connect.rs"), + ] { + discovered.extend(structs_with_password_field(source)); + } + + assert_eq!( + gated, discovered, + "password-gated.json is out of sync with the request types.\n\ + Only in password-gated.json: {:?}\n\ + Only in request types: {:?}", + gated.difference(&discovered).collect::>(), + discovered.difference(&gated).collect::>(), + ); + } + + /// Scans Rust source for `pub struct Name {` blocks containing a + /// `pub password: Option` field, returning snake_case names. + fn structs_with_password_field(source: &str) -> BTreeSet { + let mut found = BTreeSet::new(); + let lines: Vec<&str> = source.lines().collect(); + let mut index = 0; + + while index < lines.len() { + let line = lines[index].trim_end(); + let Some(rest) = line.strip_prefix("pub struct ") else { + index += 1; + continue; + }; + let Some(name) = rest.strip_suffix(" {") else { + index += 1; + continue; + }; + + let mut cursor = index + 1; + let mut has_password = false; + while cursor < lines.len() && lines[cursor] != "}" { + if lines[cursor].trim() == "pub password: Option," { + has_password = true; + } + cursor += 1; + } + + if has_password { + found.insert(to_snake_case(name)); + } + index = cursor + 1; + } + + found + } + + fn to_snake_case(name: &str) -> String { + let mut out = String::new(); + for (position, character) in name.char_indices() { + if character.is_uppercase() && position != 0 { + out.push('_'); + } + out.extend(character.to_lowercase()); + } + out + } +} From 927177d92783b0a186639d7ea7668b3b966f6efe Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 18:55:30 -0500 Subject: [PATCH 07/36] chore: update Cargo.lock for sage-api serde_json dev-dependency Follow-up to 341c7824 which added serde_json as a dev-dependency of sage-api for the password-gate drift test. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 69717a7e6..a448ad5e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6521,6 +6521,7 @@ dependencies = [ "sage-api-macro", "sage-config", "serde", + "serde_json", "specta", "tauri-specta", "utoipa", From 93ebd0cad100adf90aa3b764771f46720d39301e Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 18:59:30 -0500 Subject: [PATCH 08/36] fix(password-gate): scan every requests/*.rs file, not a hard-coded list The drift test's include_str! list only named 6 of 8 files under requests/, silently skipping data.rs and settings.rs (and any future new module). A struct gaining a password field there would go undetected. Resolve the directory at test time via CARGO_MANIFEST_DIR and read_dir every *.rs file instead, with a loud failure if the directory is unreadable or yields fewer than 6 files. Co-Authored-By: Claude Opus 5 --- crates/sage-api/src/lib.rs | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/crates/sage-api/src/lib.rs b/crates/sage-api/src/lib.rs index 78af9b1e0..5e303810b 100644 --- a/crates/sage-api/src/lib.rs +++ b/crates/sage-api/src/lib.rs @@ -31,15 +31,30 @@ mod password_gate_drift { let gated: BTreeSet = serde_json::from_str(include_str!("../password-gated.json")).unwrap(); + let requests_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/requests"); + let entries = std::fs::read_dir(&requests_dir).unwrap_or_else(|error| { + panic!("failed to read request sources at {requests_dir:?}: {error}") + }); + + let mut sources = Vec::new(); + for entry in entries { + let entry = entry.unwrap(); + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) == Some("rs") { + sources.push(std::fs::read_to_string(&path).unwrap_or_else(|error| { + panic!("failed to read {path:?}: {error}") + })); + } + } + + assert!( + sources.len() >= 6, + "expected at least 6 request source files under {requests_dir:?}, found {}", + sources.len(), + ); + let mut discovered = BTreeSet::new(); - for source in [ - include_str!("requests/action_system.rs"), - include_str!("requests/actions.rs"), - include_str!("requests/keys.rs"), - include_str!("requests/offers.rs"), - include_str!("requests/transactions.rs"), - include_str!("requests/wallet_connect.rs"), - ] { + for source in &sources { discovered.extend(structs_with_password_field(source)); } From b73821c4fca00b2e56b63f8452feb176c1b36609 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 19:10:24 -0500 Subject: [PATCH 09/36] feat(password-gate): resolve passwords in all gated endpoint commands --- src-tauri/src/commands.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4856a5ce0..6dbd799ff 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -69,7 +69,14 @@ impl_endpoints_tauri! { (repeat #[command] #[specta] - pub async fn endpoint(state: State<'_, AppState>, req: Endpoint) -> Result { + #[allow(unused_variables, unused_mut)] + pub async fn endpoint( + app_handle: AppHandle, + state: State<'_, AppState>, + gate: State<'_, PasswordGateState>, + mut req: Endpoint, + ) -> Result { + maybe_unlock Ok(state.lock().await.endpoint(req) maybe_await?) } ) From 3f90c7553817c9e3ca9a04f0f84c12053b61f086 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 19:20:18 -0500 Subject: [PATCH 10/36] feat(password-gate): prompt in main window for app bridge requests Co-Authored-By: Claude Opus 5 --- Cargo.lock | 1 + crates/sage-apps/Cargo.toml | 1 + crates/sage-apps/src/bridge/bridge_request.rs | 104 +++++++++++++++--- crates/sage-apps/src/bridge/methods/shared.rs | 3 + .../bridge/methods/user/wallet/send_xch.rs | 3 +- .../methods/user/wallet/sign_coin_spends.rs | 5 +- .../methods/user/wallet/sign_message.rs | 5 +- crates/sage-apps/src/runtime/manager.rs | 2 +- 8 files changed, 103 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a448ad5e9..167045a0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6553,6 +6553,7 @@ dependencies = [ "reqwest 0.12.24", "sage", "sage-api", + "sage-password-gate", "serde", "serde_json", "serde_path_to_error", diff --git a/crates/sage-apps/Cargo.toml b/crates/sage-apps/Cargo.toml index d084526e9..b3331937b 100644 --- a/crates/sage-apps/Cargo.toml +++ b/crates/sage-apps/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] sage = { workspace = true } sage-api = { workspace = true, features = ["tauri"] } +sage-password-gate = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } specta = { workspace = true, features = ["derive", "function", "url"] } diff --git a/crates/sage-apps/src/bridge/bridge_request.rs b/crates/sage-apps/src/bridge/bridge_request.rs index c5ab79991..e61aa32bc 100644 --- a/crates/sage-apps/src/bridge/bridge_request.rs +++ b/crates/sage-apps/src/bridge/bridge_request.rs @@ -5,12 +5,14 @@ use crate::{ BridgeMethod, BridgeMethodCapability, BridgeOrigin, BridgeRegistry, BridgeRegistryKind, BridgeTools, PendingBridgeApproval, ResolveBridgeApprovalArgs, RustBridgeApprovalBody, RustBridgeApprovalRequest, RustBridgeInvokeResult, RustBridgeRequest, RustBridgeResponse, - SharedSageApp, SystemBridgeCapability, UserBridgeCapability, assert_bridge_origin, - emit_bridge_response_to_app, emit_system_runtime_event_to_listeners, - ensure_app_is_enabled_for_scope, ensure_approval_expiry_loop, get_system_capability_definition, - get_user_capability_definition, list_pending_approvals, resolve_app, - start_bridge_approval_runtime, sync_bridge_approval_runtime, take_pending_approval, - unix_timestamp_ms, write_pending_approval, + RuntimeChangeSet, SYSTEM_APP_BRIDGE_APPROVAL_ID, SharedSageApp, SystemBridgeCapability, + UserBridgeCapability, assert_bridge_origin, emit_bridge_response_to_app, + emit_system_runtime_event_to_listeners, ensure_app_is_enabled_for_scope, + ensure_approval_expiry_loop, find_runtime_by_app_id_optional, + get_system_capability_definition, get_user_capability_definition, hide_runtime_inner, + list_pending_approvals, resolve_app, start_bridge_approval_runtime, + sync_bridge_approval_runtime, take_pending_approval, unix_timestamp_ms, + write_pending_approval, }; pub(crate) async fn process( @@ -43,6 +45,7 @@ pub(crate) async fn process( BridgeRegistryKind::User, &request, false, + None, ) .await } @@ -76,6 +79,7 @@ pub(crate) async fn process_system( BridgeRegistryKind::System, &request, false, + None, ) .await } @@ -124,15 +128,35 @@ pub(crate) async fn process_after_approval( "Active wallet changed since the approval was requested".to_string(), ) } else { - process_shared( - app_handle, - app_state, - &origin, - pending.registry_kind, - &pending.request, - true, - ) - .await? + // Hide the approval app before prompting: app runtimes are sibling + // webviews inside the same window and would cover the main webview's + // password dialog. + hide_bridge_approval_runtime(app_handle, apps_state).await; + + match password_gate_resolve(app_handle, app_state).await { + // The expiry check above ran before the prompt, and the prompt can + // outlive the deadline, so the approval window is re-checked here. + Ok(_) if unix_timestamp_ms() as u64 > pending.expires_at_ms => { + RustBridgeInvokeResult::error( + &pending.request.id, + "approval_timeout", + "Approval expired during password entry".to_string(), + ) + } + Ok(password) => { + process_shared( + app_handle, + app_state, + &origin, + pending.registry_kind, + &pending.request, + true, + password, + ) + .await? + } + Err(err) => RustBridgeInvokeResult::error(&pending.request.id, "unauthorized", err), + } }; emit_bridge_response_to_app(app_handle, &origin.app, &invoke_result.try_into()?).await?; @@ -146,6 +170,7 @@ async fn process_shared( registry_kind: BridgeRegistryKind, request: &RustBridgeRequest, approved: bool, + password: Option, ) -> Result { let registry = BridgeRegistry::new(registry_kind); @@ -175,7 +200,8 @@ async fn process_shared( if approved { let response = - execute_bridge_request(app_handle, app_state, origin, registry, request).await; + execute_bridge_request(app_handle, app_state, origin, registry, request, password) + .await; return Ok(response.into()); } @@ -187,6 +213,7 @@ async fn process_shared( app_handle, app_state, host_state: &app_handle.state::(), + password: None, }, request, ) @@ -206,7 +233,8 @@ async fn process_shared( } Ok(None) => { let response = - execute_bridge_request(app_handle, app_state, origin, registry, request).await; + execute_bridge_request(app_handle, app_state, origin, registry, request, password) + .await; Ok(response.into()) } @@ -224,6 +252,7 @@ async fn execute_bridge_request( origin: &BridgeOrigin, registry: BridgeRegistry, request: &RustBridgeRequest, + password: Option, ) -> RustBridgeResponse { let method = match assert_method(®istry, request) { Ok(method) => method, @@ -237,6 +266,7 @@ async fn execute_bridge_request( app_handle, app_state, host_state: &app_handle.state::(), + password, }, request, ) @@ -255,6 +285,46 @@ async fn execute_bridge_request( } } +/// Hides the `bridge-approval` runtime so it cannot cover the `main` webview's +/// password dialog. Best effort: a covered dialog is a UX problem, not a +/// security one, so every failure is logged and the request continues. +async fn hide_bridge_approval_runtime( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, +) { + let Some(runtime) = + find_runtime_by_app_id_optional(apps_state, SYSTEM_APP_BRIDGE_APPROVAL_ID).await + else { + return; + }; + + let mut changes = RuntimeChangeSet::default(); + + if let Err(err) = hide_runtime_inner(app_handle, &runtime, &mut changes) { + tracing::warn!( + error = %err, + "failed to hide the bridge approval runtime before the password prompt" + ); + return; + } + + changes.emit(app_handle, apps_state).await; +} + +/// Resolves the master-key password in the trusted `main` webview. The value +/// never crosses into an app runtime: it is handed straight to the wallet +/// method through `BridgeTools`. +async fn password_gate_resolve( + app_handle: &AppHandle, + app_state: &State<'_, AppState>, +) -> Result, String> { + let gate = app_handle.state::(); + + sage_password_gate::resolve(app_handle, app_state.inner(), &gate) + .await + .map_err(|err| err.reason) +} + async fn active_wallet_fingerprint(app_state: &State<'_, AppState>) -> Option { app_state .lock() diff --git a/crates/sage-apps/src/bridge/methods/shared.rs b/crates/sage-apps/src/bridge/methods/shared.rs index 1d6871b87..476dbdce5 100644 --- a/crates/sage-apps/src/bridge/methods/shared.rs +++ b/crates/sage-apps/src/bridge/methods/shared.rs @@ -53,6 +53,9 @@ pub(crate) struct BridgeTools<'a> { pub app_handle: &'a tauri::AppHandle, pub app_state: &'a tauri::State<'a, AppState>, pub host_state: &'a tauri::State<'a, AppsHostState>, + /// Password resolved by the main-window gate, for methods that sign. + /// `None` when the wallet is unprotected or the method does not sign. + pub password: Option, } #[derive(Debug, Clone)] diff --git a/crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs b/crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs index e995de2fe..d73f99f4c 100644 --- a/crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs +++ b/crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs @@ -81,7 +81,8 @@ impl BridgeMethod for WalletSendXch { request: &RustBridgeRequest, ) -> BridgeHandleResult { let params: WalletSendXchParams = parse_required_params(self, request)?; - let req: SendXch = params.into(); + let mut req: SendXch = params.into(); + req.password = tools.password.clone(); let result = tools .app_state diff --git a/crates/sage-apps/src/bridge/methods/user/wallet/sign_coin_spends.rs b/crates/sage-apps/src/bridge/methods/user/wallet/sign_coin_spends.rs index 4f00b4ef7..893483bed 100644 --- a/crates/sage-apps/src/bridge/methods/user/wallet/sign_coin_spends.rs +++ b/crates/sage-apps/src/bridge/methods/user/wallet/sign_coin_spends.rs @@ -279,11 +279,14 @@ impl BridgeMethod for WalletSignCoinSpends { let params: WalletSignCoinSpendsParams = parse_required_params(self, request)?; validate_params(¶ms)?; + let mut req = params.into_request(); + req.password = tools.password.clone(); + let response = tools .app_state .lock() .await - .sign_coin_spends(params.into_request()) + .sign_coin_spends(req) .await .map_err(|err| { BridgeMethodHandleError::internal_error(format!("{} failed: {err}", self.name())) diff --git a/crates/sage-apps/src/bridge/methods/user/wallet/sign_message.rs b/crates/sage-apps/src/bridge/methods/user/wallet/sign_message.rs index 9f0dac2f1..9d2287c41 100644 --- a/crates/sage-apps/src/bridge/methods/user/wallet/sign_message.rs +++ b/crates/sage-apps/src/bridge/methods/user/wallet/sign_message.rs @@ -72,11 +72,14 @@ impl BridgeMethod for WalletSignMessage { request: &RustBridgeRequest, ) -> BridgeHandleResult { let params: WalletSignMessageParams = parse_required_params(self, request)?; + let mut req: SignMessageWithPublicKey = params.into(); + req.password = tools.password.clone(); + let response = tools .app_state .lock() .await - .sign_message_with_public_key(params.into()) + .sign_message_with_public_key(req) .await .map_err(|err| { BridgeMethodHandleError::internal_error(format!("{} failed: {err}", self.name())) diff --git a/crates/sage-apps/src/runtime/manager.rs b/crates/sage-apps/src/runtime/manager.rs index 7d77110e3..60b2952a4 100644 --- a/crates/sage-apps/src/runtime/manager.rs +++ b/crates/sage-apps/src/runtime/manager.rs @@ -392,7 +392,7 @@ async fn show_runtime_inner( Ok(()) } -fn hide_runtime_inner( +pub(crate) fn hide_runtime_inner( app_handle: &AppHandle, runtime: &SharedRuntime, changes: &mut RuntimeChangeSet, From a6424f2a033987777394375d222e6d488118c9a9 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 19:25:33 -0500 Subject: [PATCH 11/36] fix(password-gate): scope the bridge gate to secret-bearing approvals Only GetSecretKey/SendXch/SignCoinSpends/SignMessage approvals reach a wallet secret; capability and network-whitelist grants now resume without a prompt. Also overrides any app-supplied password on wallet.getSecretKey, and clears two clippy denials this plan introduced. Co-Authored-By: Claude Opus 5 --- crates/sage-api/src/lib.rs | 2 +- crates/sage-apps/src/bridge/bridge_request.rs | 31 +++++++++++++++++++ .../methods/user/wallet/get_secret_key.rs | 6 +++- crates/sage-password-gate/src/lib.rs | 2 +- crates/sage-password-gate/src/resolve.rs | 2 +- 5 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/sage-api/src/lib.rs b/crates/sage-api/src/lib.rs index 5e303810b..7e7d48bc3 100644 --- a/crates/sage-api/src/lib.rs +++ b/crates/sage-api/src/lib.rs @@ -69,7 +69,7 @@ mod password_gate_drift { } /// Scans Rust source for `pub struct Name {` blocks containing a - /// `pub password: Option` field, returning snake_case names. + /// `pub password: Option` field, returning `snake_case` names. fn structs_with_password_field(source: &str) -> BTreeSet { let mut found = BTreeSet::new(); let lines: Vec<&str> = source.lines().collect(); diff --git a/crates/sage-apps/src/bridge/bridge_request.rs b/crates/sage-apps/src/bridge/bridge_request.rs index e61aa32bc..b972460e6 100644 --- a/crates/sage-apps/src/bridge/bridge_request.rs +++ b/crates/sage-apps/src/bridge/bridge_request.rs @@ -127,6 +127,18 @@ pub(crate) async fn process_after_approval( "wallet_changed", "Active wallet changed since the approval was requested".to_string(), ) + } else if !approval_requires_password(&pending) { + // Nothing here reaches a wallet secret, so there is nothing to unlock. + process_shared( + app_handle, + app_state, + &origin, + pending.registry_kind, + &pending.request, + true, + None, + ) + .await? } else { // Hide the approval app before prompting: app runtimes are sibling // webviews inside the same window and would cover the main webview's @@ -334,6 +346,25 @@ async fn active_wallet_fingerprint(app_state: &State<'_, AppState>) -> Option bool { + match pending.approval.body { + RustBridgeApprovalBody::GetSecretKey { .. } + | RustBridgeApprovalBody::SendXch { .. } + | RustBridgeApprovalBody::SignCoinSpends { .. } + | RustBridgeApprovalBody::SignMessage { .. } => true, + + RustBridgeApprovalBody::CapabilityGrant { .. } + | RustBridgeApprovalBody::NetworkWhitelistGrant { .. } => false, + } +} + async fn wallet_binding_violated( app_state: &State<'_, AppState>, pending: &PendingBridgeApproval, diff --git a/crates/sage-apps/src/bridge/methods/user/wallet/get_secret_key.rs b/crates/sage-apps/src/bridge/methods/user/wallet/get_secret_key.rs index bf04fa1e1..2ae7220c5 100644 --- a/crates/sage-apps/src/bridge/methods/user/wallet/get_secret_key.rs +++ b/crates/sage-apps/src/bridge/methods/user/wallet/get_secret_key.rs @@ -42,9 +42,13 @@ impl BridgeMethod for WalletGetSecretKey { tools: BridgeTools<'_>, request: &RustBridgeRequest, ) -> BridgeHandleResult { - let params: GetSecretKey = parse_required_params(self, request)?; + let mut params: GetSecretKey = parse_required_params(self, request)?; require_scoped_fingerprint(&ctx, Some(params.fingerprint))?; + // The gate in the main window is the only source of a password here; + // anything the app sent in its params is discarded. + params.password = tools.password.clone(); + let sage = tools.app_state.lock().await; let response = sage.get_secret_key(params).map_err(|err| { diff --git a/crates/sage-password-gate/src/lib.rs b/crates/sage-password-gate/src/lib.rs index d8fe10f19..2c424ba19 100644 --- a/crates/sage-password-gate/src/lib.rs +++ b/crates/sage-password-gate/src/lib.rs @@ -86,7 +86,7 @@ impl PasswordGateState { reason: "password request channel closed".to_string(), }), Err(_) => { - self.cancel(&request_id).await; + self.cancel(request_id).await; Err(Error { kind: ErrorKind::Unauthorized, reason: "Password prompt timed out".to_string(), diff --git a/crates/sage-password-gate/src/resolve.rs b/crates/sage-password-gate/src/resolve.rs index 851ec5cc4..988c120e5 100644 --- a/crates/sage-password-gate/src/resolve.rs +++ b/crates/sage-password-gate/src/resolve.rs @@ -15,7 +15,7 @@ pub const CANCELLED_REASON: &str = "Password entry cancelled"; /// How long the resolve loop waits for a frontend reply before giving up. /// Generous enough that a human typing a password is never cut off, while /// still bounding the hang if the `main` webview is absent or unresponsive. -pub const PROMPT_TIMEOUT: Duration = Duration::from_secs(300); +pub const PROMPT_TIMEOUT: Duration = Duration::from_mins(5); fn unauthorized(reason: &str) -> Error { Error { kind: ErrorKind::Unauthorized, reason: reason.to_string() } From 88388e3a53755f0f06b9253a6536f0c60926d6bd Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 19:36:10 -0500 Subject: [PATCH 12/36] fix(password-gate): restore the approval dialog after the prompt, redact BridgeTools Recompute bridge-approval runtime visibility on every exit path from the password phase so a still-queued approval is not stranded off-screen, and replace BridgeTools' derived Debug with a hand-written impl that redacts the password. Co-Authored-By: Claude Opus 5 --- crates/sage-apps/src/bridge/bridge_request.rs | 41 +++++++++++++++---- crates/sage-apps/src/bridge/methods/shared.rs | 17 +++++++- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/crates/sage-apps/src/bridge/bridge_request.rs b/crates/sage-apps/src/bridge/bridge_request.rs index b972460e6..97d460118 100644 --- a/crates/sage-apps/src/bridge/bridge_request.rs +++ b/crates/sage-apps/src/bridge/bridge_request.rs @@ -3,16 +3,15 @@ use tauri::{AppHandle, Manager, State, Webview}; use crate::{ AppState, AppsHostState, BridgeApprovalsChangedEvent, BridgeCapability, BridgeContext, BridgeMethod, BridgeMethodCapability, BridgeOrigin, BridgeRegistry, BridgeRegistryKind, - BridgeTools, PendingBridgeApproval, ResolveBridgeApprovalArgs, RustBridgeApprovalBody, - RustBridgeApprovalRequest, RustBridgeInvokeResult, RustBridgeRequest, RustBridgeResponse, - RuntimeChangeSet, SYSTEM_APP_BRIDGE_APPROVAL_ID, SharedSageApp, SystemBridgeCapability, + BridgeTools, PendingBridgeApproval, ResolveBridgeApprovalArgs, RuntimeChangeSet, + RustBridgeApprovalBody, RustBridgeApprovalRequest, RustBridgeInvokeResult, RustBridgeRequest, + RustBridgeResponse, SYSTEM_APP_BRIDGE_APPROVAL_ID, SharedSageApp, SystemBridgeCapability, UserBridgeCapability, assert_bridge_origin, emit_bridge_response_to_app, emit_system_runtime_event_to_listeners, ensure_app_is_enabled_for_scope, - ensure_approval_expiry_loop, find_runtime_by_app_id_optional, - get_system_capability_definition, get_user_capability_definition, hide_runtime_inner, - list_pending_approvals, resolve_app, start_bridge_approval_runtime, - sync_bridge_approval_runtime, take_pending_approval, unix_timestamp_ms, - write_pending_approval, + ensure_approval_expiry_loop, find_runtime_by_app_id_optional, get_system_capability_definition, + get_user_capability_definition, hide_runtime_inner, list_pending_approvals, resolve_app, + start_bridge_approval_runtime, sync_bridge_approval_runtime, take_pending_approval, + unix_timestamp_ms, write_pending_approval, }; pub(crate) async fn process( @@ -145,7 +144,15 @@ pub(crate) async fn process_after_approval( // password dialog. hide_bridge_approval_runtime(app_handle, apps_state).await; - match password_gate_resolve(app_handle, app_state).await { + let resolved = password_gate_resolve(app_handle, app_state).await; + + // The prompt hid the approval runtime, so recompute visibility on every + // exit path from the password phase -- success, cancel, error, and the + // post-gate expiry check below alike. Without this a still-queued + // approval stays invisible with no way for the user to reach it. + restore_bridge_approval_runtime(app_handle, apps_state).await; + + match resolved { // The expiry check above ran before the prompt, and the prompt can // outlive the deadline, so the approval window is re-checked here. Ok(_) if unix_timestamp_ms() as u64 > pending.expires_at_ms => { @@ -323,6 +330,22 @@ async fn hide_bridge_approval_runtime( changes.emit(app_handle, apps_state).await; } +/// Recomputes bridge-approval runtime visibility after the password prompt, +/// re-showing the dialog when further approvals are still queued and letting +/// it stay killed when the queue is empty. Best effort, like the hide: a +/// failure here is logged and never aborts the request. +async fn restore_bridge_approval_runtime( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, +) { + if let Err(err) = sync_bridge_approval_runtime(app_handle, apps_state).await { + tracing::warn!( + error = %err, + "failed to restore the bridge approval runtime after the password prompt" + ); + } +} + /// Resolves the master-key password in the trusted `main` webview. The value /// never crosses into an app runtime: it is handed straight to the wallet /// method through `BridgeTools`. diff --git a/crates/sage-apps/src/bridge/methods/shared.rs b/crates/sage-apps/src/bridge/methods/shared.rs index 476dbdce5..668be9bef 100644 --- a/crates/sage-apps/src/bridge/methods/shared.rs +++ b/crates/sage-apps/src/bridge/methods/shared.rs @@ -48,7 +48,6 @@ pub(crate) struct BridgeContext<'a> { pub app: &'a SharedSageApp, } -#[derive(Debug)] pub(crate) struct BridgeTools<'a> { pub app_handle: &'a tauri::AppHandle, pub app_state: &'a tauri::State<'a, AppState>, @@ -58,6 +57,22 @@ pub(crate) struct BridgeTools<'a> { pub password: Option, } +/// Hand-written so the master-key password can never be printed. The whole +/// point of the gate is keeping that secret out of reach, and a derived +/// `Debug` would leak it into the logs the moment somebody adds a trace line. +/// The `Some`/`None` distinction is kept because it is useful for debugging; +/// the value never is. +impl std::fmt::Debug for BridgeTools<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BridgeTools") + .field("app_handle", &self.app_handle) + .field("app_state", &self.app_state) + .field("host_state", &self.host_state) + .field("password", &self.password.as_ref().map(|_| "")) + .finish() + } +} + #[derive(Debug, Clone)] pub(crate) struct BridgeMethodHandleError { pub code: &'static str, From dc84937b009a3b3fde9c1ebdd075c5df12ebeec5 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 19:43:22 -0500 Subject: [PATCH 13/36] feat(password-gate): force approval for protected wallets on auto-submit WalletSendXch::approval_request returned Ok(None) whenever the app held WalletSendXchAutoSubmit, even on a password-protected wallet -- silent auto-submit is incompatible with password protection since there is no UI moment to collect the password. Extracts a pure requires_approval predicate and threads a password_protected flag through BridgeContext, resolved from sage.wallet_config.wallets (not the expensive Argon2 Keychain::is_password_protected probe) at both BridgeContext construction sites in bridge_request.rs. --- crates/sage-apps/src/bridge/bridge_request.rs | 26 ++++++++++++- crates/sage-apps/src/bridge/methods/shared.rs | 5 +++ .../bridge/methods/user/wallet/send_xch.rs | 38 +++++++++++++++++-- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/crates/sage-apps/src/bridge/bridge_request.rs b/crates/sage-apps/src/bridge/bridge_request.rs index 97d460118..615651347 100644 --- a/crates/sage-apps/src/bridge/bridge_request.rs +++ b/crates/sage-apps/src/bridge/bridge_request.rs @@ -225,9 +225,14 @@ async fn process_shared( return Ok(response.into()); } + let password_protected = active_wallet_password_protected(app_state).await; + match method .prepare_approval( - BridgeContext { app }, + BridgeContext { + app, + password_protected, + }, BridgeTools { app_handle, app_state, @@ -278,9 +283,14 @@ async fn execute_bridge_request( Err(response) => return response, }; + let password_protected = active_wallet_password_protected(app_state).await; + let result = method .handle( - BridgeContext { app: &origin.app }, + BridgeContext { + app: &origin.app, + password_protected, + }, BridgeTools { app_handle, app_state, @@ -369,6 +379,18 @@ async fn active_wallet_fingerprint(app_state: &State<'_, AppState>) -> Option) -> bool { + app_state + .lock() + .await + .wallet_config() + .is_some_and(|wallet| wallet.password_protected) +} + /// Whether resuming this approval needs the master-key password. /// /// Only bodies whose handler reaches a wallet secret are gated. Capability and diff --git a/crates/sage-apps/src/bridge/methods/shared.rs b/crates/sage-apps/src/bridge/methods/shared.rs index 668be9bef..a4c62bd98 100644 --- a/crates/sage-apps/src/bridge/methods/shared.rs +++ b/crates/sage-apps/src/bridge/methods/shared.rs @@ -46,6 +46,11 @@ pub(crate) enum BridgeMethodCapability { #[derive(Debug)] pub(crate) struct BridgeContext<'a> { pub app: &'a SharedSageApp, + /// Whether the currently active wallet is password-protected. Resolved + /// once at request time (from `sage.wallet_config`, not an Argon2 probe) + /// because `approval_request` is synchronous and cannot reach the async + /// app state itself. + pub password_protected: bool, } pub(crate) struct BridgeTools<'a> { diff --git a/crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs b/crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs index d73f99f4c..e4e60406a 100644 --- a/crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs +++ b/crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs @@ -12,6 +12,11 @@ use crate::{ #[derive(Debug, Clone, Copy)] pub struct WalletSendXch; +/// Whether this request needs a user approval step. +fn requires_approval(auto_submit_granted: bool, wallet_protected: bool) -> bool { + !auto_submit_granted || wallet_protected +} + #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct WalletSendXchParams { @@ -60,10 +65,11 @@ impl BridgeMethod for WalletSendXch { ctx: BridgeContext<'_>, request: &RustBridgeRequest, ) -> BridgeApprovalRequestResult { - if ctx + let auto_submit_granted = ctx .app - .is_capability_granted(UserBridgeCapability::WalletSendXchAutoSubmit.into()) - { + .is_capability_granted(UserBridgeCapability::WalletSendXchAutoSubmit.into()); + + if !requires_approval(auto_submit_granted, ctx.password_protected) { return Ok(None); } @@ -97,3 +103,29 @@ impl BridgeMethod for WalletSendXch { Ok(Box::new(result)) } } + +#[cfg(test)] +mod tests { + /// A password-protected wallet must always produce an approval, even when + /// the app holds `WalletSendXchAutoSubmit`. Silent auto-submit is + /// incompatible with password protection: there would be no UI moment in + /// which to collect the password. + #[test] + fn protected_wallet_forces_approval_despite_auto_submit_grant() { + assert!(super::requires_approval( + /* auto_submit_granted */ true, + /* wallet_protected */ true, + )); + } + + #[test] + fn unprotected_wallet_still_honours_auto_submit_grant() { + assert!(!super::requires_approval(true, false)); + } + + #[test] + fn without_the_grant_approval_is_always_required() { + assert!(super::requires_approval(false, false)); + assert!(super::requires_approval(false, true)); + } +} From c3053db651bc1a6d0eb5eea05836c68afc963d4a Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 19:49:13 -0500 Subject: [PATCH 14/36] feat(password-gate): invert PasswordContext into a responder PasswordContext now listens for events.passwordRequest from Rust and replies via commands.submitPasswordResponse, instead of callers invoking requestPassword(hasPassword). Exposes only requireLocalAuth() for the two UI-only Settings.tsx gates (RPC server start, run-on-startup toggle) that have no Rust unlock operation behind them. Queues concurrent requiresPassword requests (keyed by requestId) rather than clobbering one dialog with another, so a second gated Rust operation in flight doesn't silently hang for the 5-minute timeout. PasswordDialog gains an optional attemptsRemaining prop to surface retry state. This intentionally breaks the remaining requestPassword call sites (WalletCard, ConfirmationDialog, useOfferProcessor, Offer, WalletConnect*) for follow-up tasks to fix. --- src/components/dialogs/PasswordDialog.tsx | 9 ++ src/contexts/PasswordContext.tsx | 148 ++++++++++++++-------- src/pages/Settings.tsx | 8 +- 3 files changed, 109 insertions(+), 56 deletions(-) diff --git a/src/components/dialogs/PasswordDialog.tsx b/src/components/dialogs/PasswordDialog.tsx index 8f7803615..56d48fbbf 100644 --- a/src/components/dialogs/PasswordDialog.tsx +++ b/src/components/dialogs/PasswordDialog.tsx @@ -15,12 +15,14 @@ import { Input } from '../ui/input'; export interface PasswordDialogProps { open: boolean; + attemptsRemaining?: number; onSubmit: (password: string) => void; onCancel: () => void; } export function PasswordDialog({ open, + attemptsRemaining, onSubmit, onCancel, }: PasswordDialogProps) { @@ -59,6 +61,13 @@ export function PasswordDialog({ + {attemptsRemaining !== undefined && ( +

+ + Incorrect password. {attemptsRemaining} attempts remaining. + +

+ )} void; -} - export interface PasswordContextType { - requestPassword: (hasPassword: boolean) => Promise; + /** + * UI-only authentication gate for actions that touch no wallet secret + * (starting the RPC server, toggling run-on-startup). Returns true if the + * caller may proceed. This deliberately does NOT go through the Rust + * password gate — there is no unlock operation behind it. + */ + requireLocalAuth: () => Promise; } export const PasswordContext = createContext( @@ -21,68 +31,104 @@ export const PasswordContext = createContext( ); export function PasswordProvider({ children }: { children: ReactNode }) { - const [open, setOpen] = useState(false); - const pendingRef = useRef(null); + // A queue rather than a single slot: Rust can have multiple gated + // operations in flight concurrently (one per requestId), and each one + // needs a reply eventually or it hangs for the full 5-minute timeout. + // The dialog always shows the front of the queue; later requests wait + // their turn instead of clobbering the one in progress. + const [queue, setQueue] = useState([]); + const pending = queue[0] ?? null; const { enabled: biometricEnabled } = useBiometric(); - - // Biometric caching for standalone gate const lastBiometricPromptRef = useRef(null); - const requestPassword = useCallback( - async (hasPassword: boolean): Promise => { - // Case 1: Has password → password takes precedence, show dialog - if (hasPassword) { - return new Promise((resolve) => { - pendingRef.current = { resolve }; - setOpen(true); + const runBiometric = useCallback(async (): Promise => { + const now = performance.now(); + if ( + lastBiometricPromptRef.current !== null && + now - lastBiometricPromptRef.current < BIOMETRIC_CACHE_MS + ) { + return true; + } + try { + const { authenticate } = await import('@tauri-apps/plugin-biometric'); + await authenticate('Authenticate to continue', { + allowDeviceCredential: false, + }); + lastBiometricPromptRef.current = now; + return true; + } catch { + return false; + } + }, []); + + const requireLocalAuth = useCallback(async (): Promise => { + if (!biometricEnabled || !isMobile) return true; + return runBiometric(); + }, [biometricEnabled, runBiometric]); + + useEffect(() => { + const unlisten = events.passwordRequest.listen(async ({ payload }) => { + // Case 1: password takes precedence — enqueue for the dialog. If a + // request with the same requestId is already queued (a retry after a + // wrong password), replace it in place rather than duplicating it. + if (payload.requiresPassword) { + setQueue((prev) => { + const index = prev.findIndex( + (r) => r.requestId === payload.requestId, + ); + if (index === -1) return [...prev, payload]; + const next = [...prev]; + next[index] = payload; + return next; }); + return; } - // Case 2: No password, biometric enabled → standalone biometric gate with 5-min cache + // Case 2: no password, biometric enabled — standalone gate with 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 - } + const ok = await runBiometric(); + await commands.submitPasswordResponse( + payload.requestId, + ok ? { kind: 'no_auth_needed' } : { kind: 'cancelled' }, + ); + return; } - // Case 3: No password, no biometric → no auth needed - return null; + // Case 3: no password, no biometric — nothing to do. + await commands.submitPasswordResponse(payload.requestId, { + kind: 'no_auth_needed', + }); + }); + + return () => { + unlisten.then((fn) => fn()); + }; + }, [biometricEnabled, runBiometric]); + + const handleSubmit = useCallback( + (password: string) => { + if (!pending) return; + setQueue((prev) => prev.slice(1)); + commands.submitPasswordResponse(pending.requestId, { + kind: 'password', + password, + }); }, - [biometricEnabled], + [pending], ); - const handleSubmit = useCallback((password: string) => { - setOpen(false); - pendingRef.current?.resolve(password); - pendingRef.current = null; - }, []); - const handleCancel = useCallback(() => { - setOpen(false); - pendingRef.current?.resolve(undefined); - pendingRef.current = null; - }, []); + if (!pending) return; + setQueue((prev) => prev.slice(1)); + commands.submitPasswordResponse(pending.requestId, { kind: 'cancelled' }); + }, [pending]); return ( - + {children} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index d4f84330c..58de5ac00 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1080,7 +1080,7 @@ function ColdWalletSettings() { function RpcSettings() { const { addError } = useErrors(); - const { requestPassword } = usePassword(); + const { requireLocalAuth } = usePassword(); const [isRunning, setIsRunning] = useState(false); const [runOnStartup, setRunOnStartup] = useState(false); @@ -1103,8 +1103,7 @@ function RpcSettings() { }, [addError]); const start = async () => { - const auth = await requestPassword(false); - if (auth === undefined) return; + if (!(await requireLocalAuth())) return; commands .startRpcServer() @@ -1120,8 +1119,7 @@ function RpcSettings() { }; const toggleRunOnStartup = async (checked: boolean) => { - const auth = await requestPassword(false); - if (auth === undefined) return; + if (!(await requireLocalAuth())) return; commands .setRpcRunOnStartup(checked) From 5397d9354e42cb29add30842b4e890f41cbd77cd Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 19:58:54 -0500 Subject: [PATCH 15/36] refactor(password-gate): drop password plumbing from React call sites --- src/components/ConfirmationDialog.tsx | 15 --------------- src/components/WalletCard.tsx | 26 +++----------------------- src/hooks/useOfferProcessor.ts | 13 ------------- src/pages/Offer.tsx | 7 ------- src/pages/Settings.tsx | 5 ----- 5 files changed, 3 insertions(+), 63 deletions(-) diff --git a/src/components/ConfirmationDialog.tsx b/src/components/ConfirmationDialog.tsx index 07dd3ff22..9442f5331 100644 --- a/src/components/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog.tsx @@ -9,7 +9,6 @@ import { } from '@/components/ui/dialog'; import { LoadingButton } from '@/components/ui/loading-button'; import { useErrors } from '@/hooks/useErrors'; -import { usePassword } from '@/hooks/usePassword'; import { useWallet } from '@/contexts/WalletContext'; import { fromMojos } from '@/lib/utils'; import { useWalletState } from '@/state'; @@ -67,8 +66,6 @@ export default function ConfirmationDialog({ const ticker = walletState.sync.unit.ticker; const { addError } = useErrors(); - const { requestPassword } = usePassword(); - const { wallet } = useWallet(); const { isReadOnly, allowUnsigned } = useWallet(); const isColdWalletUnsignedMode = isReadOnly && allowUnsigned; @@ -531,11 +528,6 @@ export default function ConfirmationDialog({ { - const password = await requestPassword( - wallet?.has_password ?? false, - ); - if (password === undefined) return; - commands .signCoinSpends({ coin_spends: @@ -544,7 +536,6 @@ export default function ConfirmationDialog({ : 'coin_spends' in response ? response.coin_spends : response.spend_bundle.coin_spends, - password, }) .then((data) => { setSignature(data.spend_bundle.aggregated_signature); @@ -660,15 +651,9 @@ export default function ConfirmationDialog({ response !== null && 'coin_spends' in response ) { - 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); diff --git a/src/components/WalletCard.tsx b/src/components/WalletCard.tsx index 0aafd7b4d..1e5a870f3 100644 --- a/src/components/WalletCard.tsx +++ b/src/components/WalletCard.tsx @@ -21,7 +21,6 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useErrors } from '@/hooks/useErrors'; -import { usePassword } from '@/hooks/usePassword'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { t } from '@lingui/core/macro'; @@ -66,7 +65,6 @@ export function WalletCard({ const navigate = useNavigate(); const { addError } = useErrors(); const { setWallet } = useWallet(); - const { requestPassword } = usePassword(); const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isDetailsOpen, setIsDetailsOpen] = useState(false); @@ -79,14 +77,8 @@ export function WalletCard({ const { currentTheme } = useTheme(); const deleteSelf = async () => { - const password = await requestPassword(info.has_password); - if (password === undefined) { - setIsDeleteOpen(false); - return; - } - await commands - .deleteKey({ fingerprint: info.fingerprint, password }) + .deleteKey({ fingerprint: info.fingerprint }) .then(() => { setKeys(keys.filter((key) => key.fingerprint !== info.fingerprint)); }) @@ -177,24 +169,12 @@ export function WalletCard({ return; } - const password = await requestPassword(info.has_password); - if (password === undefined) { - setIsDetailsOpen(false); - return; - } - commands - .getSecretKey({ fingerprint: info.fingerprint, password }) + .getSecretKey({ fingerprint: info.fingerprint }) .then((data) => data.secrets !== null && setSecrets(data.secrets)) .catch(addError); })(); - }, [ - isDetailsOpen, - info.fingerprint, - info.has_password, - addError, - requestPassword, - ]); + }, [isDetailsOpen, info.fingerprint, info.has_password, addError]); const values = useSortable({ id: draggable ? info.fingerprint : 'not-draggable', diff --git a/src/hooks/useOfferProcessor.ts b/src/hooks/useOfferProcessor.ts index 9634409a8..e4f80eaae 100644 --- a/src/hooks/useOfferProcessor.ts +++ b/src/hooks/useOfferProcessor.ts @@ -1,6 +1,4 @@ import { commands, OfferAmount } from '@/bindings'; -import { useWallet } from '@/contexts/WalletContext'; -import { usePassword } from '@/hooks/usePassword'; import { toMojos } from '@/lib/utils'; import { OfferState, useWalletState } from '@/state'; import { t } from '@lingui/core/macro'; @@ -28,8 +26,6 @@ export function useOfferProcessor({ onProgress, }: UseOfferProcessorProps): UseOfferProcessorReturn { const walletState = useWalletState(); - const { requestPassword } = usePassword(); - const { wallet } = useWallet(); const [createdOffers, setCreatedOffers] = useState([]); const [isProcessing, setIsProcessing] = useState(false); const isCancelled = useRef(false); @@ -62,11 +58,6 @@ export function useOfferProcessor({ expiresAtSecond = Math.ceil(Date.now() / 1000) + totalSeconds; } - const password = await requestPassword(wallet?.has_password ?? false); - if (password === undefined) { - throw new Error(t`Authentication was cancelled`); - } - const offeredTokens = offerState.offered.tokens.map((token) => ({ asset_id: token.asset_id, amount: toMojos(token.amount.toString(), token.asset_id ? 3 : 12), @@ -121,7 +112,6 @@ export function useOfferProcessor({ walletState.sync.unit.precision, ), expires_at_second: expiresAtSecond, - password, }); if (!isCancelled.current) { newOffers.push(data.offer); @@ -165,7 +155,6 @@ export function useOfferProcessor({ walletState.sync.unit.precision, ), expires_at_second: expiresAtSecond, - password, }); if (!isCancelled.current) { setCreatedOffers([data.offer]); @@ -185,8 +174,6 @@ export function useOfferProcessor({ offerState, splitNftOffers, walletState.sync.unit.precision, - requestPassword, - wallet?.has_password, onProcessingEnd, onProgress, ]); diff --git a/src/pages/Offer.tsx b/src/pages/Offer.tsx index b92a95da7..02fcd861f 100644 --- a/src/pages/Offer.tsx +++ b/src/pages/Offer.tsx @@ -17,7 +17,6 @@ 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'; @@ -33,8 +32,6 @@ export function Offer() { const { isReadOnly } = useWallet(); const walletState = useWalletState(); const navigate = useNavigate(); - const { wallet } = useWallet(); - const { requestPassword } = usePassword(); const [isLoading, setIsLoading] = useState(true); const [loadingStatus, setLoadingStatus] = useState(t`Initializing...`); @@ -108,14 +105,10 @@ export function Offer() { return; } - const password = await requestPassword(wallet?.has_password ?? false); - if (password === undefined) return; - try { 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/Settings.tsx b/src/pages/Settings.tsx index 58de5ac00..fa86bb61a 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1164,7 +1164,6 @@ function RpcSettings() { function WalletSettings({ fingerprint }: { fingerprint: number }) { const { addError } = useErrors(); - const { requestPassword } = usePassword(); const { setWallet: setGlobalWallet } = useWallet(); const walletState = useWalletState(); @@ -1364,16 +1363,12 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { const handler = async (values: z.infer) => { const needsPassword = key?.has_secrets && hardened; if (needsPassword) { - const password = await requestPassword(key?.has_password ?? false); - if (password === undefined) return; - setPending(true); commands .increaseDerivationIndex({ index: parseInt(values.index), hardened: true, unhardened, - password, }) .then(() => { setDeriveOpen(false); From 420b4845b6de9b9948b3417bbbc52e207fd11bd5 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 19:58:55 -0500 Subject: [PATCH 16/36] refactor(password-gate): drop password plumbing from WalletConnect layer --- src/contexts/WalletConnectContext.tsx | 6 +---- src/walletconnect/commands/chip0002.ts | 12 +--------- src/walletconnect/commands/high-level.ts | 18 +-------------- src/walletconnect/commands/offers.ts | 28 +++--------------------- src/walletconnect/handler.ts | 21 +++++++----------- 5 files changed, 14 insertions(+), 71 deletions(-) diff --git a/src/contexts/WalletConnectContext.tsx b/src/contexts/WalletConnectContext.tsx index b271dc602..d949997e3 100644 --- a/src/contexts/WalletConnectContext.tsx +++ b/src/contexts/WalletConnectContext.tsx @@ -23,7 +23,6 @@ import { Switch } from '@/components/ui/switch'; import { useWallet } from '@/contexts/WalletContext'; import { useDefaultFee } from '@/hooks/useDefaultFee'; import { useErrors } from '@/hooks/useErrors'; -import { usePassword } from '@/hooks/usePassword'; import { decodeHexMessage, fromMojos, toMojos, isHex } from '@/lib/utils'; import { useWalletState } from '@/state'; import { @@ -66,7 +65,6 @@ type SessionRequest = SignClientTypes.EventArguments['session_request']; export function WalletConnectProvider({ children }: { children: ReactNode }) { const { wallet, isReadOnly } = useWallet(); const { addError } = useErrors(); - const { requestPassword } = usePassword(); const [signClient, setSignClient] = useState @@ -106,8 +104,6 @@ export function WalletConnectProvider({ children }: { children: ReactNode }) { method, request.params.request.params, { - requestPassword, - hasPassword: wallet?.has_password ?? false, isReadOnly, }, ); @@ -143,7 +139,7 @@ export function WalletConnectProvider({ children }: { children: ReactNode }) { }); } }, - [signClient, addError, requestPassword, wallet?.has_password, isReadOnly], + [signClient, addError, isReadOnly], ); useEffect(() => { diff --git a/src/walletconnect/commands/chip0002.ts b/src/walletconnect/commands/chip0002.ts index 4e17407ab..9dff5004e 100644 --- a/src/walletconnect/commands/chip0002.ts +++ b/src/walletconnect/commands/chip0002.ts @@ -1,7 +1,6 @@ import { commands } from '@/bindings'; import { BigNumber } from 'bignumber.js'; import { Params } from '../commands'; -import { HandlerContext } from '../handler'; export async function handleChainId() { const data = await commands.getNetwork({}); @@ -74,11 +73,7 @@ export async function handleGetAssetBalance( export async function handleSignCoinSpends( params: Params<'chip0002_signCoinSpends'>, - context: HandlerContext, ) { - const password = await context.requestPassword(context.hasPassword); - if (password === undefined) throw new Error('Authentication failed'); - const data = await commands.signCoinSpends({ coin_spends: params.coinSpends.map((coinSpend) => { return { @@ -93,7 +88,6 @@ export async function handleSignCoinSpends( }), partial: params.partialSign, auto_submit: false, - password, }); return data.spend_bundle.aggregated_signature; @@ -101,12 +95,8 @@ export async function handleSignCoinSpends( export async function handleSignMessage( params: Params<'chip0002_signMessage'>, - context: HandlerContext, ) { - const password = await context.requestPassword(context.hasPassword); - if (password === undefined) throw new Error('Authentication failed'); - - const data = await commands.signMessageWithPublicKey({ ...params, password }); + const data = await commands.signMessageWithPublicKey({ ...params }); return data.signature; } diff --git a/src/walletconnect/commands/high-level.ts b/src/walletconnect/commands/high-level.ts index a5865af66..f91944dd1 100644 --- a/src/walletconnect/commands/high-level.ts +++ b/src/walletconnect/commands/high-level.ts @@ -1,7 +1,6 @@ import { commands } from '@/bindings'; import { useWalletState } from '@/state'; import { Params, Return } from '../commands'; -import { HandlerContext } from '../handler'; export async function handleGetNfts( params: Params<'chia_getNfts'>, @@ -45,11 +44,7 @@ export async function handleGetNfts( export async function handleSend( params: Params<'chia_send'>, - context: HandlerContext, ): Promise> { - const password = await context.requestPassword(context.hasPassword); - if (password === undefined) throw new Error('Authentication failed'); - if (params.assetId) { await commands.sendCat({ asset_id: params.assetId, @@ -58,7 +53,6 @@ export async function handleSend( fee: params.fee ?? 0, memos: params.memos ?? [], auto_submit: true, - password, }); } else { await commands.sendXch({ @@ -67,7 +61,6 @@ export async function handleSend( fee: params.fee ?? 0, memos: params.memos ?? [], auto_submit: true, - password, }); } @@ -82,26 +75,17 @@ export async function handleGetAddress(): Promise> { export async function handleSignMessageByAddress( params: Params<'chia_signMessageByAddress'>, - context: HandlerContext, ) { - const password = await context.requestPassword(context.hasPassword); - if (password === undefined) throw new Error('Authentication failed'); - - return await commands.signMessageByAddress({ ...params, password }); + return await commands.signMessageByAddress({ ...params }); } export async function handleBulkMintNfts( params: Params<'chia_bulkMintNfts'>, - context: HandlerContext, ): Promise> { - const password = await context.requestPassword(context.hasPassword); - if (password === undefined) throw new Error('Authentication failed'); - const response = await commands.bulkMintNfts({ 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 b15fd2699..7518ea954 100644 --- a/src/walletconnect/commands/offers.ts +++ b/src/walletconnect/commands/offers.ts @@ -1,14 +1,7 @@ import { commands } from '@/bindings'; import { Params } from '../commands'; -import { HandlerContext } from '../handler'; - -export async function handleCreateOffer( - params: Params<'chia_createOffer'>, - context: HandlerContext, -) { - const password = await context.requestPassword(context.hasPassword); - if (password === undefined) throw new Error('Authentication failed'); +export async function handleCreateOffer(params: Params<'chia_createOffer'>) { const data = await commands.makeOffer({ fee: params.fee ?? 0, offered_assets: params.offerAssets.map((asset) => ({ @@ -22,7 +15,6 @@ export async function handleCreateOffer( amount: asset.amount, })), expires_at_second: null, - password, }); return { @@ -31,35 +23,21 @@ export async function handleCreateOffer( }; } -export async function handleTakeOffer( - params: Params<'chia_takeOffer'>, - context: HandlerContext, -) { - const password = await context.requestPassword(context.hasPassword); - if (password === undefined) throw new Error('Authentication failed'); - +export async function handleTakeOffer(params: Params<'chia_takeOffer'>) { const data = await commands.takeOffer({ offer: params.offer, fee: params.fee ?? 0, auto_submit: true, - password, }); return { id: data.transaction_id }; } -export async function handleCancelOffer( - params: Params<'chia_cancelOffer'>, - context: HandlerContext, -) { - const password = await context.requestPassword(context.hasPassword); - if (password === undefined) throw new Error('Authentication failed'); - +export async function handleCancelOffer(params: Params<'chia_cancelOffer'>) { await commands.cancelOffer({ 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 cb02725a1..7e8b16646 100644 --- a/src/walletconnect/handler.ts +++ b/src/walletconnect/handler.ts @@ -25,8 +25,6 @@ import { import { t } from '@lingui/core/macro'; export interface HandlerContext { - requestPassword: (hasPassword: boolean) => Promise; - hasPassword: boolean; /** True when the active wallet has no signing keys (cold/watch-only). */ isReadOnly: boolean; } @@ -67,30 +65,27 @@ export const handleCommand = async ( case 'chip0002_getAssetBalance': return await handleGetAssetBalance(parseCommand(command, params)); case 'chip0002_signCoinSpends': - return await handleSignCoinSpends(parseCommand(command, params), context); + return await handleSignCoinSpends(parseCommand(command, params)); case 'chip0002_signMessage': - return await handleSignMessage(parseCommand(command, params), context); + return await handleSignMessage(parseCommand(command, params)); case 'chip0002_sendTransaction': return await handleSendTransaction(parseCommand(command, params)); case 'chia_createOffer': - return await handleCreateOffer(parseCommand(command, params), context); + return await handleCreateOffer(parseCommand(command, params)); case 'chia_takeOffer': - return await handleTakeOffer(parseCommand(command, params), context); + return await handleTakeOffer(parseCommand(command, params)); case 'chia_cancelOffer': - return await handleCancelOffer(parseCommand(command, params), context); + return await handleCancelOffer(parseCommand(command, params)); case 'chia_getNfts': return await handleGetNfts(parseCommand(command, params)); case 'chia_send': - return await handleSend(parseCommand(command, params), context); + return await handleSend(parseCommand(command, params)); case 'chia_getAddress': return await handleGetAddress(); case 'chia_signMessageByAddress': - return await handleSignMessageByAddress( - parseCommand(command, params), - context, - ); + return await handleSignMessageByAddress(parseCommand(command, params)); case 'chia_bulkMintNfts': - return await handleBulkMintNfts(parseCommand(command, params), context); + return await handleBulkMintNfts(parseCommand(command, params)); default: throw new Error(`Unknown command: ${command}`); } From 20819adc744b74862f9668293994f9d262eb4b77 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 20:02:55 -0500 Subject: [PATCH 17/36] fix(password-gate): stay silent when the user cancels the prompt Dismissing the password dialog is a deliberate choice, not a failure. addError now short-circuits on ErrorKind::Unauthorized with the exact CANCELLED_REASON string from sage-password-gate::resolve, before any other branch or state update, so no toast/dialog appears. Other unauthorized errors (wrong password lockout, prompt timeout, etc.) are unaffected. --- src/contexts/ErrorContext.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/contexts/ErrorContext.tsx b/src/contexts/ErrorContext.tsx index fb2167d8e..c6fe448db 100644 --- a/src/contexts/ErrorContext.tsx +++ b/src/contexts/ErrorContext.tsx @@ -27,10 +27,23 @@ export const ErrorContext = createContext( undefined, ); +// Must match `CANCELLED_REASON` in crates/sage-password-gate/src/resolve.rs. +// That constant is returned with ErrorKind::Unauthorized when the user +// dismisses the password prompt; if the two strings drift, the cancel toast +// silently comes back. +const PASSWORD_CANCELLED_REASON = 'Password entry cancelled'; + export function ErrorProvider({ children }: { children: ReactNode }) { const [errors, setErrors] = useState([]); const addError = useCallback((error: CustomError) => { + if ( + error.kind === 'unauthorized' && + error.reason === PASSWORD_CANCELLED_REASON + ) { + // Deliberate user cancellation of the password prompt, not a failure. + return; + } if (error.kind === 'incorrect_password') { // Wrong password — AES decryption failed toast.error(t`Incorrect password`); From 55dfcba140f72ba08445c90018799c49c67ff4b4 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 20:04:43 -0500 Subject: [PATCH 18/36] fix(password-gate): surface too-many-attempts and prompt-timeout errors The generic unauthorized-toast filter (kind === 'unauthorized' with reason containing 'not found'/'No secret') predates the password gate and was silently swallowing its two failure reasons: "Too many incorrect password attempts" (resolve.rs:64) and "Password prompt timed out" (lib.rs:92). Users saw no feedback at all on a real auth failure. Add both reasons as named, Rust-file-referencing constants and extend the toast condition to match them, leaving the "not found"/"No secret" behaviour and the silent NotLoggedIn/NoSigningKey fallthrough untouched. --- src/contexts/ErrorContext.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/contexts/ErrorContext.tsx b/src/contexts/ErrorContext.tsx index c6fe448db..fb8235e50 100644 --- a/src/contexts/ErrorContext.tsx +++ b/src/contexts/ErrorContext.tsx @@ -33,6 +33,15 @@ export const ErrorContext = createContext( // silently comes back. const PASSWORD_CANCELLED_REASON = 'Password entry cancelled'; +// Must match the reason returned at crates/sage-password-gate/src/resolve.rs:64 +// when the user exhausts MAX_ATTEMPTS incorrect password attempts. +const PASSWORD_TOO_MANY_ATTEMPTS_REASON = + 'Too many incorrect password attempts'; + +// Must match the reason returned at crates/sage-password-gate/src/lib.rs:92 +// when the password prompt is not answered within PROMPT_TIMEOUT. +const PASSWORD_PROMPT_TIMED_OUT_REASON = 'Password prompt timed out'; + export function ErrorProvider({ children }: { children: ReactNode }) { const [errors, setErrors] = useState([]); @@ -54,8 +63,14 @@ export function ErrorProvider({ children }: { children: ReactNode }) { } if (error.kind === 'unauthorized') { const reason = error.reason ?? ''; - if (reason.includes('not found') || reason.includes('No secret')) { - // KeyNotFound or NoSecretKey — wallet-level issue, not a transition + if ( + reason.includes('not found') || + reason.includes('No secret') || + reason === PASSWORD_TOO_MANY_ATTEMPTS_REASON || + reason === PASSWORD_PROMPT_TIMED_OUT_REASON + ) { + // KeyNotFound / NoSecretKey (wallet-level issue, not a transition), + // or a genuine password-gate failure (too many attempts / timeout). toast.error(error.reason); } // NotLoggedIn / NoSigningKey during wallet transitions are silently ignored From c8aa6d564fb2d3c59d49aa338f4c09ba5c3705b5 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 20:08:59 -0500 Subject: [PATCH 19/36] chore: fix pre-existing manual_string_new lint in sage-rpc tests --- crates/sage-rpc/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/sage-rpc/src/tests.rs b/crates/sage-rpc/src/tests.rs index 4a7b9c086..62ac0c667 100644 --- a/crates/sage-rpc/src/tests.rs +++ b/crates/sage-rpc/src/tests.rs @@ -350,7 +350,7 @@ async fn test_change_password() -> Result<()> { // Set password app.change_password(ChangePassword { fingerprint, - old_password: "".to_string(), + old_password: String::new(), new_password: "secret".to_string(), }) .await?; From 9a6d4c14b3bf931daf2f84c7ef2b9fd78d98a1a9 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 20:11:47 -0500 Subject: [PATCH 20/36] chore(password-gate): cargo fmt formatting fixes --- crates/sage-api/src/lib.rs | 10 ++-- .../bridge/methods/user/wallet/send_xch.rs | 3 +- crates/sage-password-gate/src/lib.rs | 23 ++++++++-- crates/sage-password-gate/src/resolve.rs | 46 +++++++++++++++---- 4 files changed, 61 insertions(+), 21 deletions(-) diff --git a/crates/sage-api/src/lib.rs b/crates/sage-api/src/lib.rs index 7e7d48bc3..466c56db1 100644 --- a/crates/sage-api/src/lib.rs +++ b/crates/sage-api/src/lib.rs @@ -41,9 +41,10 @@ mod password_gate_drift { let entry = entry.unwrap(); let path = entry.path(); if path.extension().and_then(|extension| extension.to_str()) == Some("rs") { - sources.push(std::fs::read_to_string(&path).unwrap_or_else(|error| { - panic!("failed to read {path:?}: {error}") - })); + sources.push( + std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {path:?}: {error}")), + ); } } @@ -59,7 +60,8 @@ mod password_gate_drift { } assert_eq!( - gated, discovered, + gated, + discovered, "password-gated.json is out of sync with the request types.\n\ Only in password-gated.json: {:?}\n\ Only in request types: {:?}", diff --git a/crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs b/crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs index e4e60406a..5cda0eb38 100644 --- a/crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs +++ b/crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs @@ -113,8 +113,7 @@ mod tests { #[test] fn protected_wallet_forces_approval_despite_auto_submit_grant() { assert!(super::requires_approval( - /* auto_submit_granted */ true, - /* wallet_protected */ true, + /* auto_submit_granted */ true, /* wallet_protected */ true, )); } diff --git a/crates/sage-password-gate/src/lib.rs b/crates/sage-password-gate/src/lib.rs index 2c424ba19..f79c9b00c 100644 --- a/crates/sage-password-gate/src/lib.rs +++ b/crates/sage-password-gate/src/lib.rs @@ -119,7 +119,10 @@ pub async fn resolve( let sage = state.lock().await; let fingerprint = sage .wallet() - .map_err(|err| Error { kind: err.kind(), reason: err.to_string() })? + .map_err(|err| Error { + kind: err.kind(), + reason: err.to_string(), + })? .fingerprint; let requires_password = sage .wallet_config @@ -145,7 +148,10 @@ struct SageVerifier<'a> { impl PasswordVerifier for SageVerifier<'_> { async fn verify(&self, fingerprint: u32, password: &str) -> Result { let sage = self.state.lock().await; - match sage.keychain.extract_secrets(fingerprint, password.as_bytes()) { + match sage + .keychain + .extract_secrets(fingerprint, password.as_bytes()) + { Ok(_) => Ok(true), Err(sage_keychain::KeychainError::Decrypt) => Ok(false), Err(err) => Err(Error { @@ -191,9 +197,15 @@ mod tests { let (tx, _rx) = tokio::sync::oneshot::channel(); gate.register("req-2".to_string(), tx).await; - gate.deliver("req-2", PasswordOutcome::Cancelled).await.unwrap(); + gate.deliver("req-2", PasswordOutcome::Cancelled) + .await + .unwrap(); - assert!(gate.deliver("req-2", PasswordOutcome::Cancelled).await.is_err()); + assert!( + gate.deliver("req-2", PasswordOutcome::Cancelled) + .await + .is_err() + ); } #[tokio::test] @@ -207,7 +219,8 @@ mod tests { // Keep the sender alive for the whole wait so the failure is a // genuine timeout, not the channel being dropped. let waiting_gate = gate.clone(); - let waiter = tokio::spawn(async move { waiting_gate.await_outcome("req-timeout", rx).await }); + let waiter = + tokio::spawn(async move { waiting_gate.await_outcome("req-timeout", rx).await }); tokio::time::advance(PROMPT_TIMEOUT + std::time::Duration::from_secs(1)).await; diff --git a/crates/sage-password-gate/src/resolve.rs b/crates/sage-password-gate/src/resolve.rs index 988c120e5..99a135966 100644 --- a/crates/sage-password-gate/src/resolve.rs +++ b/crates/sage-password-gate/src/resolve.rs @@ -18,7 +18,10 @@ pub const CANCELLED_REASON: &str = "Password entry cancelled"; pub const PROMPT_TIMEOUT: Duration = Duration::from_mins(5); fn unauthorized(reason: &str) -> Error { - Error { kind: ErrorKind::Unauthorized, reason: reason.to_string() } + Error { + kind: ErrorKind::Unauthorized, + reason: reason.to_string(), + } } /// Prompts the frontend for a password and verifies it against the keychain, @@ -99,7 +102,12 @@ mod tests { impl Prompter for MockPrompter { async fn prompt(&self, request: PasswordRequest) -> Result { self.seen.lock().unwrap().push(request); - Ok(self.scripted.lock().unwrap().pop().expect("prompted more times than scripted")) + Ok(self + .scripted + .lock() + .unwrap() + .pop() + .expect("prompted more times than scripted")) } } @@ -112,10 +120,16 @@ mod tests { #[async_trait] impl PasswordVerifier for KeychainVerifier { async fn verify(&self, fingerprint: u32, password: &str) -> Result { - match self.keychain.extract_secrets(fingerprint, password.as_bytes()) { + match self + .keychain + .extract_secrets(fingerprint, password.as_bytes()) + { Ok(_) => Ok(true), Err(KeychainError::Decrypt) => Ok(false), - Err(err) => Err(Error { kind: ErrorKind::Internal, reason: err.to_string() }), + Err(err) => Err(Error { + kind: ErrorKind::Internal, + reason: err.to_string(), + }), } } } @@ -130,7 +144,9 @@ mod tests { } fn pw(s: &str) -> PasswordOutcome { - PasswordOutcome::Password { password: s.to_string() } + PasswordOutcome::Password { + password: s.to_string(), + } } #[tokio::test] @@ -138,7 +154,9 @@ mod tests { let (verifier, fingerprint) = protected_keychain("hunter2"); let prompter = MockPrompter::new(vec![pw("hunter2")]); - let result = resolve_with(&prompter, &verifier, fingerprint, true).await.unwrap(); + let result = resolve_with(&prompter, &verifier, fingerprint, true) + .await + .unwrap(); assert_eq!(result, Some("hunter2".to_string())); let seen = prompter.seen(); @@ -153,7 +171,9 @@ mod tests { let (verifier, fingerprint) = protected_keychain("hunter2"); let prompter = MockPrompter::new(vec![pw("wrong"), pw("hunter2")]); - let result = resolve_with(&prompter, &verifier, fingerprint, true).await.unwrap(); + let result = resolve_with(&prompter, &verifier, fingerprint, true) + .await + .unwrap(); assert_eq!(result, Some("hunter2".to_string())); let seen = prompter.seen(); @@ -167,7 +187,9 @@ mod tests { let (verifier, fingerprint) = protected_keychain("hunter2"); let prompter = MockPrompter::new(vec![pw("a"), pw("b"), pw("c")]); - let error = resolve_with(&prompter, &verifier, fingerprint, true).await.unwrap_err(); + let error = resolve_with(&prompter, &verifier, fingerprint, true) + .await + .unwrap_err(); assert!(matches!(error.kind, ErrorKind::Unauthorized)); assert_eq!(prompter.seen().len(), MAX_ATTEMPTS as usize); @@ -178,7 +200,9 @@ mod tests { let (verifier, fingerprint) = protected_keychain("hunter2"); let prompter = MockPrompter::new(vec![PasswordOutcome::Cancelled]); - let error = resolve_with(&prompter, &verifier, fingerprint, true).await.unwrap_err(); + let error = resolve_with(&prompter, &verifier, fingerprint, true) + .await + .unwrap_err(); assert!(matches!(error.kind, ErrorKind::Unauthorized)); assert_eq!(error.reason, CANCELLED_REASON); @@ -190,7 +214,9 @@ mod tests { let (verifier, fingerprint) = protected_keychain("hunter2"); let prompter = MockPrompter::new(vec![PasswordOutcome::NoAuthNeeded]); - let result = resolve_with(&prompter, &verifier, fingerprint, false).await.unwrap(); + let result = resolve_with(&prompter, &verifier, fingerprint, false) + .await + .unwrap(); assert_eq!(result, None); assert_eq!(prompter.seen().len(), 1); From f05044c0389415c9557b195415cb05cdf0c080c2 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 20:14:07 -0500 Subject: [PATCH 21/36] docs(password-gate): add design spec and implementation plan Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-21-password-gate.md | 1905 +++++++++++++++++ .../specs/2026-08-21-password-gate-design.md | 226 ++ 2 files changed, 2131 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-21-password-gate.md create mode 100644 docs/superpowers/specs/2026-08-21-password-gate-design.md diff --git a/docs/superpowers/plans/2026-08-21-password-gate.md b/docs/superpowers/plans/2026-08-21-password-gate.md new file mode 100644 index 000000000..9c2ac63be --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-password-gate.md @@ -0,0 +1,1905 @@ +# Password Gate Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move the decision of _when_ a password is required out of the frontend and into the Rust +Tauri host, so callers never supply a password, and app-bridge requests can operate on +password-protected wallets with the dialog rendering only in the trusted `main` webview. + +**Architecture:** A new `password_gate` module in the Tauri host sits above `Sage` and above +`app_state.lock()`. It emits a `PasswordRequest` event to the `main` webview only, awaits a reply +delivered back through a `submit_password_response` command, verifies the answer against the keychain, +and hands the verified password down through the existing `password: Option` API field. The +`sage` and `sage-rpc` crates are not touched — `sage-rpc` is headless and its clients legitimately +supply passwords in the request body. + +**Tech Stack:** Rust, Tauri 2, tauri-specta, tokio (`oneshot`, `Mutex`), proc-macro (`sage-api-macro`), +React 18 + TypeScript, pnpm, Vite. + +**Spec:** `docs/superpowers/specs/2026-08-21-password-gate-design.md` + +## Global Constraints + +- **Branch:** `password-gate`, off `password`. Do not merge to `main`. +- **Commit per task on `password-gate` only.** The user has authorised commits on this branch for the + duration of this plan. Never push, never merge, never commit on `main` or `password`. The standing + "no auto-commit" preference still applies everywhere outside this branch. +- **Every `cargo` command requires `export SDKROOT="$(xcrun --show-sdk-path)"` first**, or the build + fails with `stdlib.h not found`. +- **Never run `pnpm run extract`** (lingui). `.po` churn is batched into a separate pre-release pass. +- **Do not modify `crates/sage/**`or`crates/sage-rpc/**`.** Not `Sage::sign`, not any endpoint in + `crates/sage/src/endpoints/`. If a task seems to need this, stop and report. +- **Do not remove `password: Option` from any `sage-api` request type.** It is the RPC contract. +- **`ChangePassword` is out of scope.** Its `old_password` / `new_password` stay caller-supplied. +- **Regenerate bindings with `pnpm run generate:bindings`**, never by hand-editing `src/bindings.ts`. +- The exact set of 32 password-gated endpoints is listed in Task 3. It is 1:1 with the `sage-api` + request types carrying a `password` field. + +--- + +## File Structure + +**New files** + +| File | Responsibility | +| ------------------------------------------- | --------------------------------------------------------------------- | +| `crates/sage-password-gate/Cargo.toml` | New crate manifest | +| `crates/sage-password-gate/src/lib.rs` | Public surface: `PasswordGateState`, `resolve()`, `Error`, re-exports | +| `crates/sage-password-gate/src/types.rs` | `PasswordRequest` event, `PasswordOutcome`, `PasswordAttemptError` | +| `crates/sage-password-gate/src/prompter.rs` | `Prompter` trait + `TauriPrompter` (emit to `main` webview) | +| `crates/sage-password-gate/src/resolve.rs` | Verify-and-retry loop; unit-tested against a mock `Prompter` | + +The gate lives in its own crate rather than in `src-tauri/src/` because **both** `sage-tauri` and +`sage-apps` must call it — `sage-apps` cannot depend on the `sage-tauri` binary crate. Putting it here +from the start avoids extracting it later. It depends only on `sage`, `sage-api`, `sage-keychain`, and +`tauri`; nothing depends on it except the two hosts. + +Splitting the gate across four small files keeps the testable core (`resolve.rs`) free of any Tauri +`AppHandle` dependency. `resolve.rs` talks only to the `Prompter` trait, so its tests need no running +Tauri app. This is the single most important structural decision in the plan — do not collapse these +into one file. + +**Modified files** + +| File | Change | +| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `crates/sage-api/macro/src/lib.rs` | Add `maybe_unlock` token expansion | +| `crates/sage-api/password-gated.json` | New: the 32 gated endpoint names | +| `crates/sage-api/src/lib.rs` | Drift test module | +| `src-tauri/src/commands.rs` | Repeat block gains the gate; new `submit_password_response` | +| `src-tauri/src/lib.rs` | Register state, command, event | +| `crates/sage-apps/src/bridge/bridge_request.rs` | Gate call in `process_after_approval` | +| `crates/sage-apps/src/bridge/types.rs` | `BridgeTools` carries the resolved password | +| `crates/sage-apps/src/bridge/methods/user/wallet/{send_xch,sign_message,sign_coin_spends}.rs` | Use the resolved password; force approval when protected | +| `src/contexts/PasswordContext.tsx` | Invert to a responder; add `requireLocalAuth` | +| `src/hooks/usePassword.ts` | Export the new shape | +| `src/pages/Settings.tsx` | Use `requireLocalAuth` | +| `src/components/{WalletCard,ConfirmationDialog}.tsx`, `src/hooks/useOfferProcessor.ts`, `src/pages/Offer.tsx` | Drop password plumbing | +| `src/contexts/WalletConnectContext.tsx`, `src/walletconnect/{handler,commands/chip0002,commands/high-level,commands/offers}.ts` | Drop password plumbing | +| `src/bindings.ts` | Regenerated | + +--- + +## Task 1: Gate types and the testable resolve loop + +This task builds the entire decision core with **no Tauri dependency**, so it is fully unit-testable. + +**Files:** + +- Create: `crates/sage-password-gate/Cargo.toml` +- Create: `crates/sage-password-gate/src/lib.rs` +- Create: `crates/sage-password-gate/src/types.rs` +- Create: `crates/sage-password-gate/src/prompter.rs` +- Create: `crates/sage-password-gate/src/resolve.rs` (tests live in a `#[cfg(test)] mod tests` here) +- Modify: `Cargo.toml` (workspace members + dependency entry) + +**Interfaces:** + +- Consumes: `sage_keychain::{Keychain, KeychainError}`, `sage_api::ErrorKind`, `sage::Sage` +- Produces: + - `pub enum PasswordOutcome { Password { password: String }, NoAuthNeeded, Cancelled }` (serde `tag = "kind"`, snake_case) + - `pub struct PasswordAttemptError { pub attempts_remaining: u8 }` + - `pub struct PasswordRequest { pub request_id: String, pub fingerprint: u32, pub requires_password: bool, pub attempt: u8, pub error: Option }` + - `#[async_trait] pub trait Prompter { async fn prompt(&self, request: PasswordRequest) -> Result; }` + - `#[async_trait] pub trait PasswordVerifier { async fn verify(&self, fingerprint: u32, password: &str) -> Result; }` — `Ok(false)` means wrong password; `Err` means a real failure + - `pub struct Error { pub kind: ErrorKind, pub reason: String }` and `pub type Result = std::result::Result` — the crate's own error, structurally identical to `src-tauri`'s so `From` is trivial + - `pub const MAX_ATTEMPTS: u8 = 3;` + - `pub const CANCELLED_REASON: &str = "Password entry cancelled";` + - `pub async fn resolve_with(prompter: &dyn Prompter, verifier: &dyn PasswordVerifier, fingerprint: u32, requires_password: bool) -> Result>` + +- [ ] **Step 1: Create the crate** + +Create `crates/sage-password-gate/Cargo.toml`: + +```toml +[package] +name = "sage-password-gate" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +sage = { workspace = true } +sage-api = { workspace = true, features = ["tauri"] } +sage-keychain = { workspace = true } +async-trait = "0.1.89" +serde = { workspace = true, features = ["derive"] } +specta = { workspace = true } +tauri = { workspace = true } +tauri-specta = { workspace = true } +tokio = { workspace = true, features = ["sync"] } +uuid = { version = "1.19.0", features = ["v4"] } + +[dev-dependencies] +bip39 = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync"] } +``` + +`async-trait` and `uuid` are **not** in the root `[workspace.dependencies]` table — `crates/sage-apps` +pins them locally, and the versions above match it exactly. Do not change them to `workspace = true`. +Copy the `version`/`edition`/`license` spellings and the `workspace = true` dependency forms from +`crates/sage-apps/Cargo.toml`. + +Register the crate in the root `Cargo.toml`: add `"crates/sage-password-gate"` to `[workspace] members` +(if members are globbed as `crates/*`, no change is needed), and add +`sage-password-gate = { path = "./crates/sage-password-gate" }` to `[workspace.dependencies]`. + +Create `crates/sage-password-gate/src/lib.rs`: + +```rust +mod prompter; +mod resolve; +mod types; + +use sage_api::ErrorKind; + +pub use prompter::{PasswordVerifier, Prompter}; +pub use resolve::{CANCELLED_REASON, MAX_ATTEMPTS, resolve_with}; +pub use types::{PasswordAttemptError, PasswordOutcome, PasswordRequest}; + +/// This crate's error. Structurally identical to `sage-tauri`'s `Error`, so the +/// host converts with a trivial `From` impl. +#[derive(Debug, Clone)] +pub struct Error { + pub kind: ErrorKind, + pub reason: String, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.reason) + } +} + +impl std::error::Error for Error {} + +pub type Result = std::result::Result; +``` + +Add to `src-tauri/src/error.rs` so host commands can use `?` on gate calls: + +```rust +impl From for Error { + fn from(error: sage_password_gate::Error) -> Self { + Self { kind: error.kind, reason: error.reason } + } +} +``` + +and add `sage-password-gate = { workspace = true }` to `src-tauri/Cargo.toml` dependencies. In +`src-tauri/src/lib.rs`, alias it for the shorter call sites used later: +`use sage_password_gate as password_gate;` + +- [ ] **Step 2: Write the types** + +Create `crates/sage-password-gate/src/types.rs`: + +```rust +use serde::{Deserialize, Serialize}; +use specta::Type; + +/// How the frontend answered a password request. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PasswordOutcome { + /// The user supplied a password. + Password { password: String }, + /// No authentication was required, or a biometric gate already passed. + NoAuthNeeded, + /// The user dismissed the prompt. + Cancelled, +} + +/// Attached to a re-prompt after an incorrect password. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PasswordAttemptError { + pub attempts_remaining: u8, +} + +/// Emitted to the `main` webview only. Never broadcast. +#[derive(Debug, Clone, Serialize, Deserialize, Type, tauri_specta::Event)] +#[serde(rename_all = "camelCase")] +pub struct PasswordRequest { + pub request_id: String, + pub fingerprint: u32, + /// Advisory: the wallet's stored `password_protected` flag. The frontend + /// still decides between password dialog, biometric gate, and no auth, + /// because Rust does not know whether biometrics are enabled. + pub requires_password: bool, + /// 1-based. Increments on each incorrect-password re-prompt. + pub attempt: u8, + pub error: Option, +} +``` + +- [ ] **Step 3: Write the Prompter trait** + +Create `crates/sage-password-gate/src/prompter.rs`: + +```rust +use async_trait::async_trait; + +use super::types::{PasswordOutcome, PasswordRequest}; +use crate::Result; + +/// Abstracts the round-trip to the frontend so the resolve loop can be +/// unit-tested without a running Tauri app. +#[async_trait] +pub trait Prompter: Send + Sync { + async fn prompt(&self, request: PasswordRequest) -> Result; +} + +/// Checks a candidate password against the keychain. +/// +/// This is a trait rather than a borrowed `&Keychain` on purpose. `Keychain` +/// is not `Clone` and owns a `ChaCha20Rng`; cloning it to escape the app lock +/// would duplicate an RNG stream, which is a nonce-reuse hazard the moment a +/// clone ever encrypts. Instead the production implementation takes the Sage +/// lock briefly for each attempt and drops it before the next prompt, so no +/// lock is ever held across an await. +#[async_trait] +pub trait PasswordVerifier: Send + Sync { + /// `Ok(true)` = correct, `Ok(false)` = wrong password, + /// `Err` = a genuine failure (not a wrong password). + async fn verify(&self, fingerprint: u32, password: &str) -> Result; +} +``` + +`async-trait` was declared in the crate manifest in Step 1 with an explicit version, matching +`crates/sage-apps`. It is not a workspace dependency. + +- [ ] **Step 4: Write the failing tests** + +Create `crates/sage-password-gate/src/resolve.rs` with **only** this test module for now (the file will +not compile yet — that is expected and is the point of the next step): + +```rust +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use async_trait::async_trait; + use sage_api::ErrorKind; + use sage_keychain::{Keychain, KeychainError}; + + use super::*; + use crate::types::{PasswordOutcome, PasswordRequest}; + use crate::{Error, PasswordVerifier, Prompter, Result}; + + /// Replays a scripted sequence of outcomes and records what it was asked. + struct MockPrompter { + scripted: Mutex>, + seen: Mutex>, + } + + impl MockPrompter { + fn new(scripted: Vec) -> Self { + Self { + scripted: Mutex::new(scripted.into_iter().rev().collect()), + seen: Mutex::new(Vec::new()), + } + } + + fn seen(&self) -> Vec { + self.seen.lock().unwrap().clone() + } + } + + #[async_trait] + impl Prompter for MockPrompter { + async fn prompt(&self, request: PasswordRequest) -> Result { + self.seen.lock().unwrap().push(request); + Ok(self.scripted.lock().unwrap().pop().expect("prompted more times than scripted")) + } + } + + /// A verifier backed by a real keychain holding one mnemonic key + /// encrypted with `password`. + struct KeychainVerifier { + keychain: Keychain, + } + + #[async_trait] + impl PasswordVerifier for KeychainVerifier { + async fn verify(&self, fingerprint: u32, password: &str) -> Result { + match self.keychain.extract_secrets(fingerprint, password.as_bytes()) { + Ok(_) => Ok(true), + Err(KeychainError::Decrypt) => Ok(false), + Err(err) => Err(Error { kind: ErrorKind::Internal, reason: err.to_string() }), + } + } + } + + fn protected_keychain(password: &str) -> (KeychainVerifier, u32) { + let mut keychain = Keychain::default(); + let mnemonic = bip39::Mnemonic::from_entropy(&[7u8; 32]).unwrap(); + let fingerprint = keychain + .add_mnemonic(&mnemonic, password.as_bytes()) + .expect("failed to add mnemonic"); + (KeychainVerifier { keychain }, fingerprint) + } + + fn pw(s: &str) -> PasswordOutcome { + PasswordOutcome::Password { password: s.to_string() } + } + + #[tokio::test] + async fn correct_password_resolves_on_first_attempt() { + let (verifier, fingerprint) = protected_keychain("hunter2"); + let prompter = MockPrompter::new(vec![pw("hunter2")]); + + let result = resolve_with(&prompter, &verifier, fingerprint, true).await.unwrap(); + + assert_eq!(result, Some("hunter2".to_string())); + let seen = prompter.seen(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].attempt, 1); + assert!(seen[0].requires_password); + assert!(seen[0].error.is_none()); + } + + #[tokio::test] + async fn wrong_then_right_reprompts_with_attempts_remaining() { + let (verifier, fingerprint) = protected_keychain("hunter2"); + let prompter = MockPrompter::new(vec![pw("wrong"), pw("hunter2")]); + + let result = resolve_with(&prompter, &verifier, fingerprint, true).await.unwrap(); + + assert_eq!(result, Some("hunter2".to_string())); + let seen = prompter.seen(); + assert_eq!(seen.len(), 2); + assert_eq!(seen[1].attempt, 2); + assert_eq!(seen[1].error.as_ref().unwrap().attempts_remaining, 2); + } + + #[tokio::test] + async fn three_wrong_attempts_fails_unauthorized() { + let (verifier, fingerprint) = protected_keychain("hunter2"); + let prompter = MockPrompter::new(vec![pw("a"), pw("b"), pw("c")]); + + let error = resolve_with(&prompter, &verifier, fingerprint, true).await.unwrap_err(); + + assert!(matches!(error.kind, ErrorKind::Unauthorized)); + assert_eq!(prompter.seen().len(), MAX_ATTEMPTS as usize); + } + + #[tokio::test] + async fn cancellation_fails_immediately_without_reprompting() { + let (verifier, fingerprint) = protected_keychain("hunter2"); + let prompter = MockPrompter::new(vec![PasswordOutcome::Cancelled]); + + let error = resolve_with(&prompter, &verifier, fingerprint, true).await.unwrap_err(); + + assert!(matches!(error.kind, ErrorKind::Unauthorized)); + assert_eq!(error.reason, CANCELLED_REASON); + assert_eq!(prompter.seen().len(), 1); + } + + #[tokio::test] + async fn no_auth_needed_resolves_to_none() { + let (verifier, fingerprint) = protected_keychain("hunter2"); + let prompter = MockPrompter::new(vec![PasswordOutcome::NoAuthNeeded]); + + let result = resolve_with(&prompter, &verifier, fingerprint, false).await.unwrap(); + + assert_eq!(result, None); + assert_eq!(prompter.seen().len(), 1); + assert!(!prompter.seen()[0].requires_password); + } +} +``` + +- [ ] **Step 5: Run the tests to verify they fail** + +```bash +export SDKROOT="$(xcrun --show-sdk-path)" +cargo test -p sage-password-gate +``` + +Expected: FAIL to compile, with errors like `cannot find function 'resolve_with' in this scope` and +`cannot find value 'CANCELLED_REASON' in this scope`. + +- [ ] **Step 6: Write the resolve loop** + +Prepend to `crates/sage-password-gate/src/resolve.rs`, above the test module: + +```rust +use sage_api::ErrorKind; + +use super::types::{PasswordAttemptError, PasswordOutcome, PasswordRequest}; +use crate::{Error, Result}; + +/// Maximum password entry attempts before the operation is refused. +pub const MAX_ATTEMPTS: u8 = 3; + +/// Reason string used when the user dismisses the prompt, so the frontend can +/// distinguish a deliberate cancel from a genuine auth failure and stay silent. +pub const CANCELLED_REASON: &str = "Password entry cancelled"; + +fn unauthorized(reason: &str) -> Error { + Error { kind: ErrorKind::Unauthorized, reason: reason.to_string() } +} + +/// Prompts the frontend for a password and verifies it against the keychain, +/// retrying up to `MAX_ATTEMPTS` times on an incorrect password. +/// +/// Returns `Ok(None)` when no authentication was required. The caller places +/// the returned value directly into the request's `password` field. +/// +/// This runs *before* `app_state.lock()` is taken. Verifying here keeps a wrong +/// password cheap (one keychain decrypt, no partially built transaction) and +/// avoids awaiting the frontend while holding the app lock. +pub async fn resolve_with( + prompter: &dyn Prompter, + verifier: &dyn PasswordVerifier, + fingerprint: u32, + requires_password: bool, +) -> Result> { + let mut error: Option = None; + + for attempt in 1..=MAX_ATTEMPTS { + let request = PasswordRequest { + request_id: uuid::Uuid::new_v4().to_string(), + fingerprint, + requires_password, + attempt, + error: error.take(), + }; + + match prompter.prompt(request).await? { + PasswordOutcome::NoAuthNeeded => return Ok(None), + PasswordOutcome::Cancelled => return Err(unauthorized(CANCELLED_REASON)), + PasswordOutcome::Password { password } => { + if verifier.verify(fingerprint, &password).await? { + return Ok(Some(password)); + } + error = Some(PasswordAttemptError { + attempts_remaining: MAX_ATTEMPTS - attempt, + }); + } + } + } + + Err(unauthorized("Too many incorrect password attempts")) +} +``` + +Both `uuid` and `bip39` were declared in the crate manifest in Step 1 — `uuid` with an explicit +version (it is not a workspace dependency), `bip39` as `workspace = true`. + +- [ ] **Step 7: Run the tests to verify they pass** + +```bash +export SDKROOT="$(xcrun --show-sdk-path)" +cargo test -p sage-password-gate +``` + +Expected: PASS, 5 tests. + +- [ ] **Step 8: Commit (only with user approval)** + +```bash +git add crates/sage-password-gate Cargo.toml src-tauri/Cargo.toml src-tauri/src/error.rs src-tauri/src/lib.rs +git commit -m "feat(password-gate): add testable password resolve loop" +``` + +--- + +## Task 2: Tauri transport — state, event emission, response command + +Wires the abstract `Prompter` to the real `main` webview. + +**Files:** + +- Modify: `crates/sage-password-gate/src/prompter.rs` (add `TauriPrompter`) +- Modify: `crates/sage-password-gate/src/lib.rs` (add `PasswordGateState`, `resolve`) +- Modify: `src-tauri/src/commands.rs` (add `submit_password_response`) +- Modify: `src-tauri/src/lib.rs` (register state, command, event) + +**Interfaces:** + +- Consumes: `Prompter`, `PasswordOutcome`, `PasswordRequest`, `resolve_with`, `MAX_ATTEMPTS` from Task 1 +- Produces: + - `pub struct PasswordGateState { pending: Mutex>> }` with `Default` + - `pub async fn resolve(app_handle: &AppHandle, state: &AppState, gate: &PasswordGateState) -> Result>` + - `pub fn submit_password_response(gate: State<'_, PasswordGateState>, request_id: String, outcome: PasswordOutcome) -> Result<()>` + - Constant `SAGE_WEBVIEW_LABEL: &str = "main"` + +- [ ] **Step 1: Write the failing test for the pending-map handoff** + +Append to `crates/sage-password-gate/src/lib.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn resolving_a_pending_request_delivers_the_outcome() { + let gate = PasswordGateState::default(); + let (tx, rx) = tokio::sync::oneshot::channel(); + gate.register("req-1".to_string(), tx).await; + + gate.deliver("req-1", PasswordOutcome::NoAuthNeeded) + .expect("delivery should succeed"); + + assert!(matches!(rx.await.unwrap(), PasswordOutcome::NoAuthNeeded)); + } + + #[tokio::test] + async fn delivering_an_unknown_request_id_is_an_error() { + let gate = PasswordGateState::default(); + + let error = gate + .deliver("nope", PasswordOutcome::Cancelled) + .expect_err("unknown id must error"); + + assert!(error.reason.contains("nope")); + } + + #[tokio::test] + async fn a_request_id_can_only_be_delivered_once() { + let gate = PasswordGateState::default(); + let (tx, _rx) = tokio::sync::oneshot::channel(); + gate.register("req-2".to_string(), tx).await; + + gate.deliver("req-2", PasswordOutcome::Cancelled).unwrap(); + + assert!(gate.deliver("req-2", PasswordOutcome::Cancelled).is_err()); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +export SDKROOT="$(xcrun --show-sdk-path)" +cargo test -p sage-password-gate +``` + +Expected: FAIL to compile — `cannot find type 'PasswordGateState'`, `no method named 'register'`. + +- [ ] **Step 3: Implement the state** + +Add to `crates/sage-password-gate/src/lib.rs`, above the test module: + +```rust +use std::collections::HashMap; + +use tokio::sync::{Mutex, oneshot}; + +/// Tracks in-flight password requests awaiting a frontend reply. +#[derive(Default)] +pub struct PasswordGateState { + pending: Mutex>>, +} + +impl PasswordGateState { + pub(crate) async fn register(&self, request_id: String, tx: oneshot::Sender) { + self.pending.lock().await.insert(request_id, tx); + } + + pub(crate) async fn cancel(&self, request_id: &str) { + self.pending.lock().await.remove(request_id); + } + + /// Hands an outcome to the waiting resolve loop. Consumes the entry, so a + /// given request id can only be answered once. + pub(crate) fn deliver(&self, request_id: &str, outcome: PasswordOutcome) -> Result<()> { + let sender = self + .pending + .blocking_lock() + .remove(request_id) + .ok_or_else(|| Error { + kind: ErrorKind::NotFound, + reason: format!("no pending password request with id {request_id}"), + })?; + + sender.send(outcome).map_err(|_| Error { + kind: ErrorKind::Internal, + reason: "password request was abandoned".to_string(), + }) + } +} +``` + +If `blocking_lock` panics in an async context during the tests, change `deliver` to `async fn` using +`self.pending.lock().await`, and make `submit_password_response` in Step 5 `async` to match. Prefer the +async form if in doubt — it is the safer choice inside Tauri's async command runtime. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +export SDKROOT="$(xcrun --show-sdk-path)" +cargo test -p sage-password-gate +``` + +Expected: PASS, 8 tests. + +- [ ] **Step 5: Implement `TauriPrompter` and the public `resolve`** + +Append to `crates/sage-password-gate/src/prompter.rs`: + +```rust +use tauri::{AppHandle, Emitter}; +use tauri_specta::Event; + +use super::{Error, PasswordGateState}; +use sage_api::ErrorKind; + +/// The Sage React webview. App runtimes are sibling webviews in the same +/// window, so emission MUST target this label — a plain `emit` would deliver +/// the request to app-land. +pub const SAGE_WEBVIEW_LABEL: &str = "main"; + +pub struct TauriPrompter<'a> { + pub app_handle: &'a AppHandle, + pub gate: &'a PasswordGateState, +} + +#[async_trait] +impl Prompter for TauriPrompter<'_> { + async fn prompt(&self, request: PasswordRequest) -> Result { + let request_id = request.request_id.clone(); + let (tx, rx) = tokio::sync::oneshot::channel(); + self.gate.register(request_id.clone(), tx).await; + + if let Err(err) = request.emit_to(self.app_handle, SAGE_WEBVIEW_LABEL) { + self.gate.cancel(&request_id).await; + return Err(Error { + kind: ErrorKind::Internal, + reason: format!("failed to emit password request: {err}"), + }); + } + + rx.await.map_err(|_| Error { + kind: ErrorKind::Internal, + reason: "password request channel closed".to_string(), + }) + } +} +``` + +If `tauri_specta::Event` does not provide `emit_to` in this version, use +`self.app_handle.emit_to(SAGE_WEBVIEW_LABEL, "password-request", &request)` instead and declare the +event name explicitly. Verify against the `SyncEvent` usage in `src-tauri/src/app_state.rs:52`. + +Append to `crates/sage-password-gate/src/lib.rs`: + +```rust +use std::sync::Arc; + +use sage::Sage; +use tauri::AppHandle; + +/// The host's shared Sage handle. Mirrors `sage_tauri::app_state::AppState`. +pub type SharedSage = Arc>; + +/// Resolves a password for the active wallet, prompting the `main` webview. +/// +/// Reads the wallet fingerprint and its stored `password_protected` flag, then +/// releases the app lock *before* the frontend round-trip. Uses the cheap +/// config flag rather than `Keychain::is_password_protected`, which runs an +/// Argon2 decrypt probe on every call. +pub async fn resolve( + app_handle: &AppHandle, + state: &SharedSage, + gate: &PasswordGateState, +) -> Result> { + let (fingerprint, requires_password) = { + let sage = state.lock().await; + let fingerprint = sage + .wallet() + .map_err(|err| Error { kind: err.kind(), reason: err.to_string() })? + .fingerprint; + let requires_password = sage + .wallet_config + .wallets + .iter() + .find(|wallet| wallet.fingerprint == fingerprint) + .is_some_and(|wallet| wallet.password_protected); + (fingerprint, requires_password) + }; + + let prompter = prompter::TauriPrompter { app_handle, gate }; + let verifier = SageVerifier { state }; + resolve_with(&prompter, &verifier, fingerprint, requires_password).await +} + +/// Verifies a candidate password by taking the Sage lock briefly, then +/// releasing it before the next prompt. Never holds the lock across an await. +struct SageVerifier<'a> { + state: &'a SharedSage, +} + +#[async_trait::async_trait] +impl PasswordVerifier for SageVerifier<'_> { + async fn verify(&self, fingerprint: u32, password: &str) -> Result { + let sage = self.state.lock().await; + match sage.keychain.extract_secrets(fingerprint, password.as_bytes()) { + Ok(_) => Ok(true), + Err(sage_keychain::KeychainError::Decrypt) => Ok(false), + Err(err) => Err(Error { + kind: sage_api::ErrorKind::Internal, + reason: err.to_string(), + }), + } + } +} +``` + +`Keychain` is deliberately **not** cloned and `crates/sage-keychain/` is **not** modified. Both lock +scopes above are tight and neither spans an await, so the frontend round-trip always happens with the +Sage lock released. Add `sage-keychain = { workspace = true }` to the gate crate's manifest if Step 1 +omitted it. + +- [ ] **Step 6: Add the response command** + +Add to `src-tauri/src/commands.rs`: + +```rust +use sage_password_gate::{PasswordGateState, PasswordOutcome}; + +#[command] +#[specta] +pub async fn submit_password_response( + gate: State<'_, PasswordGateState>, + request_id: String, + outcome: PasswordOutcome, +) -> Result<()> { + Ok(gate.deliver(&request_id, outcome)?) +} +``` + +(Make it `gate.deliver(...).await` if Step 3 chose the async form.) + +- [ ] **Step 7: Register state, command, and event** + +In `src-tauri/src/lib.rs`: + +1. Add `commands::submit_password_response,` to the `sage_commands!` list in `collect_commands![...]`. +2. Change **both** `.events(collect_events![SyncEvent])` occurrences (the `specta_builder` one near + line 187 and the `#[cfg(mobile)]` one near line 215) to + `.events(collect_events![SyncEvent, password_gate::PasswordRequest])`. +3. Add `.manage(password_gate::PasswordGateState::default())` to the `tauri::Builder` chain, alongside + the other `.manage(...)` calls. +4. Add `use crate::password_gate;` if not already in scope. + +- [ ] **Step 8: Build and regenerate bindings** + +```bash +export SDKROOT="$(xcrun --show-sdk-path)" +cargo build -p sage-password-gate -p sage-tauri +pnpm run generate:bindings +git diff --stat src/bindings.ts +``` + +Expected: builds clean; `src/bindings.ts` gains `submitPasswordResponse`, `PasswordRequest`, +`PasswordOutcome`, `PasswordAttemptError`, and a `passwordRequest` entry in `events`. + +- [ ] **Step 9: Commit (only with user approval)** + +```bash +git add crates/sage-password-gate crates/sage-keychain/src src-tauri/src src/bindings.ts +git commit -m "feat(password-gate): wire main-window transport for password requests" +``` + +--- + +## Task 3: Macro support and the drift-proof gated endpoint set + +**Files:** + +- Create: `crates/sage-api/password-gated.json` +- Modify: `crates/sage-api/macro/src/lib.rs` +- Modify: `crates/sage-api/src/lib.rs` (drift test) + +**Interfaces:** + +- Produces: a `maybe_unlock` token usable inside `impl_endpoints_tauri!`'s `repeat` block, expanding to + the gate call for gated endpoints and to nothing otherwise. + +- [ ] **Step 1: Create the gated endpoint set** + +Create `crates/sage-api/password-gated.json`. These 32 names are exactly the endpoints whose request +type carries a `password: Option` field: + +```json +[ + "add_nft_uri", + "assign_nfts_to_did", + "auto_combine_cat", + "auto_combine_xch", + "bulk_mint_nfts", + "bulk_send_cat", + "bulk_send_xch", + "cancel_offer", + "cancel_offers", + "combine", + "create_did", + "create_transaction", + "delete_key", + "exercise_options", + "finalize_clawback", + "get_secret_key", + "increase_derivation_index", + "issue_cat", + "make_offer", + "mint_option", + "multi_send", + "normalize_dids", + "send_cat", + "send_xch", + "sign_coin_spends", + "sign_message_by_address", + "sign_message_with_public_key", + "split", + "take_offer", + "transfer_dids", + "transfer_nfts", + "transfer_options" +] +``` + +- [ ] **Step 2: Write the failing drift test** + +Add to `crates/sage-api/src/lib.rs`: + +```rust +#[cfg(test)] +mod password_gate_drift { + use std::collections::BTreeSet; + + /// The gated set must match, exactly, the request types carrying a + /// `password` field. If this fails you either added a signing endpoint + /// without gating it (a security hole) or gated one that takes no + /// password (a spurious prompt). + #[test] + fn gated_set_matches_request_types_with_password_field() { + let gated: BTreeSet = + serde_json::from_str(include_str!("../password-gated.json")).unwrap(); + + let mut discovered = BTreeSet::new(); + for source in [ + include_str!("requests/action_system.rs"), + include_str!("requests/actions.rs"), + include_str!("requests/keys.rs"), + include_str!("requests/offers.rs"), + include_str!("requests/transactions.rs"), + include_str!("requests/wallet_connect.rs"), + ] { + discovered.extend(structs_with_password_field(source)); + } + + assert_eq!( + gated, discovered, + "password-gated.json is out of sync with the request types.\n\ + Only in password-gated.json: {:?}\n\ + Only in request types: {:?}", + gated.difference(&discovered).collect::>(), + discovered.difference(&gated).collect::>(), + ); + } + + /// Scans Rust source for `pub struct Name {` blocks containing a + /// `pub password: Option` field, returning snake_case names. + fn structs_with_password_field(source: &str) -> BTreeSet { + let mut found = BTreeSet::new(); + let lines: Vec<&str> = source.lines().collect(); + let mut index = 0; + + while index < lines.len() { + let line = lines[index].trim_end(); + let Some(rest) = line.strip_prefix("pub struct ") else { + index += 1; + continue; + }; + let Some(name) = rest.strip_suffix(" {") else { + index += 1; + continue; + }; + + let mut cursor = index + 1; + let mut has_password = false; + while cursor < lines.len() && lines[cursor] != "}" { + if lines[cursor].trim() == "pub password: Option," { + has_password = true; + } + cursor += 1; + } + + if has_password { + found.insert(to_snake_case(name)); + } + index = cursor + 1; + } + + found + } + + fn to_snake_case(name: &str) -> String { + let mut out = String::new(); + for (position, character) in name.char_indices() { + if character.is_uppercase() && position != 0 { + out.push('_'); + } + out.extend(character.to_lowercase()); + } + out + } +} +``` + +Note the empty-struct guard: `pub struct DeleteDatabaseResponse {}` is written on one line and so is +correctly skipped by the `" {"` suffix check. Do not loosen that check. + +- [ ] **Step 3: Run the test to verify it fails** + +```bash +export SDKROOT="$(xcrun --show-sdk-path)" +cargo test -p sage-api password_gate_drift +``` + +Expected: FAIL — `couldn't read ../password-gated.json` if Step 1 was skipped, otherwise it should +already PASS. If it fails with a set mismatch, **the JSON in Step 1 is authoritative only if the code +agrees** — re-derive the list from the actual source and fix the JSON, do not weaken the test. + +- [ ] **Step 4: Add `maybe_unlock` to the macro** + +In `crates/sage-api/macro/src/lib.rs`, inside `generate()`, load the gated set next to the endpoints: + +```rust +let password_gated: std::collections::BTreeSet = + serde_json::from_str(include_str!("../../password-gated.json")) + .expect("Invalid password-gated endpoint file"); +``` + +Thread `&password_gated` through `convert()` as an extra parameter (alongside `endpoints`), then add a +branch in the `TokenTree::Ident` arm, next to the existing `maybe_async` / `maybe_await` branches: + +```rust +} else if ident == "maybe_unlock" { + if password_gated.contains(endpoint) { + output.extend(quote!( + req.password = sage_password_gate::resolve(&app_handle, state.inner(), gate.inner()).await?; + )); + } +} +``` + +The `maybe_unlock` identifier is snake_case, so this branch **must** appear before the +`ident.is_case(Case::Snake)` branch, exactly as `maybe_async` and `maybe_await` do. Otherwise it will +be rewritten into an endpoint name instead of expanded. + +- [ ] **Step 5: Verify the macro compiles** + +```bash +export SDKROOT="$(xcrun --show-sdk-path)" +cargo build -p sage-api-macro +cargo test -p sage-api password_gate_drift +``` + +Expected: both PASS. + +- [ ] **Step 6: Commit (only with user approval)** + +```bash +git add crates/sage-api/password-gated.json crates/sage-api/macro/src/lib.rs crates/sage-api/src/lib.rs +git commit -m "feat(password-gate): add maybe_unlock macro token and drift test" +``` + +--- + +## Task 4: Gate every endpoint command + +**Files:** + +- Modify: `src-tauri/src/commands.rs:67-75` + +**Interfaces:** + +- Consumes: `password_gate::resolve` (Task 2), `maybe_unlock` (Task 3) +- Produces: all 32 gated Tauri commands now resolve their own password + +- [ ] **Step 1: Update the repeat block** + +Replace the `impl_endpoints_tauri!` block at `src-tauri/src/commands.rs:67-75` with: + +```rust +impl_endpoints_tauri! { + (repeat + #[command] + #[specta] + pub async fn endpoint( + app_handle: AppHandle, + state: State<'_, AppState>, + gate: State<'_, PasswordGateState>, + mut req: Endpoint, + ) -> Result { + maybe_unlock; + Ok(state.lock().await.endpoint(req) maybe_await?) + } + ) +} +``` + +Two consequences to expect from the compiler: + +- Ungated endpoints now take `app_handle`, `gate`, and `mut req` without using them. Silence this by + prefixing with underscores is **not** possible inside the repeat block, so instead add + `#[allow(unused_variables, unused_mut)]` immediately above `pub async fn endpoint`. +- Adding parameters changes the generated command signatures. Tauri injects `AppHandle` and `State` + automatically, so the **TypeScript call signatures are unchanged** — `req` remains the only argument. + Confirm this in the bindings diff in Step 3. + +- [ ] **Step 2: Build** + +```bash +export SDKROOT="$(xcrun --show-sdk-path)" +cargo build -p sage-password-gate -p sage-tauri +``` + +Expected: clean build. If a specific endpoint fails because its request type has no `password` field +but appears in `password-gated.json`, the drift test in Task 3 was wrong — fix the JSON, not this block. + +- [ ] **Step 3: Regenerate bindings and confirm TS signatures did not change** + +```bash +pnpm run generate:bindings +git diff src/bindings.ts | grep -E '^\-.*sendXch|^\+.*sendXch' +``` + +Expected: no change to `sendXch`'s TypeScript signature. If the signature gained parameters, the +`AppHandle`/`State` injection is not being recognized — stop and report. + +- [ ] **Step 4: Confirm the RPC path is untouched** + +```bash +export SDKROOT="$(xcrun --show-sdk-path)" +cargo test -p sage-rpc +``` + +Expected: PASS, unchanged. This is the regression canary for the headless path — if these fail, the +gate has leaked into the core. + +- [ ] **Step 5: Commit (only with user approval)** + +```bash +git add src-tauri/src/commands.rs src/bindings.ts +git commit -m "feat(password-gate): resolve passwords in all gated endpoint commands" +``` + +--- + +## Task 5: Bridge — gate app requests after approval + +**Files:** + +- Modify: `crates/sage-apps/src/bridge/bridge_request.rs:83-135` (`process_after_approval`) +- Modify: `crates/sage-apps/src/bridge/types.rs` (`BridgeTools`) +- Modify: `crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs` +- Modify: `crates/sage-apps/src/bridge/methods/user/wallet/sign_message.rs` +- Modify: `crates/sage-apps/src/bridge/methods/user/wallet/sign_coin_spends.rs` +- Modify: `crates/sage-apps/src/runtime/manager.rs` (expose `hide_runtime_inner` to the bridge) + +**Interfaces:** + +- Consumes: `password_gate::resolve` (Task 2) +- Produces: `BridgeTools` gains `pub password: Option`, defaulted to `None` on every existing + construction site. + +- [ ] **Step 1: Add the field to `BridgeTools`** + +In `crates/sage-apps/src/bridge/types.rs`, add to the `BridgeTools` struct: + +```rust +/// Password resolved by the main-window gate, for methods that sign. +/// `None` when the wallet is unprotected or the method does not sign. +pub password: Option, +``` + +Then fix every construction of `BridgeTools` in `bridge_request.rs` (there are three: in +`process_shared`'s `prepare_approval` call, in `process_shared`'s non-approval `execute_bridge_request` +call, and in `execute_bridge_request` itself) to pass `password: None` for now. The build must be green +before moving on. + +- [ ] **Step 2: Thread the password through `execute_bridge_request`** + +Change `execute_bridge_request` in `bridge_request.rs` to take an extra parameter and use it: + +```rust +async fn execute_bridge_request( + app_handle: &AppHandle, + app_state: &State<'_, AppState>, + origin: &BridgeOrigin, + registry: BridgeRegistry, + request: &RustBridgeRequest, + password: Option, +) -> RustBridgeResponse { +``` + +and inside, set `password` on the `BridgeTools` it constructs. Update both call sites in +`process_shared` to pass `None`, and add the same parameter to `process_shared` so +`process_after_approval` can supply it. + +- [ ] **Step 3: Call the gate in `process_after_approval`** + +In `process_after_approval`, the final `else` branch currently calls `process_shared(...)`. Replace that +branch with: + +```rust +} else { + // Hide the approval app before prompting: app runtimes are sibling + // webviews inside the same window and would cover the main webview's + // password dialog. + hide_bridge_approval_runtime(app_handle, apps_state).await; + + match password_gate_resolve(app_handle, app_state).await { + Ok(password) => { + process_shared( + app_handle, + app_state, + &origin, + pending.registry_kind, + &pending.request, + true, + password, + ) + .await? + } + Err(err) => RustBridgeInvokeResult::error( + &pending.request.id, + "unauthorized", + err.to_string(), + ), + } +} +``` + +The existing `expires_at_ms` check stays exactly where it is, _above_ this branch. Because the check +already ran before the prompt, add a second check immediately after the gate returns, so a prompt that +outlives the deadline fails correctly: + +```rust +if unix_timestamp_ms() as u64 > pending.expires_at_ms { + RustBridgeInvokeResult::error( + &pending.request.id, + "approval_timeout", + "Approval expired during password entry".to_string(), + ) +} +``` + +Place this as a guard inside the `Ok(password)` arm, before `process_shared`. + +- [ ] **Step 4: Add the two helper functions** + +`hide_bridge_approval_runtime` finds the runtime whose app id is `SYSTEM_APP_BRIDGE_APPROVAL_ID` +(`crates/sage-apps/src/system_apps.rs:22`) and calls the existing `hide_runtime_inner` +(`crates/sage-apps/src/runtime/manager.rs:395`), then emits the resulting `RuntimeChangeSet`. Make +`hide_runtime_inner` and `RuntimeChangeSet` visible to the bridge module by widening their visibility +from private to `pub(crate)`. Failure to hide is logged with `tracing::warn!` and does not abort the +request — a covered dialog is a UX problem, not a security one. + +`password_gate_resolve` is a thin call into `sage-password-gate`, which `sage-apps` depends on +directly — this is exactly why the gate was made its own crate in Task 1 rather than living in +`src-tauri/src/`: + +```rust +async fn password_gate_resolve( + app_handle: &AppHandle, + app_state: &State<'_, AppState>, +) -> Result, String> { + let gate = app_handle.state::(); + sage_password_gate::resolve(app_handle, app_state.inner(), &gate) + .await + .map_err(|err| err.reason) +} +``` + +Add `sage-password-gate = { workspace = true }` to `crates/sage-apps/Cargo.toml`. `AppState` in +`sage-apps` is already `Arc>`, matching `sage_password_gate::SharedSage`; if the types do +not unify, pass `app_state.inner().clone()` and take a reference to that. + +- [ ] **Step 5: Use the password in the three wallet methods** + +In each of `send_xch.rs`, `sign_message.rs`, and `sign_coin_spends.rs`, the `From` impl +currently hardcodes `password: None`. Remove the `password` field from those `From` impls and set it in +`handle` instead. For `send_xch.rs`: + +```rust +async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, +) -> BridgeHandleResult { + let params: WalletSendXchParams = parse_required_params(self, request)?; + let mut req: SendXch = params.into(); + req.password = tools.password.clone(); + + let result = tools + .app_state + .lock() + .await + .send_xch(req) + .await + .map_err(|err| { + BridgeMethodHandleError::internal_error(format!("{} failed: {err}", self.name())) + })?; + + Ok(Box::new(result)) +} +``` + +Apply the same shape to `sign_message.rs` (`SignMessageWithPublicKey`) and `sign_coin_spends.rs` +(`SignCoinSpends`). Keep `password: None` in the `From` impls only if removing it breaks struct +initialization — in that case leave `password: None` there and let the `handle` assignment override it. + +- [ ] **Step 6: Build and test** + +```bash +export SDKROOT="$(xcrun --show-sdk-path)" +cargo build --workspace +cargo test -p sage-apps +``` + +Expected: clean build, existing `sage-apps` tests pass. + +- [ ] **Step 7: Commit (only with user approval)** + +```bash +git add crates/sage-apps crates/sage-password-gate src-tauri +git commit -m "feat(password-gate): prompt in main window for app bridge requests" +``` + +--- + +## Task 6: Force approval on protected wallets despite auto-submit + +**Files:** + +- Modify: `crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs` (`approval_request`) +- Test: `crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs` (`#[cfg(test)] mod tests`) + +**Interfaces:** + +- Consumes: `BridgeContext` (existing), the wallet `password_protected` flag +- Produces: no new public interface + +- [ ] **Step 1: Write the failing test** + +Add to `send_xch.rs`: + +```rust +#[cfg(test)] +mod tests { + /// A password-protected wallet must always produce an approval, even when + /// the app holds WalletSendXchAutoSubmit. Silent auto-submit is + /// incompatible with password protection: there would be no UI moment in + /// which to collect the password. + #[test] + fn protected_wallet_forces_approval_despite_auto_submit_grant() { + assert!(super::requires_approval( + /* auto_submit_granted */ true, + /* wallet_protected */ true, + )); + } + + #[test] + fn unprotected_wallet_still_honours_auto_submit_grant() { + assert!(!super::requires_approval(true, false)); + } + + #[test] + fn without_the_grant_approval_is_always_required() { + assert!(super::requires_approval(false, false)); + assert!(super::requires_approval(false, true)); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +export SDKROOT="$(xcrun --show-sdk-path)" +cargo test -p sage-apps send_xch +``` + +Expected: FAIL to compile — `cannot find function 'requires_approval'`. + +- [ ] **Step 3: Extract the predicate and use it** + +Add to `send_xch.rs`: + +```rust +/// Whether this request needs a user approval step. +fn requires_approval(auto_submit_granted: bool, wallet_protected: bool) -> bool { + !auto_submit_granted || wallet_protected +} +``` + +Then change `approval_request` so its early return consults the predicate. The existing body is: + +```rust +if ctx + .app + .is_capability_granted(UserBridgeCapability::WalletSendXchAutoSubmit.into()) +{ + return Ok(None); +} +``` + +Replace with a call to `requires_approval`, reading the active wallet's `password_protected` flag from +`ctx`. `approval_request` is synchronous and `BridgeContext` currently carries only `app`, so add the +flag to `BridgeContext` — populate it where `BridgeContext { app }` is constructed in +`bridge_request.rs` (three sites), reading it from `app_state` the same way `active_wallet_fingerprint` +does. Return early only when `requires_approval(..)` is `false`. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +export SDKROOT="$(xcrun --show-sdk-path)" +cargo test -p sage-apps +``` + +Expected: PASS. + +- [ ] **Step 5: Commit (only with user approval)** + +```bash +git add crates/sage-apps/src/bridge +git commit -m "feat(password-gate): force approval for protected wallets on auto-submit" +``` + +--- + +## Task 7: Invert PasswordContext into a responder + +**Files:** + +- Modify: `src/contexts/PasswordContext.tsx` +- Modify: `src/hooks/usePassword.ts` +- Modify: `src/pages/Settings.tsx:1106`, `src/pages/Settings.tsx:1123` + +**Interfaces:** + +- Consumes: `events.passwordRequest`, `commands.submitPasswordResponse` from `src/bindings.ts` +- Produces: `PasswordContextType { requireLocalAuth: () => Promise }`. + `requestPassword` is **removed** — later tasks depend on it being gone. + +- [ ] **Step 1: Rewrite the provider** + +Replace the body of `src/contexts/PasswordContext.tsx` with: + +```tsx +import { PasswordDialog } from '@/components/dialogs/PasswordDialog'; +import { useBiometric } from '@/hooks/useBiometric'; +import { commands, events, PasswordRequest } from '@/bindings'; +import { platform } from '@tauri-apps/plugin-os'; +import { + createContext, + ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +const isMobile = platform() === 'ios' || platform() === 'android'; + +// Biometric caching interval (5 minutes) +const BIOMETRIC_CACHE_MS = 5 * 60 * 1000; + +export interface PasswordContextType { + /** + * UI-only authentication gate for actions that touch no wallet secret + * (starting the RPC server, toggling run-on-startup). Returns true if the + * caller may proceed. This deliberately does NOT go through the Rust + * password gate — there is no unlock operation behind it. + */ + requireLocalAuth: () => Promise; +} + +export const PasswordContext = createContext( + undefined, +); + +export function PasswordProvider({ children }: { children: ReactNode }) { + const [pending, setPending] = useState(null); + const { enabled: biometricEnabled } = useBiometric(); + const lastBiometricPromptRef = useRef(null); + + const runBiometric = useCallback(async (): Promise => { + const now = performance.now(); + if ( + lastBiometricPromptRef.current !== null && + now - lastBiometricPromptRef.current < BIOMETRIC_CACHE_MS + ) { + return true; + } + try { + const { authenticate } = await import('@tauri-apps/plugin-biometric'); + await authenticate('Authenticate to continue', { + allowDeviceCredential: false, + }); + lastBiometricPromptRef.current = now; + return true; + } catch { + return false; + } + }, []); + + const requireLocalAuth = useCallback(async (): Promise => { + if (!biometricEnabled || !isMobile) return true; + return runBiometric(); + }, [biometricEnabled, runBiometric]); + + useEffect(() => { + const unlisten = events.passwordRequest.listen(async ({ payload }) => { + // Case 1: password takes precedence — show the dialog and wait. + if (payload.requiresPassword) { + setPending(payload); + return; + } + + // Case 2: no password, biometric enabled — standalone gate with cache. + if (biometricEnabled && isMobile) { + const ok = await runBiometric(); + await commands.submitPasswordResponse( + payload.requestId, + ok ? { kind: 'no_auth_needed' } : { kind: 'cancelled' }, + ); + return; + } + + // Case 3: no password, no biometric — nothing to do. + await commands.submitPasswordResponse(payload.requestId, { + kind: 'no_auth_needed', + }); + }); + + return () => { + unlisten.then((fn) => fn()); + }; + }, [biometricEnabled, runBiometric]); + + const handleSubmit = useCallback( + (password: string) => { + if (!pending) return; + setPending(null); + commands.submitPasswordResponse(pending.requestId, { + kind: 'password', + password, + }); + }, + [pending], + ); + + const handleCancel = useCallback(() => { + if (!pending) return; + setPending(null); + commands.submitPasswordResponse(pending.requestId, { kind: 'cancelled' }); + }, [pending]); + + return ( + + {children} + + + ); +} +``` + +Verify the generated `PasswordOutcome` discriminant names against `src/bindings.ts` — the Rust type uses +`#[serde(tag = "kind", rename_all = "snake_case")]`, so `no_auth_needed`, `cancelled`, and `password` +are expected. If the generated shape differs, match the bindings, not this snippet. + +`src/hooks/usePassword.ts` needs no change — it re-exports whatever the context provides. + +- [ ] **Step 2: Show the retry error in the dialog** + +`PasswordDialog` currently takes no error prop. Add an optional one so a wrong password re-prompts +visibly rather than silently reopening: + +In `src/components/dialogs/PasswordDialog.tsx`, extend `PasswordDialogProps` with +`attemptsRemaining?: number`, and render, below the existing `DialogDescription`: + +```tsx +{ + attemptsRemaining !== undefined && ( +

+ Incorrect password. {attemptsRemaining} attempts remaining. +

+ ); +} +``` + +Pass it from the provider: `attemptsRemaining={pending?.error?.attemptsRemaining}`. + +Do **not** run `pnpm run extract` for the new string — see Global Constraints. + +- [ ] **Step 3: Convert the Settings call sites** + +In `src/pages/Settings.tsx`, replace both `requestPassword(false)` gates. At line ~1106: + +```tsx +const start = async () => { + if (!(await requireLocalAuth())) return; + + commands + .startRpcServer() + .catch(addError) + .then(() => setIsRunning(true)); +}; +``` + +At line ~1123: + +```tsx +const toggleRunOnStartup = async (checked: boolean) => { + if (!(await requireLocalAuth())) return; + + commands + .setRpcRunOnStartup(checked) + .catch(addError) + .then(() => setRunOnStartup(checked)); +}; +``` + +Change the destructure at line ~1083 from `const { requestPassword } = usePassword();` to +`const { requireLocalAuth } = usePassword();`. + +- [ ] **Step 4: Verify** + +```bash +pnpm run build:frontend +pnpm run lint +``` + +Expected: `tsc -b` fails **only** in the files Tasks 8 and 9 will fix (`WalletCard.tsx`, +`ConfirmationDialog.tsx`, `useOfferProcessor.ts`, `Offer.tsx`, `WalletConnectContext.tsx`, +`src/walletconnect/*`), because `requestPassword` no longer exists. `Settings.tsx` and +`PasswordContext.tsx` must be clean. Record the remaining error list — it is the worklist for the next +two tasks. + +- [ ] **Step 5: Commit (only with user approval)** + +```bash +git add src/contexts/PasswordContext.tsx src/components/dialogs/PasswordDialog.tsx src/pages/Settings.tsx +git commit -m "feat(password-gate): invert PasswordContext into a responder" +``` + +--- + +## Task 8: Remove password plumbing from React call sites + +**Files:** + +- Modify: `src/components/WalletCard.tsx:69,82,180,196` +- Modify: `src/components/ConfirmationDialog.tsx:70,534,663` +- Modify: `src/hooks/useOfferProcessor.ts:31,65,188` +- Modify: `src/pages/Offer.tsx:37,111` +- Modify: `src/pages/Settings.tsx` — the `WalletSettings` component's `usePassword()` at ~:1167 and its `requestPassword` call gating `increaseDerivationIndex` + +**Interfaces:** + +- Consumes: `PasswordContextType` from Task 7 (no `requestPassword`) +- Produces: no component's public props change + +- [ ] **Step 1: Apply the mechanical edit in each file** + +Note on `Settings.tsx`: Task 7 converted two call sites in that file to `requireLocalAuth()` because they gate UI-only actions (starting the RPC server, run-on-startup) with no wallet secret behind them. The `WalletSettings` site is **different** and must be treated as an ordinary Task 8 site: `increase_derivation_index` is one of the 32 password-gated endpoints, so Rust now resolves its password itself. Strip the plumbing — do NOT convert it to `requireLocalAuth()`. After this task `usePassword()` should remain in the file only for the two `requireLocalAuth` consumers. + +In every listed file the pattern is identical. Remove: + +1. The `const { requestPassword } = usePassword();` destructure (and the whole `usePassword` import if + nothing else in the file uses it). +2. The `const password = await requestPassword(...); if (password === undefined) return;` guard. +3. The `password` property from the command argument object. +4. `requestPassword` from any `useCallback` / `useMemo` dependency array. + +Concretely, `src/pages/Offer.tsx:111` currently reads: + +```tsx +const password = await requestPassword(wallet?.has_password ?? false); +if (password === undefined) return; +``` + +Delete both lines, and drop `password` from the `commands.takeOffer({ ... })` argument below them. + +Keep the surrounding `try`/`catch` and `addError` handling exactly as-is. A cancelled prompt now +surfaces as a rejected command with the `Unauthorized` kind and the reason +`"Password entry cancelled"` — Task 10 makes the error handler silent for that case. + +- [ ] **Step 2: Verify** + +```bash +pnpm run build:frontend +pnpm run lint +``` + +Expected: the four files in this task no longer appear in the `tsc` error list. Only +`WalletConnectContext.tsx` and `src/walletconnect/*` remain. + +- [ ] **Step 3: Commit (only with user approval)** + +```bash +git add src/components/WalletCard.tsx src/components/ConfirmationDialog.tsx src/hooks/useOfferProcessor.ts src/pages/Offer.tsx +git commit -m "refactor(password-gate): drop password plumbing from React call sites" +``` + +--- + +## Task 9: Remove password plumbing from the WalletConnect layer + +**Files:** + +- Modify: `src/walletconnect/handler.ts:28` +- Modify: `src/walletconnect/commands/chip0002.ts:79,106` +- Modify: `src/walletconnect/commands/high-level.ts:50,87,97` +- Modify: `src/walletconnect/commands/offers.ts:9,38,55` +- Modify: `src/contexts/WalletConnectContext.tsx:69,109,146` + +**Interfaces:** + +- Consumes: `PasswordContextType` from Task 7 +- Produces: the handler context type loses both `requestPassword` and `hasPassword` + +- [ ] **Step 1: Narrow the handler context type** + +In `src/walletconnect/handler.ts`, delete line 28: + +```ts +requestPassword: (hasPassword: boolean) => Promise; +``` + +and delete the `hasPassword` field from the same interface if present. Nothing replaces them — the +Rust gate now owns this. + +- [ ] **Step 2: Strip the prompts from the command modules** + +In `chip0002.ts`, `high-level.ts`, and `offers.ts`, at each listed line, delete the pair: + +```ts +const password = await context.requestPassword(context.hasPassword); +if (password === undefined) throw new Error('Authentication failed'); +``` + +and remove `password` from the command argument object immediately below. In `chip0002.ts:109` the +call is `commands.signMessageWithPublicKey({ ...params, password })` — it becomes +`commands.signMessageWithPublicKey({ ...params })`. + +- [ ] **Step 3: Stop supplying the removed fields** + +In `src/contexts/WalletConnectContext.tsx`: + +- Delete `const { requestPassword } = usePassword();` (line ~69) and the `usePassword` import if now + unused. +- Delete `requestPassword,` from the context object passed to the handler (line ~109). +- Remove `requestPassword` and `wallet?.has_password` from the `useMemo`/`useCallback` dependency array + (line ~146). Leave `signClient`, `addError`, and `isReadOnly` in place. + +- [ ] **Step 4: Verify** + +```bash +pnpm run build:frontend +pnpm run lint +``` + +Expected: `tsc -b` now PASSES with zero errors, and `eslint` reports no new warnings. If +`wallet?.has_password` is now unused in this file, remove the `wallet` destructure too. + +**`tsc` alone does not prove this task is complete.** The four `src/walletconnect/` files type-check +against their own local `HandlerContext` interface rather than against `PasswordContextType`, so they +did not appear as compiler errors even while still calling `requestPassword`. A green build is +therefore necessary but not sufficient. Confirm the removal directly: + +```bash +grep -rn "requestPassword\|hasPassword" src/walletconnect/ src/contexts/WalletConnectContext.tsx +``` + +Expected: no matches. Any hit is an unremoved call site regardless of what `tsc` reports. + +- [ ] **Step 5: Commit (only with user approval)** + +```bash +git add src/walletconnect src/contexts/WalletConnectContext.tsx +git commit -m "refactor(password-gate): drop password plumbing from WalletConnect layer" +``` + +--- + +## Task 10: Silence cancelled-prompt errors + +A user dismissing the password dialog must not produce an error toast. Before this task, cancelling any +operation surfaces `"Password entry cancelled"` as a failure. + +**Files:** + +- Modify: `src/contexts/ErrorContext.tsx` + +**Interfaces:** + +- Consumes: `CANCELLED_REASON` value `"Password entry cancelled"` from Task 1, and `ErrorKind` + `unauthorized` +- Produces: no new public interface + +- [ ] **Step 1: Read the current error handling** + +```bash +grep -n "IncorrectPassword\|incorrect_password\|addError" src/contexts/ErrorContext.tsx | head -20 +``` + +Note how `addError` decides to surface an error, and whether an existing branch already special-cases +password errors. + +- [ ] **Step 2: Add the silent branch** + +In `src/contexts/ErrorContext.tsx`, inside `addError`, return early without displaying anything when +the error is a deliberate cancellation: + +```ts +const PASSWORD_CANCELLED_REASON = 'Password entry cancelled'; + +// ... inside addError, before any state update: +if ( + error.kind === 'unauthorized' && + error.reason === PASSWORD_CANCELLED_REASON +) { + return; +} +``` + +Match the exact string to `CANCELLED_REASON` in +`crates/sage-password-gate/src/resolve.rs`. If they +drift the toast reappears, so keep the comment noting the coupling. + +- [ ] **Step 3: Verify** + +```bash +pnpm run build:frontend +pnpm run lint +``` + +Expected: PASS. + +- [ ] **Step 4: Commit (only with user approval)** + +```bash +git add src/contexts/ErrorContext.tsx +git commit -m "fix(password-gate): stay silent when the user cancels the prompt" +``` + +--- + +## Task 11: Full verification and manual smoke + +No new code. This task proves the feature works end to end and that nothing regressed. + +**Files:** none modified. + +- [ ] **Step 1: Full workspace build and test** + +```bash +export SDKROOT="$(xcrun --show-sdk-path)" +cargo build --workspace +cargo test --workspace +``` + +Expected: all PASS. Pay particular attention to `sage-rpc` — those tests exercise the headless +password path and must be unchanged. Any failure there means the gate leaked into the core. + +- [ ] **Step 1b: Clear the one pre-existing clippy denial in `sage-rpc`** + +`crates/sage-rpc/src/tests.rs:353` has `old_password: "".to_string()`, which `clippy::manual_string_new` +denies. It predates this branch — verified identical on the `password` base branch — but Step 2 gates on +`-D warnings`, so it must go or the gate fails on code this plan did not write. + +This is the one authorised exception to the "do not modify `crates/sage-rpc/**`" constraint. That +constraint exists to stop the password gate leaking into the headless core; a `String::new()` lint fix in +a test file is not that. Change it to `old_password: String::new(),` and nothing else. Commit it +separately, labelled as a pre-existing lint fix, so it stays trivially separable from the feature. + +```bash +git add crates/sage-rpc/src/tests.rs +git commit -m "chore: fix pre-existing manual_string_new lint in sage-rpc tests" +``` + +- [ ] **Step 2: Lint and format** + +```bash +export SDKROOT="$(xcrun --show-sdk-path)" +cargo clippy --workspace --all-targets -- -D warnings +cargo fmt --all --check +pnpm run lint +pnpm run prettier:check +``` + +Expected: clean. Run `cargo fmt --all` and `pnpm run prettier` to fix formatting if needed. + +- [ ] **Step 3: Confirm no forbidden files changed** + +```bash +git diff --name-only password...HEAD | grep -E '^crates/sage/|^crates/sage-rpc/' || echo "clean: core untouched" +``` + +Expected: `clean: core untouched`. If anything is listed, review it against the Global Constraints +before proceeding. + +- [ ] **Step 4: Confirm no .po churn** + +```bash +git diff --name-only password...HEAD | grep '\.po$' || echo "clean: no lingui churn" +``` + +Expected: `clean: no lingui churn`. + +- [ ] **Step 5: Manual smoke — the three responder branches** + +```bash +export SDKROOT="$(xcrun --show-sdk-path)" +pnpm run tauri:dev +``` + +Walk through each and confirm: + +1. **Protected wallet, main UI.** Send a small amount of XCH. The password dialog appears. Enter the + wrong password twice — it re-prompts each time showing the remaining attempt count. Enter the + correct password — the transaction proceeds. +2. **Protected wallet, three strikes.** Repeat with three wrong passwords. The operation fails with an + authorization error, and the dialog closes. +3. **Cancel.** Start a send and dismiss the dialog. No error toast appears, and nothing is submitted. +4. **Unprotected wallet.** Send XCH. No dialog appears at all. +5. **Unprotected wallet with biometrics (mobile only).** The biometric gate appears, and a second + operation within five minutes does not re-prompt. + +- [ ] **Step 6: Manual smoke — the app bridge** + +With a **password-protected** wallet active, launch an app that calls `wallet.sendXch` and confirm: + +1. The `bridge-approval` app shows the transaction summary. +2. Approving it hides that app and reveals the password dialog **in the main window** — not inside the + app's webview. +3. The correct password completes the request; the app receives a success response. +4. Cancelling the password dialog fails the request and the app receives an error. +5. Grant the app `WalletSendXchAutoSubmit` and repeat: an approval **still** appears, because the + wallet is protected. +6. Switch to an **unprotected** wallet, keep the auto-submit grant, and repeat: no approval appears. + +- [ ] **Step 7: Manual smoke — WalletConnect** + +Pair a WalletConnect dApp against a protected wallet and confirm a signing request prompts for the +password in the main window and completes. + +- [ ] **Step 8: Commit any formatting fixes (only with user approval)** + +```bash +git add -A +git commit -m "chore(password-gate): formatting and lint fixes" +``` diff --git a/docs/superpowers/specs/2026-08-21-password-gate-design.md b/docs/superpowers/specs/2026-08-21-password-gate-design.md new file mode 100644 index 000000000..d11764377 --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-password-gate-design.md @@ -0,0 +1,226 @@ +# Password Gate: Rust-Owned Password Prompting + +**Date:** 2026-08-21 +**Branch:** `password-gate` (off `password`) +**Status:** Approved design, ready for implementation planning + +## Problem + +Password prompting in Sage is decided by the frontend. `PasswordContext.requestPassword(hasPassword)` +reads the wallet's `password_protected` flag, chooses between a password dialog, a biometric gate, and +no auth at all, and then threads the resulting string into the command it is about to call. Roughly +sixteen call sites across React pages, components, hooks, and the WalletConnect command layer repeat +this pattern. + +This has two consequences: + +1. **Apps cannot transact on a protected wallet.** The sage-apps bridge hardcodes `password: None` in + its request conversions (`send_xch.rs`, `sign_message.rs`, `sign_coin_spends.rs`), so any bridge + request against a password-protected wallet fails to decrypt. Apps have no path to prompt, and + giving them one would mean the master-key password passing through a sandboxed app webview. +2. **The decision is in the least trustworthy place.** Whether an operation requires authentication is + a security property of the wallet, not a UI concern. Every new caller must remember to ask. + +## Goals + +- Rust decides when authentication is required. Callers never supply a password and never decide. +- The password dialog renders only in the trusted `main` webview. Secrets never enter app-land. +- Apps gain a working path to operate on protected wallets. +- All existing in-app password prompting is replumbed through the same choke point. + +## Non-goals + +- **Session unlock / key caching.** Prompting once and holding the decrypted master key in memory for + a window is a separate change to the security model. Deferred. +- **`ChangePassword`.** Its `old_password` / `new_password` are password _management_ form data, not + wallet unlocking. Unchanged. +- **`passkey-unlock`.** Independent work on its own branch. + +## Key constraint: sage-rpc is headless + +`sage-rpc` drives the same `Arc>` core over mTLS (`crates/sage-rpc/src/lib.rs:33`) and its +clients legitimately supply `password` in the request body (`crates/sage-rpc/src/tests.rs:216`). There +is no UI to prompt. + +Therefore: + +- `password: Option` **stays** on the `sage-api` request types. It is the RPC contract. +- `Sage::sign(coin_spends, partial, &password)` and the `keychain.extract_secrets` call sites in + `crates/sage/src/endpoints/` are **unchanged**. +- The prompting choke point lives in the **Tauri host layer, above `Sage`** — never in the core. + +This placement also removes a re-entrancy hazard. Every endpoint runs inside `app_state.lock().await`. +Awaiting a round-trip to the main webview while holding that lock risks deadlock if the webview's +handler invokes any command needing the same lock. Resolving the password _before_ the lock is taken +avoids the problem entirely. + +## Architecture + +New crate `crates/sage-password-gate`. It lives outside `src-tauri/src/` because both `sage-tauri` +and `sage-apps` must call it, and `sage-apps` cannot depend on the `sage-tauri` binary crate. Its entry +point is +`resolve(app_handle, state) -> Result>`. It reads the active wallet's fingerprint and +`password_protected` flag from `state` without holding the lock across the round-trip, asks the main +webview, validates the answer against the keychain, and returns a verified password or `None`. + +### Transport + +Rust to the main webview is a tauri-specta event; the reply returns as a command, because a password +must not ride an event broadcast. + +- **Event** `PasswordRequest { request_id, fingerprint, requires_password: bool, attempt: u8, error: Option }`, + emitted with `emit_to(SAGE_WEBVIEW_LABEL, ...)`. Never plain `emit` — no app webview may observe it. +- **Command** `submit_password_response(request_id, outcome)` where + `outcome = Password(String) | NoAuthNeeded | Cancelled`. +- **State** `PasswordGateState { pending: Mutex>> }`. + The gate awaits the oneshot. + +`requires_password` is advisory rather than the whole decision. Rust knows `password_protected`; it +does not know whether biometric auth is enabled, which is a UI and plugin setting. So the gate always +emits, and the frontend keeps today's exact three-way logic: password dialog, biometric gate with its +existing five-minute cache, or an immediate `NoAuthNeeded`. Biometric logic stays where it belongs and +behavior is preserved bit-for-bit. The cost is one sub-millisecond IPC round-trip on unprotected +wallets. + +_Rejected:_ pushing the biometric setting into Rust to skip that round-trip. Not worth the state-sync +complexity for the latency saved. + +### Verification and retry + +The gate verifies with `keychain.extract_secrets(fingerprint, &password)` **before** taking the app +lock. On `KeychainError::Decrypt` it re-emits with an incremented `attempt` and an inline error, up to +three attempts, then fails `Unauthorized`. `Cancelled` fails immediately with a distinct error kind so +the frontend can stay silent rather than surfacing a toast. + +Verifying above the lock is what makes bounded retry cheap: a wrong password costs one keychain +decrypt, not a partially built transaction. + +### Gating the endpoints + +Every endpoint command is generated from a single `repeat` block (`src-tauri/src/commands.rs:67-75`) +driven by `crates/sage-api/endpoints.json`. The gate therefore goes in exactly one place. + +Add a `maybe_unlock` token to `crates/sage-api/macro/src/lib.rs` alongside `maybe_async` and +`maybe_await`, driven by a new gated-endpoint set. The repeat block becomes: + +```rust +pub async fn endpoint( + app_handle: AppHandle, + state: State<'_, AppState>, + gate: State<'_, PasswordGateState>, + mut req: Endpoint, +) -> Result { + maybe_unlock; // expands to: req.password = gate.resolve(&app_handle, &state).await?; + Ok(state.lock().await.endpoint(req) maybe_await?) +} +``` + +`maybe_unlock` expands to nothing for ungated endpoints. + +Drift is prevented by a `sage-api` test asserting the gated set is exactly the set of request types +carrying a `password` field. Adding a signing endpoint without gating it fails the build. + +### Bridge path (apps) + +The two dialogs are sequential. The user approves the request summary in the `bridge-approval` system +app exactly as today; the password is then collected in the main webview. + +In `process_after_approval` (`crates/sage-apps/src/bridge/bridge_request.rs:83`), after `approved == +true` and the wallet-binding check passes, and before `process_shared`: + +1. Hide the `bridge-approval` runtime via `hide_runtime_inner` so it does not cover the main webview. + App runtimes are sibling webviews inside the same `main` window (`runtime/manager.rs:395-414`), so + a React dialog would otherwise render underneath. +2. Call the gate. +3. Execute. + +Because approval and password are now two phases, the original `expires_at_ms` keeps running through +the prompt. A lapse mid-prompt fails with `approval_timeout`. One clock, no new concept, and an app +cannot hold a signing path open indefinitely. + +The verified password is threaded into the handler through `BridgeTools`, so the conversions in +`send_xch.rs`, `sign_message.rs`, and `sign_coin_spends.rs` set `Some(...)` instead of the hardcoded +`None`. + +Separately, `WalletSendXch::approval_request` stops returning `Ok(None)` on a protected wallet even +when `WalletSendXchAutoSubmit` is granted. A password-protected wallet always gets an approval; silent +auto-submit is incompatible with password protection. + +### Frontend + +`PasswordContext` inverts. It stops exporting `requestPassword` as something callers invoke and +instead subscribes to `PasswordRequest`, runs its existing three-way decision, and replies via +`submit_password_response`. `PasswordDialog` itself is unchanged. + +All call sites then drop their password plumbing: + +- `WalletCard.tsx`, `ConfirmationDialog.tsx`, `useOfferProcessor.ts`, `Offer.tsx` — remove the + `requestPassword` call and the `password` field on the command. +- `src/walletconnect/` — `chip0002.ts`, `high-level.ts`, and `offers.ts` lose their prompts; + `handler.ts` and `WalletConnectContext.tsx` drop `requestPassword` and `hasPassword` from the + handler context entirely. +- `Settings.tsx:1106` and `:1123` gate starting the RPC server and toggling run-on-startup. No wallet + secret is involved, so there is no Rust unlock operation to hang them off. They get a new, + explicitly named `requireLocalAuth()` from the same provider: a UI-only biometric gate with no Rust + round-trip. + +## Error handling + +| Condition | Result | +| ----------------------------------- | -------------------------------------------------- | +| Correct password | Endpoint executes | +| Wrong password, attempts 1-2 | Re-prompt with inline error, `attempt` incremented | +| Wrong password, attempt 3 | `Unauthorized` | +| User cancels | Distinct cancellation error; frontend stays silent | +| Approval deadline lapses mid-prompt | `approval_timeout`, dialog closes | +| Main webview absent or unresponsive | Gate fails; operation does not proceed | + +## Testing + +**Rust** + +- Gate unit tests against a mock responder: correct password; wrong-then-right; three strikes; + cancellation; expiry mid-prompt. +- The drift test asserting gated set == request types with a `password` field. +- A bridge test that a protected wallet forces an approval despite the `WalletSendXchAutoSubmit` grant. +- Existing `sage-rpc` password tests must pass **unchanged** — the regression canary proving the core + was not disturbed. + +**TypeScript** + +The repository has no frontend test runner (no vitest or jest, no `test` script in `package.json`), +and bootstrapping one inside this feature is out of scope. Frontend changes are verified by +`pnpm run build:frontend` (`tsc -b`), `pnpm run lint`, and a manual smoke run covering all three +responder branches: password dialog on a protected wallet, biometric gate on an unprotected wallet +with biometrics on, and immediate `NoAuthNeeded` otherwise. + +## Files touched + +**New** + +- `crates/sage-password-gate/` (`lib.rs`, `types.rs`, `prompter.rs`, `resolve.rs`) + +**Rust** + +- `src-tauri/src/commands.rs` — repeat block, `submit_password_response` +- `src-tauri/src/lib.rs` — register state, command, and event +- `src-tauri/src/error.rs` — `From` +- `crates/sage-apps/Cargo.toml`, `src-tauri/Cargo.toml`, root `Cargo.toml` — new crate wiring +- `crates/sage-api/macro/src/lib.rs` — `maybe_unlock` +- `crates/sage-api/endpoints.json` (or a sibling gated-endpoint set) +- `crates/sage-apps/src/bridge/bridge_request.rs` — gate call in `process_after_approval` +- `crates/sage-apps/src/bridge/methods/user/wallet/{send_xch,sign_message,sign_coin_spends}.rs` + +**TypeScript** + +- `src/contexts/PasswordContext.tsx`, `src/hooks/usePassword.ts` +- `src/contexts/WalletConnectContext.tsx`, `src/walletconnect/{handler,commands/chip0002,commands/high-level,commands/offers}.ts` +- `src/components/{WalletCard,ConfirmationDialog}.tsx` +- `src/hooks/useOfferProcessor.ts`, `src/pages/{Offer,Settings}.tsx` +- `src/bindings.ts` (regenerated) + +**Unchanged, deliberately** + +- `crates/sage/src/**` — all endpoints and `Sage::sign` +- `crates/sage-rpc/src/**` +- `password: Option` on all `sage-api` request types From fb8ef092614c476db29a78d1ea3874cde8ff7ccb Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 20:33:04 -0500 Subject: [PATCH 22/36] fix(password-gate): make the gate fingerprint-aware `maybe_unlock` expanded identically for all 32 gated endpoints, so `delete_key` and `get_secret_key` -- the only gated request types that carry a `fingerprint` -- called `resolve`, which starts with `sage.wallet()?`. Both are driven exclusively from the logged-out wallet list, so they failed with `NotLoggedIn` before doing anything, and `ErrorContext` silently swallows that reason: the dialog closed and nothing happened. While logged in they verified the *active* wallet's password but handed it to the keychain for `req.fingerprint`. Adds `resolve_for_fingerprint`, which never touches `Sage::wallet()` -- the `password_protected` lookup already searches `wallet_config.wallets` by fingerprint and needs no active wallet. The macro picks the form from `password-gated-fingerprint.json`, a subset of `password-gated.json` kept honest by a new drift test. Co-Authored-By: Claude Opus 5 --- crates/sage-api/macro/src/lib.rs | 36 +++++- .../sage-api/password-gated-fingerprint.json | 4 + crates/sage-api/src/lib.rs | 89 ++++++++++++--- crates/sage-password-gate/src/lib.rs | 103 ++++++++++++++++-- 4 files changed, 198 insertions(+), 34 deletions(-) create mode 100644 crates/sage-api/password-gated-fingerprint.json diff --git a/crates/sage-api/macro/src/lib.rs b/crates/sage-api/macro/src/lib.rs index ce9da0c7f..1f6c7fdb0 100644 --- a/crates/sage-api/macro/src/lib.rs +++ b/crates/sage-api/macro/src/lib.rs @@ -84,19 +84,38 @@ fn generate(input: &TokenStream, tauri: bool) -> TokenStream { serde_json::from_str(include_str!("../../password-gated.json")) .expect("Invalid password-gated endpoint file"); + // The subset of the gated endpoints whose request type carries a + // `fingerprint` field. These act on a *named* wallet rather than the + // active one, so the gate must verify against that wallet. + let fingerprint_gated: std::collections::BTreeSet = + serde_json::from_str(include_str!("../../password-gated-fingerprint.json")) + .expect("Invalid fingerprint-gated endpoint file"); + + let gating = Gating { + password_gated: &password_gated, + fingerprint_gated: &fingerprint_gated, + }; + let mut output = proc_macro2::TokenStream::new(); for token in input.clone() { - convert(token, &endpoints, &password_gated, None, &mut output); + convert(token, &endpoints, gating, None, &mut output); } output.into() } +/// Which endpoints the password gate applies to, and how. +#[derive(Clone, Copy)] +struct Gating<'a> { + password_gated: &'a std::collections::BTreeSet, + fingerprint_gated: &'a std::collections::BTreeSet, +} + fn convert( tree: TokenTree, endpoints: &IndexMap, - password_gated: &std::collections::BTreeSet, + gating: Gating<'_>, endpoint: Option<&str>, output: &mut proc_macro2::TokenStream, ) { @@ -121,7 +140,14 @@ fn convert( output.extend(quote!(.await)); } } else if ident == "maybe_unlock" { - if password_gated.contains(endpoint) { + if gating.fingerprint_gated.contains(endpoint) { + // Acts on `req.fingerprint`, which may be a wallet other + // than the active one -- and may run with no wallet + // active at all, from the logged-out wallet list. + output.extend(quote!( + req.password = sage_password_gate::resolve_for_fingerprint(&app_handle, state.inner(), gate.inner(), req.fingerprint).await?; + )); + } else if gating.password_gated.contains(endpoint) { output.extend(quote!( req.password = sage_password_gate::resolve(&app_handle, state.inner(), gate.inner()).await?; )); @@ -163,14 +189,14 @@ fn convert( if repeat { for endpoint in endpoints.keys() { for tree in stream.clone() { - convert(tree, endpoints, password_gated, Some(endpoint), output); + convert(tree, endpoints, gating, Some(endpoint), output); } } } else { let mut inner = proc_macro2::TokenStream::new(); for tree in stream { - convert(tree, endpoints, password_gated, endpoint, &mut inner); + convert(tree, endpoints, gating, endpoint, &mut inner); } output.extend(proc_macro2::TokenStream::from(TokenStream::from( diff --git a/crates/sage-api/password-gated-fingerprint.json b/crates/sage-api/password-gated-fingerprint.json new file mode 100644 index 000000000..a1e7d0d4f --- /dev/null +++ b/crates/sage-api/password-gated-fingerprint.json @@ -0,0 +1,4 @@ +[ + "delete_key", + "get_secret_key" +] diff --git a/crates/sage-api/src/lib.rs b/crates/sage-api/src/lib.rs index 466c56db1..f05112ec1 100644 --- a/crates/sage-api/src/lib.rs +++ b/crates/sage-api/src/lib.rs @@ -20,17 +20,11 @@ pub use sage_api_macro::openapi as openapi_attr; #[cfg(test)] mod password_gate_drift { - use std::collections::BTreeSet; - - /// The gated set must match, exactly, the request types carrying a - /// `password` field. If this fails you either added a signing endpoint - /// without gating it (a security hole) or gated one that takes no - /// password (a spurious prompt). - #[test] - fn gated_set_matches_request_types_with_password_field() { - let gated: BTreeSet = - serde_json::from_str(include_str!("../password-gated.json")).unwrap(); + use std::collections::{BTreeMap, BTreeSet}; + /// Reads every request source file so the scanners below see the same + /// text the macro's JSON manifests are supposed to describe. + fn request_sources() -> Vec { let requests_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/requests"); let entries = std::fs::read_dir(&requests_dir).unwrap_or_else(|error| { panic!("failed to read request sources at {requests_dir:?}: {error}") @@ -54,9 +48,64 @@ mod password_gate_drift { sources.len(), ); + sources + } + + /// The fingerprint-bearing subset must match, exactly, the gated request + /// types that also carry a `fingerprint` field. If this fails the macro + /// either prompts for the active wallet while acting on a named one (wrong + /// password handed to the keychain, and a hard failure when logged out), or + /// reads a `req.fingerprint` that does not exist (a compile error). + #[test] + fn fingerprint_gated_set_matches_request_types_with_fingerprint_field() { + let fingerprint_gated: BTreeSet = + serde_json::from_str(include_str!("../password-gated-fingerprint.json")).unwrap(); + let gated: BTreeSet = + serde_json::from_str(include_str!("../password-gated.json")).unwrap(); + + assert!( + fingerprint_gated.is_subset(&gated), + "password-gated-fingerprint.json must be a subset of password-gated.json; \ + extra entries: {:?}", + fingerprint_gated.difference(&gated).collect::>(), + ); + let mut discovered = BTreeSet::new(); - for source in &sources { - discovered.extend(structs_with_password_field(source)); + for source in &request_sources() { + for (name, has_fingerprint) in password_structs(source) { + if has_fingerprint { + discovered.insert(name); + } + } + } + + assert_eq!( + fingerprint_gated, + discovered, + "password-gated-fingerprint.json is out of sync with the request types.\n\ + Only in password-gated-fingerprint.json: {:?}\n\ + Only in request types: {:?}", + fingerprint_gated + .difference(&discovered) + .collect::>(), + discovered + .difference(&fingerprint_gated) + .collect::>(), + ); + } + + /// The gated set must match, exactly, the request types carrying a + /// `password` field. If this fails you either added a signing endpoint + /// without gating it (a security hole) or gated one that takes no + /// password (a spurious prompt). + #[test] + fn gated_set_matches_request_types_with_password_field() { + let gated: BTreeSet = + serde_json::from_str(include_str!("../password-gated.json")).unwrap(); + + let mut discovered = BTreeSet::new(); + for source in &request_sources() { + discovered.extend(password_structs(source).into_keys()); } assert_eq!( @@ -71,9 +120,10 @@ mod password_gate_drift { } /// Scans Rust source for `pub struct Name {` blocks containing a - /// `pub password: Option` field, returning `snake_case` names. - fn structs_with_password_field(source: &str) -> BTreeSet { - let mut found = BTreeSet::new(); + /// `pub password: Option` field, returning `snake_case` names + /// mapped to whether the same struct also carries a `fingerprint` field. + fn password_structs(source: &str) -> BTreeMap { + let mut found = BTreeMap::new(); let lines: Vec<&str> = source.lines().collect(); let mut index = 0; @@ -90,15 +140,20 @@ mod password_gate_drift { let mut cursor = index + 1; let mut has_password = false; + let mut has_fingerprint = false; while cursor < lines.len() && lines[cursor] != "}" { - if lines[cursor].trim() == "pub password: Option," { + let field = lines[cursor].trim(); + if field == "pub password: Option," { has_password = true; } + if field == "pub fingerprint: u32," { + has_fingerprint = true; + } cursor += 1; } if has_password { - found.insert(to_snake_case(name)); + found.insert(to_snake_case(name), has_fingerprint); } index = cursor + 1; } diff --git a/crates/sage-password-gate/src/lib.rs b/crates/sage-password-gate/src/lib.rs index f79c9b00c..d409ee9c6 100644 --- a/crates/sage-password-gate/src/lib.rs +++ b/crates/sage-password-gate/src/lib.rs @@ -104,26 +104,65 @@ use tauri::AppHandle; /// The host's shared Sage handle. Mirrors `sage_tauri::app_state::AppState`. pub type SharedSage = Arc>; -/// Resolves a password for the active wallet, prompting the `main` webview. +/// Resolves a password for the **active** wallet, prompting the `main` webview. /// -/// Reads the wallet fingerprint and its stored `password_protected` flag, then -/// releases the app lock *before* the frontend round-trip. Uses the cheap -/// config flag rather than `Keychain::is_password_protected`, which runs an -/// Argon2 decrypt probe on every call. +/// Use this for endpoints whose request type carries no fingerprint, so the +/// operation implicitly targets whatever wallet is logged in. Endpoints that +/// name a fingerprint must use [`resolve_for_fingerprint`] instead, otherwise +/// the gate verifies the wrong wallet's password (and fails outright when no +/// wallet is active at all). pub async fn resolve( app_handle: &AppHandle, state: &SharedSage, gate: &PasswordGateState, +) -> Result> { + resolve_target(app_handle, state, gate, None).await +} + +/// Resolves a password for an explicitly named wallet. +/// +/// Unlike [`resolve`] this never touches `Sage::wallet()`, so it works while +/// logged out (the logged-out wallet list is exactly where `delete_key` and +/// `get_secret_key` are driven from) and it verifies against the wallet the +/// caller is actually acting on rather than the active one. +pub async fn resolve_for_fingerprint( + app_handle: &AppHandle, + state: &SharedSage, + gate: &PasswordGateState, + fingerprint: u32, +) -> Result> { + resolve_target(app_handle, state, gate, Some(fingerprint)).await +} + +/// Shared body of [`resolve`] and [`resolve_for_fingerprint`]. +/// +/// Reads the target wallet fingerprint and its stored `password_protected` +/// flag, then releases the app lock *before* the frontend round-trip. Uses the +/// cheap config flag rather than `Keychain::is_password_protected`, which runs +/// an Argon2 decrypt probe on every call. +/// +/// `target` of `None` means "the active wallet"; only that path needs a wallet +/// to be logged in, because the `password_protected` lookup searches +/// `wallet_config.wallets` by fingerprint and is happy without an active one. +async fn resolve_target( + app_handle: &AppHandle, + state: &SharedSage, + gate: &PasswordGateState, + target: Option, ) -> Result> { let (fingerprint, requires_password) = { let sage = state.lock().await; - let fingerprint = sage - .wallet() - .map_err(|err| Error { - kind: err.kind(), - reason: err.to_string(), - })? - .fingerprint; + let fingerprint = match target { + Some(fingerprint) => fingerprint, + None => { + sage.wallet() + .map_err(|err| Error { + kind: err.kind(), + reason: err.to_string(), + })? + .fingerprint + } + }; let requires_password = sage .wallet_config .wallets @@ -208,6 +247,46 @@ mod tests { ); } + /// The resolve loop reuses one request id across all attempts, so the same + /// id is registered again after the previous attempt's entry was consumed. + /// There must be no stale-entry collision: the new oneshot has to be the + /// one that receives the outcome. + #[tokio::test] + async fn a_request_id_can_be_registered_again_after_delivery() { + let gate = PasswordGateState::default(); + + let (first_tx, first_rx) = tokio::sync::oneshot::channel(); + gate.register("req-retry".to_string(), first_tx).await; + gate.deliver( + "req-retry", + PasswordOutcome::Password { + password: "wrong".to_string(), + }, + ) + .await + .unwrap(); + assert!(matches!( + first_rx.await.unwrap(), + PasswordOutcome::Password { .. } + )); + + let (second_tx, second_rx) = tokio::sync::oneshot::channel(); + gate.register("req-retry".to_string(), second_tx).await; + gate.deliver( + "req-retry", + PasswordOutcome::Password { + password: "right".to_string(), + }, + ) + .await + .expect("the retry must be deliverable under the same id"); + + match second_rx.await.unwrap() { + PasswordOutcome::Password { password } => assert_eq!(password, "right"), + other => panic!("unexpected outcome: {other:?}"), + } + } + #[tokio::test] async fn a_request_that_is_never_answered_times_out_and_is_cleared() { tokio::time::pause(); From d6338d03c0fff58ebc6a3ecc07a91e98208e6f2d Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 20:33:04 -0500 Subject: [PATCH 23/36] fix(apps): target the gate at the approval's wallet, and stop the flicker `GetSecretKey` approvals prompted for the active wallet while acting on `params.fingerprint`, the same root cause as the endpoint fix. The approval path now resolves against the fingerprint in the approval body. `approval_requires_password` also keyed only off the approval body, so every SendXch/SignMessage/SignCoinSpends approval on an unprotected wallet hid the `bridge-approval` runtime, ran a no-op gate round-trip and re-synced visibility -- a visible flicker whenever approvals were queued. The hide/restore pair is now conditional on the target wallet actually being password-protected. The gate call itself still runs unconditionally, because the frontend may put a biometric gate in front of it. Co-Authored-By: Claude Opus 5 --- crates/sage-apps/src/bridge/bridge_request.rs | 74 +++++++++++++++++-- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/crates/sage-apps/src/bridge/bridge_request.rs b/crates/sage-apps/src/bridge/bridge_request.rs index 615651347..c43d36bfb 100644 --- a/crates/sage-apps/src/bridge/bridge_request.rs +++ b/crates/sage-apps/src/bridge/bridge_request.rs @@ -139,18 +139,37 @@ pub(crate) async fn process_after_approval( ) .await? } else { + // The gate targets the wallet the approval actually acts on, which for + // `GetSecretKey` is the fingerprint in the approval body rather than + // the active wallet. + let target_fingerprint = approval_password_fingerprint(&pending); + + // Only a password-protected wallet can produce a password dialog. On + // the unprotected path the gate still runs (the frontend may still put + // a biometric gate in front of it) but nothing covers the main webview, + // so hiding and re-syncing the approval runtime would be a pure + // flicker. + let protected = match target_fingerprint { + Some(fingerprint) => wallet_password_protected(app_state, fingerprint).await, + None => active_wallet_password_protected(app_state).await, + }; + // Hide the approval app before prompting: app runtimes are sibling // webviews inside the same window and would cover the main webview's // password dialog. - hide_bridge_approval_runtime(app_handle, apps_state).await; + if protected { + hide_bridge_approval_runtime(app_handle, apps_state).await; + } - let resolved = password_gate_resolve(app_handle, app_state).await; + let resolved = password_gate_resolve(app_handle, app_state, target_fingerprint).await; // The prompt hid the approval runtime, so recompute visibility on every // exit path from the password phase -- success, cancel, error, and the // post-gate expiry check below alike. Without this a still-queued // approval stays invisible with no way for the user to reach it. - restore_bridge_approval_runtime(app_handle, apps_state).await; + if protected { + restore_bridge_approval_runtime(app_handle, apps_state).await; + } match resolved { // The expiry check above ran before the prompt, and the prompt can @@ -362,12 +381,24 @@ async fn restore_bridge_approval_runtime( async fn password_gate_resolve( app_handle: &AppHandle, app_state: &State<'_, AppState>, + fingerprint: Option, ) -> Result, String> { let gate = app_handle.state::(); - sage_password_gate::resolve(app_handle, app_state.inner(), &gate) - .await - .map_err(|err| err.reason) + let resolved = match fingerprint { + Some(fingerprint) => { + sage_password_gate::resolve_for_fingerprint( + app_handle, + app_state.inner(), + &gate, + fingerprint, + ) + .await + } + None => sage_password_gate::resolve(app_handle, app_state.inner(), &gate).await, + }; + + resolved.map_err(|err| err.reason) } async fn active_wallet_fingerprint(app_state: &State<'_, AppState>) -> Option { @@ -391,6 +422,37 @@ async fn active_wallet_password_protected(app_state: &State<'_, AppState>) -> bo .is_some_and(|wallet| wallet.password_protected) } +/// Whether `fingerprint`'s wallet is password-protected, per its entry in +/// `sage.wallet_config.wallets`. Unlike [`active_wallet_password_protected`] +/// this needs no wallet to be logged in. +async fn wallet_password_protected(app_state: &State<'_, AppState>, fingerprint: u32) -> bool { + app_state + .lock() + .await + .wallet_config + .wallets + .iter() + .find(|wallet| wallet.fingerprint == fingerprint) + .is_some_and(|wallet| wallet.password_protected) +} + +/// The wallet the password gate must target for this approval. +/// +/// `None` means "the active wallet". `GetSecretKey` names its own fingerprint, +/// which need not be the active wallet, so prompting for and verifying the +/// active wallet's password there would hand the keychain the wrong secret. +fn approval_password_fingerprint(pending: &PendingBridgeApproval) -> Option { + match pending.approval.body { + RustBridgeApprovalBody::GetSecretKey { fingerprint } => Some(fingerprint), + + RustBridgeApprovalBody::SendXch { .. } + | RustBridgeApprovalBody::SignCoinSpends { .. } + | RustBridgeApprovalBody::SignMessage { .. } + | RustBridgeApprovalBody::CapabilityGrant { .. } + | RustBridgeApprovalBody::NetworkWhitelistGrant { .. } => None, + } +} + /// Whether resuming this approval needs the master-key password. /// /// Only bodies whose handler reaches a wallet secret are gated. Capability and From 0af74b856259d6c280356ec01bde235b97ecb4e6 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 20:33:20 -0500 Subject: [PATCH 24/36] fix(password-gate): drop the fingerprint from the event payload `emit_to(app_handle, "main")` is not an isolation boundary. Delivery runs through `Listeners::emit_js_filter` -> `match_any_or_filter`, which short-circuits to true for any listener registered with `EventTarget::Any` and never consults the target, and `capabilities/apps.json` grants app webviews `core:event:allow-listen`. An app webview that calls `listen('password-request', ...)` sees every prompt. No password leaks -- the secret only ever rides `submit_password_response`, which apps are not granted -- but the active wallet fingerprint, and wallet switches over time, did. Removes the field (the frontend never read it) and corrects the comments and the design spec, which both asserted a guarantee the runtime does not provide. `attemptsRemaining` stays: the dialog needs it, and a bare retry counter identifies no wallet and reveals nothing an observer could not already infer from the re-prompt timing it can see anyway. Co-Authored-By: Claude Opus 5 --- crates/sage-password-gate/src/prompter.rs | 13 +++-- crates/sage-password-gate/src/types.rs | 17 ++++++- .../specs/2026-08-21-password-gate-design.md | 49 +++++++++++++++---- src/bindings.ts | 25 ++++++++-- 4 files changed, 86 insertions(+), 18 deletions(-) diff --git a/crates/sage-password-gate/src/prompter.rs b/crates/sage-password-gate/src/prompter.rs index 24f474338..0444d31a5 100644 --- a/crates/sage-password-gate/src/prompter.rs +++ b/crates/sage-password-gate/src/prompter.rs @@ -32,9 +32,16 @@ use super::Error; use crate::PasswordGateState; use sage_api::ErrorKind; -/// The Sage React webview. App runtimes are sibling webviews in the same -/// window, so emission MUST target this label — a plain `emit` would deliver -/// the request to app-land. +/// The Sage React webview, and the label every password request is emitted to. +/// +/// Targeting this label is where the request is *meant* to land, not a +/// guarantee of where it can land. Tauri resolves an `AnyLabel` target through +/// `match_any_or_filter`, which short-circuits to true for any listener +/// registered with `EventTarget::Any`, and `src-tauri/capabilities/apps.json` +/// grants app webviews `core:event:allow-listen`. An app runtime that listens +/// for `password-request` will therefore see it. `PasswordRequest` is kept free +/// of anything sensitive for exactly that reason; the password travels back on +/// `submit_password_response`, a command app webviews are not granted. pub const SAGE_WEBVIEW_LABEL: &str = "main"; pub struct TauriPrompter<'a> { diff --git a/crates/sage-password-gate/src/types.rs b/crates/sage-password-gate/src/types.rs index 1da7d79d9..b76b02106 100644 --- a/crates/sage-password-gate/src/types.rs +++ b/crates/sage-password-gate/src/types.rs @@ -20,17 +20,30 @@ pub struct PasswordAttemptError { pub attempts_remaining: u8, } -/// Emitted to the `main` webview only. Never broadcast. +/// The password prompt as it crosses to JavaScript. +/// +/// Emission targets the `main` webview (see `prompter::SAGE_WEBVIEW_LABEL`), +/// but that is **not** an isolation guarantee: Tauri's event filter +/// short-circuits for listeners registered with `EventTarget::Any`, and +/// `src-tauri/capabilities/apps.json` grants app webviews +/// `core:event:allow-listen`. Any app runtime can therefore observe this +/// payload. It deliberately carries nothing sensitive -- no fingerprint, no +/// wallet identity, and of course no password. The password itself only ever +/// travels the other way, through the `submit_password_response` command, +/// which is not granted to app webviews. #[derive(Debug, Clone, Serialize, Deserialize, Type, tauri_specta::Event)] #[serde(rename_all = "camelCase")] pub struct PasswordRequest { pub request_id: String, - pub fingerprint: u32, /// Advisory: the wallet's stored `password_protected` flag. The frontend /// still decides between password dialog, biometric gate, and no auth, /// because Rust does not know whether biometrics are enabled. pub requires_password: bool, /// 1-based. Increments on each incorrect-password re-prompt. pub attempt: u8, + /// Retained despite being observable by app webviews: the dialog needs it + /// to show "N attempts remaining", and a bare retry counter identifies no + /// wallet and reveals nothing an observer could not already infer from the + /// re-prompts themselves. pub error: Option, } diff --git a/docs/superpowers/specs/2026-08-21-password-gate-design.md b/docs/superpowers/specs/2026-08-21-password-gate-design.md index d11764377..bab2860a8 100644 --- a/docs/superpowers/specs/2026-08-21-password-gate-design.md +++ b/docs/superpowers/specs/2026-08-21-password-gate-design.md @@ -63,13 +63,37 @@ point is `password_protected` flag from `state` without holding the lock across the round-trip, asks the main webview, validates the answer against the keychain, and returns a verified password or `None`. +A sibling entry point `resolve_for_fingerprint(app_handle, state, gate, fingerprint)` targets an +explicitly named wallet. Endpoints whose request type carries a `fingerprint` — `delete_key` and +`get_secret_key`, plus the `wallet.getSecretKey` bridge method — act on a wallet that need not be the +active one, and are driven from the logged-out wallet list where there is no active wallet at all. +`resolve` would both prompt for the wrong wallet's password and fail with `NotLoggedIn`, so those +endpoints use the fingerprint-targeted form. The `password_protected` lookup searches +`wallet_config.wallets` by fingerprint and needs no active wallet, so this path never calls +`Sage::wallet()`. The macro picks the form from `crates/sage-api/password-gated-fingerprint.json`, +a subset of `password-gated.json` kept honest by a drift test in `crates/sage-api/src/lib.rs`. + ### Transport Rust to the main webview is a tauri-specta event; the reply returns as a command, because a password must not ride an event broadcast. -- **Event** `PasswordRequest { request_id, fingerprint, requires_password: bool, attempt: u8, error: Option }`, - emitted with `emit_to(SAGE_WEBVIEW_LABEL, ...)`. Never plain `emit` — no app webview may observe it. +- **Event** `PasswordRequest { request_id, requires_password: bool, attempt: u8, error: Option }`, + emitted with `emit_to(SAGE_WEBVIEW_LABEL, ...)` rather than a plain `emit`. + + Targeting `main` is where the request is *meant* to land, not a guarantee of where it *can* land. + Tauri resolves an `AnyLabel` target through `Listeners::emit_js_filter` → `match_any_or_filter`, + which short-circuits to true for any listener registered with `EventTarget::Any`, never consulting + the target. `src-tauri/capabilities/apps.json` grants app webviews `core:event:allow-listen`, and + `plugin:event|listen` takes its target straight from JS, so an app runtime that calls + `listen('password-request', …)` receives every prompt. The payload is therefore designed to be + observable: it carries no wallet fingerprint, no wallet identity, and no password. `attempt` and + `error.attemptsRemaining` are retained because the dialog needs them and a bare retry counter + identifies nothing an observer could not already infer from the re-prompt timing itself. + + What keeps the *password* safe is direction, not targeting: the secret only ever travels back on + `submit_password_response`, a command absent from both `apps.json` and `system-apps.json`, and apps + hold no `core:event:allow-emit` with which to forge a request. - **Command** `submit_password_response(request_id, outcome)` where `outcome = Password(String) | NoAuthNeeded | Cancelled`. - **State** `PasswordGateState { pending: Mutex>> }`. @@ -117,8 +141,10 @@ pub async fn endpoint( `maybe_unlock` expands to nothing for ungated endpoints. -Drift is prevented by a `sage-api` test asserting the gated set is exactly the set of request types -carrying a `password` field. Adding a signing endpoint without gating it fails the build. +Drift is prevented by `sage-api` tests asserting that the gated set is exactly the set of request +types carrying a `password` field, and that the fingerprint-targeted subset is exactly the gated +request types that also carry a `fingerprint` field. Adding a signing endpoint without gating it +fails the build; adding a fingerprint to a gated request type without listing it fails the test. ### Bridge path (apps) @@ -128,11 +154,16 @@ app exactly as today; the password is then collected in the main webview. In `process_after_approval` (`crates/sage-apps/src/bridge/bridge_request.rs:83`), after `approved == true` and the wallet-binding check passes, and before `process_shared`: -1. Hide the `bridge-approval` runtime via `hide_runtime_inner` so it does not cover the main webview. - App runtimes are sibling webviews inside the same `main` window (`runtime/manager.rs:395-414`), so - a React dialog would otherwise render underneath. -2. Call the gate. -3. Execute. +1. If the target wallet is password-protected, hide the `bridge-approval` runtime via + `hide_runtime_inner` so it does not cover the main webview. App runtimes are sibling webviews + inside the same `main` window (`runtime/manager.rs:395-414`), so a React dialog would otherwise + render underneath. On an unprotected wallet no dialog can appear, and hiding then re-syncing the + runtime would be a visible flicker for nothing, so the hide/restore pair is skipped — the gate + call itself still runs, because the frontend may still put a biometric gate in front of it. +2. Call the gate — `resolve_for_fingerprint` for `GetSecretKey`, which names its own wallet, and + `resolve` for the bodies that act on the active wallet. +3. Restore runtime visibility if step 1 hid it, on every exit path from the password phase. +4. Execute. Because approval and password are now two phases, the original `expires_at_ms` keeps running through the prompt. A lapse mid-prompt fails with `approval_timeout`. One clock, no new concept, and an app diff --git a/src/bindings.ts b/src/bindings.ts index 5dfba9ac8..2b3277953 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -2264,9 +2264,19 @@ export type PasswordOutcome = */ { kind: "cancelled" } /** - * Emitted to the `main` webview only. Never broadcast. - */ -export type PasswordRequest = { requestId: string; fingerprint: number; + * The password prompt as it crosses to JavaScript. + * + * Emission targets the `main` webview (see `prompter::SAGE_WEBVIEW_LABEL`), + * but that is **not** an isolation guarantee: Tauri's event filter + * short-circuits for listeners registered with `EventTarget::Any`, and + * `src-tauri/capabilities/apps.json` grants app webviews + * `core:event:allow-listen`. Any app runtime can therefore observe this + * payload. It deliberately carries nothing sensitive -- no fingerprint, no + * wallet identity, and of course no password. The password itself only ever + * travels the other way, through the `submit_password_response` command, + * which is not granted to app webviews. + */ +export type PasswordRequest = { requestId: string; /** * Advisory: the wallet's stored `password_protected` flag. The frontend * still decides between password dialog, biometric gate, and no auth, @@ -2276,7 +2286,14 @@ requiresPassword: boolean; /** * 1-based. Increments on each incorrect-password re-prompt. */ -attempt: number; error: PasswordAttemptError | null } +attempt: number; +/** + * Retained despite being observable by app webviews: the dialog needs it + * to show "N attempts remaining", and a bare retry counter identifies no + * wallet and reveals nothing an observer could not already infer from the + * re-prompts themselves. + */ +error: PasswordAttemptError | null } export type PeerRecord = { ip_addr: string; port: number; peak_height: number; user_managed: boolean } export type PendingTransactionRecord = { transaction_id: string; fee: Amount; submitted_at: number | null; spent: TransactionCoinRecord[]; created: TransactionCoinRecord[] } /** From 39d178997da5873554c1c7b86ee90da89e1d009a Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 20:33:20 -0500 Subject: [PATCH 25/36] fix(password-gate): reuse one request id across retry attempts `resolve_with` minted a fresh `Uuid::new_v4()` per attempt, so `PasswordContext`'s replace-in-place branch -- which looks a queued request up by `requestId` -- could never match. One id for the whole resolve makes that branch live, and a retry now resumes at the front of the queue instead of appending behind a concurrent request. Each attempt registers its own oneshot under the id; the previous entry is always gone by then, consumed by `deliver` or removed on timeout. Covered by a resolve-level test that all attempts share an id, and a gate-state test that re-registering a consumed id delivers to the new receiver. Co-Authored-By: Claude Opus 5 --- crates/sage-password-gate/src/resolve.rs | 28 ++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/sage-password-gate/src/resolve.rs b/crates/sage-password-gate/src/resolve.rs index 99a135966..f63768b86 100644 --- a/crates/sage-password-gate/src/resolve.rs +++ b/crates/sage-password-gate/src/resolve.rs @@ -41,10 +41,17 @@ pub async fn resolve_with( ) -> Result> { let mut error: Option = None; + // One id for the whole resolve, not one per attempt: the frontend queues + // requests by id and replaces a queued entry on a re-prompt, so a fresh id + // per attempt would append the retry behind any concurrent request instead + // of resuming the dialog in place. Each attempt registers its own oneshot + // under this id, and the previous attempt's entry is always gone by then -- + // consumed by `deliver`, or removed by the timeout path. + let request_id = uuid::Uuid::new_v4().to_string(); + for attempt in 1..=MAX_ATTEMPTS { let request = PasswordRequest { - request_id: uuid::Uuid::new_v4().to_string(), - fingerprint, + request_id: request_id.clone(), requires_password, attempt, error: error.take(), @@ -182,6 +189,23 @@ mod tests { assert_eq!(seen[1].error.as_ref().unwrap().attempts_remaining, 2); } + #[tokio::test] + async fn every_attempt_of_one_resolve_shares_a_request_id() { + let (verifier, fingerprint) = protected_keychain("hunter2"); + let prompter = MockPrompter::new(vec![pw("wrong"), pw("also wrong"), pw("hunter2")]); + + let result = resolve_with(&prompter, &verifier, fingerprint, true) + .await + .unwrap(); + + assert_eq!(result, Some("hunter2".to_string())); + let seen = prompter.seen(); + assert_eq!(seen.len(), 3); + assert_eq!(seen[0].request_id, seen[1].request_id); + assert_eq!(seen[1].request_id, seen[2].request_id); + assert!(!seen[0].request_id.is_empty()); + } + #[tokio::test] async fn three_wrong_attempts_fails_unauthorized() { let (verifier, fingerprint) = protected_keychain("hunter2"); From 2fa0132dfce35b1f1324b0be51a1a95d0b4281d8 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 20:33:20 -0500 Subject: [PATCH 26/36] fix(i18n): translate the user-facing password gate errors The two gate failures a user actually sees -- too many attempts, prompt timed out -- were echoed straight from the Rust reason string, so they appeared untranslated unlike every other user-facing string. The reason strings stay as the discriminator; the toast now renders through `t`, matching the `incorrect_password` branch above. Co-Authored-By: Claude Opus 5 --- src/contexts/ErrorContext.tsx | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/contexts/ErrorContext.tsx b/src/contexts/ErrorContext.tsx index fb8235e50..b383d0c59 100644 --- a/src/contexts/ErrorContext.tsx +++ b/src/contexts/ErrorContext.tsx @@ -33,13 +33,15 @@ export const ErrorContext = createContext( // silently comes back. const PASSWORD_CANCELLED_REASON = 'Password entry cancelled'; -// Must match the reason returned at crates/sage-password-gate/src/resolve.rs:64 -// when the user exhausts MAX_ATTEMPTS incorrect password attempts. +// Must match the reason returned by `resolve_with` in +// crates/sage-password-gate/src/resolve.rs when the user exhausts +// MAX_ATTEMPTS incorrect password attempts. const PASSWORD_TOO_MANY_ATTEMPTS_REASON = 'Too many incorrect password attempts'; -// Must match the reason returned at crates/sage-password-gate/src/lib.rs:92 -// when the password prompt is not answered within PROMPT_TIMEOUT. +// Must match the reason returned by `PasswordGateState::await_outcome` in +// crates/sage-password-gate/src/lib.rs when the password prompt is not +// answered within PROMPT_TIMEOUT. const PASSWORD_PROMPT_TIMED_OUT_REASON = 'Password prompt timed out'; export function ErrorProvider({ children }: { children: ReactNode }) { @@ -63,14 +65,19 @@ export function ErrorProvider({ children }: { children: ReactNode }) { } if (error.kind === 'unauthorized') { const reason = error.reason ?? ''; - if ( - reason.includes('not found') || - reason.includes('No secret') || - reason === PASSWORD_TOO_MANY_ATTEMPTS_REASON || - reason === PASSWORD_PROMPT_TIMED_OUT_REASON - ) { - // KeyNotFound / NoSecretKey (wallet-level issue, not a transition), - // or a genuine password-gate failure (too many attempts / timeout). + // The two password-gate failures are matched on the Rust reason string + // and then rendered as translated text, rather than echoing the raw + // English reason the way the wallet-level cases below do. + if (reason === PASSWORD_TOO_MANY_ATTEMPTS_REASON) { + toast.error(t`Too many incorrect password attempts`); + return; + } + if (reason === PASSWORD_PROMPT_TIMED_OUT_REASON) { + toast.error(t`Password prompt timed out`); + return; + } + if (reason.includes('not found') || reason.includes('No secret')) { + // KeyNotFound / NoSecretKey: a wallet-level issue, not a transition. toast.error(error.reason); } // NotLoggedIn / NoSigningKey during wallet transitions are silently ignored From 849f03415a51423182a8675c7fd0002d33c0fc87 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Fri, 21 Aug 2026 20:33:20 -0500 Subject: [PATCH 27/36] fix(i18n): pluralize the attempts-remaining message "1 attempts remaining" on the last try. Uses lingui's ``. Co-Authored-By: Claude Opus 5 --- src/components/dialogs/PasswordDialog.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/dialogs/PasswordDialog.tsx b/src/components/dialogs/PasswordDialog.tsx index 56d48fbbf..aae57e3f0 100644 --- a/src/components/dialogs/PasswordDialog.tsx +++ b/src/components/dialogs/PasswordDialog.tsx @@ -1,5 +1,5 @@ import { t } from '@lingui/core/macro'; -import { Trans } from '@lingui/react/macro'; +import { Plural, Trans } from '@lingui/react/macro'; import { KeyRoundIcon } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from '../ui/button'; @@ -63,9 +63,12 @@ export function PasswordDialog({ {attemptsRemaining !== undefined && (

- - Incorrect password. {attemptsRemaining} attempts remaining. - + Incorrect password.{' '} +

)} Date: Fri, 21 Aug 2026 20:41:11 -0500 Subject: [PATCH 28/36] docs(password-gate): add manual smoke test checklist Co-Authored-By: Claude Opus 5 --- .../2026-08-21-password-gate-smoke-tests.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-21-password-gate-smoke-tests.md diff --git a/docs/superpowers/specs/2026-08-21-password-gate-smoke-tests.md b/docs/superpowers/specs/2026-08-21-password-gate-smoke-tests.md new file mode 100644 index 000000000..e9e7731eb --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-password-gate-smoke-tests.md @@ -0,0 +1,86 @@ +# Password Gate — Manual Smoke Test Checklist + +**Branch:** `password-gate` +**Status:** outstanding — no automated coverage exists for these paths + +Every automated gate on this branch passes: workspace build (warning-free), full test +suite, the `sage-rpc` canary, clippy `-D warnings`, `cargo fmt`, eslint, prettier, and +the frontend build. What follows cannot be automated: the repository has no frontend +test runner, and these paths need a human at a running app. + +Launch with: + +```bash +export SDKROOT="$(xcrun --show-sdk-path)" +pnpm run tauri:dev +``` + +## 1. Main window, protected wallet + +- [ ] Send a small amount of XCH. The password dialog appears. +- [ ] Enter the wrong password twice. It re-prompts each time, showing the remaining + attempt count, and reads "1 attempt remaining" (singular) on the last try. +- [ ] Enter the correct password. The transaction proceeds. +- [ ] Repeat with three wrong passwords. A toast reads "Too many incorrect password + attempts" and the dialog closes. **It must not fail silently.** +- [ ] Start a send and dismiss the dialog. No error toast appears and nothing is submitted. + +## 2. Main window, unprotected wallet + +- [ ] Send XCH. No dialog appears at all. +- [ ] Mobile only, biometrics enabled: the biometric gate appears, and a second + operation within five minutes does not re-prompt. + +## 3. Logged-out wallet list — the regression the final review caught + +These two broke completely and silently at one point; they are the highest-value checks here. + +- [ ] Log out. On the wallet list, open "Wallet details" on an **unprotected** wallet. + It must work. +- [ ] Same, on a **protected** wallet — including one that is not the last-active + wallet. It must prompt for **that wallet's** password, not another's. +- [ ] Delete a protected wallet from the logged-out list. Same expectation. +- [ ] While logged in to wallet A, delete or inspect protected wallet B. The prompt + must name B and accept B's password. + +## 4. App bridge, protected wallet + +- [ ] Launch an app that calls `wallet.sendXch`. The `bridge-approval` app shows the + transaction summary. +- [ ] Approving hides that app and reveals the password dialog **in the main window** — + not inside the app's webview. +- [ ] The correct password completes the request; the app receives success. +- [ ] Cancelling the dialog fails the request; the app receives an error. +- [ ] Grant the app `WalletSendXchAutoSubmit` and repeat: an approval **still** appears, + because the wallet is protected. +- [ ] Queue two approvals and answer the first. The second must remain reachable — + the approval window must not be left hidden. + +## 5. App bridge, unprotected wallet + +- [ ] With the auto-submit grant, send XCH: no approval appears. +- [ ] Approve a `wallet.getSecretKey` request: the approval window must **not** flicker + away and back for a prompt that never comes. + +## 6. WalletConnect + +- [ ] Pair a dApp against a protected wallet. A signing request prompts for the password + in the main window and completes. + +## 7. Timeout + +- [ ] Start a gated operation and leave the prompt untouched for five minutes. A toast + reads "Password prompt timed out". A late Submit or Cancel must not throw an + unhandled rejection in the console. + +## 8. Trust boundary (optional, verifies a known limitation) + +The `password-request` event targets the `main` webview, but Tauri's listener matching +short-circuits for `EventTarget::Any` listeners, so an app webview holding +`core:event:allow-listen` can observe it. The payload was reduced to carry nothing that +identifies a wallet, and the password itself only ever returns via +`submit_password_response`, which is ACL-denied to app webviews. + +- [ ] In an installed app's devtools, listen for `password-request` and trigger a gated + operation. Confirm the payload contains **no** `fingerprint` — only `requestId`, + `requiresPassword`, `attempt`, and `error`. From 29ec0679e8a968620998b731604a7c2652c09711 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sat, 22 Aug 2026 07:51:38 -0500 Subject: [PATCH 29/36] fix(password-gate): prompt once per send, not twice Endpoints routed through Sage::transact/transact_with only use the password when req.auto_submit is set. The Send page builds the transaction with auto_submit unset, so the gate collected a password that was then discarded, and prompted again after the confirmation dialog. Replace password-gated.json and password-gated-fingerprint.json with a single password-gating.json mapping each endpoint to always | auto_submit | fingerprint. Carrying an auto_submit field is not the criterion -- sign_coin_spends and take_offer have one but reach the keychain on every call. A new drift test enforces the criterion that matters by scanning the sage crate's endpoint implementations: a body calling extract_secrets or self.sign must not be auto_submit, and one that only forwards to transact must be. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 1 + crates/sage-api/macro/Cargo.toml | 1 + crates/sage-api/macro/src/lib.rs | 87 ++++--- .../sage-api/password-gated-fingerprint.json | 4 - crates/sage-api/password-gated.json | 34 --- crates/sage-api/password-gating.json | 34 +++ crates/sage-api/src/lib.rs | 244 +++++++++++++++--- .../specs/2026-08-21-password-gate-design.md | 28 +- .../2026-08-21-password-gate-smoke-tests.md | 7 +- 9 files changed, 327 insertions(+), 113 deletions(-) delete mode 100644 crates/sage-api/password-gated-fingerprint.json delete mode 100644 crates/sage-api/password-gated.json create mode 100644 crates/sage-api/password-gating.json diff --git a/Cargo.lock b/Cargo.lock index 167045a0b..06b86180c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6535,6 +6535,7 @@ dependencies = [ "indexmap 2.12.1", "proc-macro2", "quote", + "serde", "serde_json", "syn 2.0.111", ] diff --git a/crates/sage-api/macro/Cargo.toml b/crates/sage-api/macro/Cargo.toml index a34e0f891..9936eb8fb 100644 --- a/crates/sage-api/macro/Cargo.toml +++ b/crates/sage-api/macro/Cargo.toml @@ -20,6 +20,7 @@ proc-macro = true [dependencies] quote = { workspace = true } convert_case = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } indexmap = { workspace = true, features = ["serde"] } proc-macro2 = { workspace = true } diff --git a/crates/sage-api/macro/src/lib.rs b/crates/sage-api/macro/src/lib.rs index 1f6c7fdb0..da2b47502 100644 --- a/crates/sage-api/macro/src/lib.rs +++ b/crates/sage-api/macro/src/lib.rs @@ -80,38 +80,42 @@ fn generate(input: &TokenStream, tauri: bool) -> TokenStream { endpoints.extend(tauri_endpoints); } - let password_gated: std::collections::BTreeSet = - serde_json::from_str(include_str!("../../password-gated.json")) - .expect("Invalid password-gated endpoint file"); - - // The subset of the gated endpoints whose request type carries a - // `fingerprint` field. These act on a *named* wallet rather than the - // active one, so the gate must verify against that wallet. - let fingerprint_gated: std::collections::BTreeSet = - serde_json::from_str(include_str!("../../password-gated-fingerprint.json")) - .expect("Invalid fingerprint-gated endpoint file"); - - let gating = Gating { - password_gated: &password_gated, - fingerprint_gated: &fingerprint_gated, - }; + // How the host-layer password gate treats each endpoint. Endpoints absent + // from this map take no password at all. `sage-api`'s drift tests keep the + // manifest in sync with the request types and with how each endpoint's + // implementation consumes the password. + let gating: std::collections::BTreeMap = + serde_json::from_str(include_str!("../../password-gating.json")) + .expect("Invalid password gating file"); let mut output = proc_macro2::TokenStream::new(); for token in input.clone() { - convert(token, &endpoints, gating, None, &mut output); + convert(token, &endpoints, &gating, None, &mut output); } output.into() } -/// Which endpoints the password gate applies to, and how. -#[derive(Clone, Copy)] -struct Gating<'a> { - password_gated: &'a std::collections::BTreeSet, - fingerprint_gated: &'a std::collections::BTreeSet, +/// How the host-layer password gate treats an endpoint. +#[derive(Clone, Copy, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +enum GateMode { + /// Prompt on every call, verifying against the active wallet. + Always, + /// Prompt only when `req.auto_submit` is set. These endpoints build a + /// transaction for confirmation first and reach no secret until the caller + /// asks for it to be signed and submitted, so prompting unconditionally + /// would collect a password that is then discarded. + AutoSubmit, + /// Prompt on every call, verifying against `req.fingerprint`. These act on + /// a *named* wallet rather than the active one -- and may run with no + /// wallet active at all, from the logged-out wallet list. + Fingerprint, } +type Gating<'a> = &'a std::collections::BTreeMap; + fn convert( tree: TokenTree, endpoints: &IndexMap, @@ -140,17 +144,36 @@ fn convert( output.extend(quote!(.await)); } } else if ident == "maybe_unlock" { - if gating.fingerprint_gated.contains(endpoint) { - // Acts on `req.fingerprint`, which may be a wallet other - // than the active one -- and may run with no wallet - // active at all, from the logged-out wallet list. - output.extend(quote!( - req.password = sage_password_gate::resolve_for_fingerprint(&app_handle, state.inner(), gate.inner(), req.fingerprint).await?; - )); - } else if gating.password_gated.contains(endpoint) { - output.extend(quote!( - req.password = sage_password_gate::resolve(&app_handle, state.inner(), gate.inner()).await?; - )); + match gating.get(endpoint) { + Some(GateMode::Fingerprint) => { + output.extend(quote!( + req.password = sage_password_gate::resolve_for_fingerprint(&app_handle, state.inner(), gate.inner(), req.fingerprint).await?; + )); + } + Some(GateMode::Always) => { + output.extend(quote!( + req.password = sage_password_gate::resolve(&app_handle, state.inner(), gate.inner()).await?; + )); + } + Some(GateMode::AutoSubmit) => { + // Without `auto_submit` the endpoint only builds the + // transaction for the confirmation dialog and never + // touches the key, so prompting here would ask twice. + // The `else` keeps the host layer the sole source of + // the password: every gated command overwrites whatever + // the caller sent, on both branches. + output.extend(quote!(if req.auto_submit { + req.password = sage_password_gate::resolve( + &app_handle, + state.inner(), + gate.inner(), + ) + .await?; + } else { + req.password = None; + })); + } + None => {} } } else if ident.is_case(Case::Snake) { let ident = proc_macro2::Ident::new( diff --git a/crates/sage-api/password-gated-fingerprint.json b/crates/sage-api/password-gated-fingerprint.json deleted file mode 100644 index a1e7d0d4f..000000000 --- a/crates/sage-api/password-gated-fingerprint.json +++ /dev/null @@ -1,4 +0,0 @@ -[ - "delete_key", - "get_secret_key" -] diff --git a/crates/sage-api/password-gated.json b/crates/sage-api/password-gated.json deleted file mode 100644 index 7f95db820..000000000 --- a/crates/sage-api/password-gated.json +++ /dev/null @@ -1,34 +0,0 @@ -[ - "add_nft_uri", - "assign_nfts_to_did", - "auto_combine_cat", - "auto_combine_xch", - "bulk_mint_nfts", - "bulk_send_cat", - "bulk_send_xch", - "cancel_offer", - "cancel_offers", - "combine", - "create_did", - "create_transaction", - "delete_key", - "exercise_options", - "finalize_clawback", - "get_secret_key", - "increase_derivation_index", - "issue_cat", - "make_offer", - "mint_option", - "multi_send", - "normalize_dids", - "send_cat", - "send_xch", - "sign_coin_spends", - "sign_message_by_address", - "sign_message_with_public_key", - "split", - "take_offer", - "transfer_dids", - "transfer_nfts", - "transfer_options" -] diff --git a/crates/sage-api/password-gating.json b/crates/sage-api/password-gating.json new file mode 100644 index 000000000..13ef6b4a2 --- /dev/null +++ b/crates/sage-api/password-gating.json @@ -0,0 +1,34 @@ +{ + "add_nft_uri": "auto_submit", + "assign_nfts_to_did": "auto_submit", + "auto_combine_cat": "auto_submit", + "auto_combine_xch": "auto_submit", + "bulk_mint_nfts": "auto_submit", + "bulk_send_cat": "auto_submit", + "bulk_send_xch": "auto_submit", + "cancel_offer": "auto_submit", + "cancel_offers": "auto_submit", + "combine": "auto_submit", + "create_did": "auto_submit", + "create_transaction": "auto_submit", + "delete_key": "fingerprint", + "exercise_options": "auto_submit", + "finalize_clawback": "auto_submit", + "get_secret_key": "fingerprint", + "increase_derivation_index": "always", + "issue_cat": "auto_submit", + "make_offer": "always", + "mint_option": "auto_submit", + "multi_send": "auto_submit", + "normalize_dids": "auto_submit", + "send_cat": "auto_submit", + "send_xch": "auto_submit", + "sign_coin_spends": "always", + "sign_message_by_address": "always", + "sign_message_with_public_key": "always", + "split": "auto_submit", + "take_offer": "always", + "transfer_dids": "auto_submit", + "transfer_nfts": "auto_submit", + "transfer_options": "auto_submit" +} diff --git a/crates/sage-api/src/lib.rs b/crates/sage-api/src/lib.rs index f05112ec1..3a75e46c1 100644 --- a/crates/sage-api/src/lib.rs +++ b/crates/sage-api/src/lib.rs @@ -22,13 +22,71 @@ pub use sage_api_macro::openapi as openapi_attr; mod password_gate_drift { use std::collections::{BTreeMap, BTreeSet}; + /// The three ways the host-layer gate can treat an endpoint. Mirrors the + /// enum the macro deserialises `password-gating.json` into. + #[derive(Clone, Copy, PartialEq, Eq, Debug)] + enum GateMode { + /// Prompt every call, verifying against the active wallet. + Always, + /// Prompt only when `req.auto_submit` is set. These endpoints build a + /// transaction for confirmation first and touch no secret until the + /// caller asks for it to be signed and submitted. + AutoSubmit, + /// Prompt every call, verifying against `req.fingerprint` rather than + /// the active wallet. + Fingerprint, + } + + /// Reads the single gating manifest, rejecting any mode string the macro + /// would not understand. + fn gate_modes() -> BTreeMap { + let raw: BTreeMap = + serde_json::from_str(include_str!("../password-gating.json")).unwrap(); + + raw.into_iter() + .map(|(endpoint, mode)| { + let mode = match mode.as_str() { + "always" => GateMode::Always, + "auto_submit" => GateMode::AutoSubmit, + "fingerprint" => GateMode::Fingerprint, + other => panic!( + "password-gating.json: unknown mode {other:?} for {endpoint:?}; \ + expected \"always\", \"auto_submit\", or \"fingerprint\"", + ), + }; + (endpoint, mode) + }) + .collect() + } + + fn endpoints_with_mode(mode: GateMode) -> BTreeSet { + gate_modes() + .into_iter() + .filter(|(_, actual)| *actual == mode) + .map(|(endpoint, _)| endpoint) + .collect() + } + /// Reads every request source file so the scanners below see the same - /// text the macro's JSON manifests are supposed to describe. + /// text the macro's JSON manifest is supposed to describe. fn request_sources() -> Vec { let requests_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/requests"); - let entries = std::fs::read_dir(&requests_dir).unwrap_or_else(|error| { - panic!("failed to read request sources at {requests_dir:?}: {error}") - }); + read_rust_sources(&requests_dir, 6) + } + + /// Reads the `sage` crate's endpoint implementations. The gate lives in the + /// Tauri host layer, but the mode each endpoint needs is a property of how + /// its implementation consumes the password, so the invariant can only be + /// checked against that source. + fn endpoint_implementation_sources() -> Vec { + let endpoints_dir = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../sage/src/endpoints"); + read_rust_sources(&endpoints_dir, 4) + } + + fn read_rust_sources(dir: &std::path::Path, minimum: usize) -> Vec { + let entries = std::fs::read_dir(dir) + .unwrap_or_else(|error| panic!("failed to read sources at {dir:?}: {error}")); let mut sources = Vec::new(); for entry in entries { @@ -43,32 +101,46 @@ mod password_gate_drift { } assert!( - sources.len() >= 6, - "expected at least 6 request source files under {requests_dir:?}, found {}", + sources.len() >= minimum, + "expected at least {minimum} Rust source files under {dir:?}, found {}", sources.len(), ); sources } - /// The fingerprint-bearing subset must match, exactly, the gated request - /// types that also carry a `fingerprint` field. If this fails the macro - /// either prompts for the active wallet while acting on a named one (wrong - /// password handed to the keychain, and a hard failure when logged out), or - /// reads a `req.fingerprint` that does not exist (a compile error). + /// The manifest's keys must match, exactly, the request types carrying a + /// `password` field. If this fails you either added a signing endpoint + /// without gating it (a security hole) or gated one that takes no + /// password (a spurious prompt). #[test] - fn fingerprint_gated_set_matches_request_types_with_fingerprint_field() { - let fingerprint_gated: BTreeSet = - serde_json::from_str(include_str!("../password-gated-fingerprint.json")).unwrap(); - let gated: BTreeSet = - serde_json::from_str(include_str!("../password-gated.json")).unwrap(); + fn gating_map_matches_request_types_with_password_field() { + let gated: BTreeSet = gate_modes().into_keys().collect(); - assert!( - fingerprint_gated.is_subset(&gated), - "password-gated-fingerprint.json must be a subset of password-gated.json; \ - extra entries: {:?}", - fingerprint_gated.difference(&gated).collect::>(), + let mut discovered = BTreeSet::new(); + for source in &request_sources() { + discovered.extend(password_structs(source).into_keys()); + } + + assert_eq!( + gated, + discovered, + "password-gating.json is out of sync with the request types.\n\ + Only in password-gating.json: {:?}\n\ + Only in request types: {:?}", + gated.difference(&discovered).collect::>(), + discovered.difference(&gated).collect::>(), ); + } + + /// The `fingerprint` mode must match, exactly, the gated request types that + /// carry a `fingerprint` field. If this fails the macro either prompts for + /// the active wallet while acting on a named one (wrong password handed to + /// the keychain, and a hard failure when logged out), or reads a + /// `req.fingerprint` that does not exist (a compile error). + #[test] + fn fingerprint_mode_matches_request_types_with_fingerprint_field() { + let fingerprint_gated = endpoints_with_mode(GateMode::Fingerprint); let mut discovered = BTreeSet::new(); for source in &request_sources() { @@ -82,8 +154,9 @@ mod password_gate_drift { assert_eq!( fingerprint_gated, discovered, - "password-gated-fingerprint.json is out of sync with the request types.\n\ - Only in password-gated-fingerprint.json: {:?}\n\ + "the \"fingerprint\" entries in password-gating.json are out of sync with the \ + request types.\n\ + Only in password-gating.json: {:?}\n\ Only in request types: {:?}", fingerprint_gated .difference(&discovered) @@ -94,31 +167,124 @@ mod password_gate_drift { ); } - /// The gated set must match, exactly, the request types carrying a - /// `password` field. If this fails you either added a signing endpoint - /// without gating it (a security hole) or gated one that takes no - /// password (a spurious prompt). + /// `auto_submit` mode expands to `if req.auto_submit { ... }`, so the field + /// has to exist. Without this check a mis-filed endpoint is a compile error + /// in generated code, which points at the macro rather than the manifest. #[test] - fn gated_set_matches_request_types_with_password_field() { - let gated: BTreeSet = - serde_json::from_str(include_str!("../password-gated.json")).unwrap(); + fn auto_submit_mode_entries_have_an_auto_submit_field() { + let auto_submit = endpoints_with_mode(GateMode::AutoSubmit); let mut discovered = BTreeSet::new(); for source in &request_sources() { - discovered.extend(password_structs(source).into_keys()); + for (name, _) in password_structs(source) { + if struct_has_auto_submit(source, &name) { + discovered.insert(name); + } + } } - assert_eq!( - gated, - discovered, - "password-gated.json is out of sync with the request types.\n\ - Only in password-gated.json: {:?}\n\ - Only in request types: {:?}", - gated.difference(&discovered).collect::>(), - discovered.difference(&gated).collect::>(), + let missing: Vec<_> = auto_submit.difference(&discovered).collect(); + assert!( + missing.is_empty(), + "these endpoints are marked \"auto_submit\" in password-gating.json but their \ + request types have no `auto_submit` field: {missing:?}", ); } + /// The mode has to match how the endpoint actually consumes the password. + /// An endpoint that reaches the keychain directly (`extract_secrets`, or + /// `self.sign`) needs the password on every call; one that only forwards it + /// to `Sage::transact`/`transact_with` uses it solely when `auto_submit` is + /// set, and prompting unconditionally there means asking the user for a + /// password that gets discarded -- once to build the transaction, and again + /// after they confirm it. + #[test] + fn gate_modes_match_how_endpoints_consume_the_password() { + let sources = endpoint_implementation_sources(); + + for (endpoint, mode) in gate_modes() { + let Some(body) = endpoint_body(&sources, &endpoint) else { + panic!("no `fn {endpoint}` found in the sage crate's endpoint implementations"); + }; + + let signs_directly = body.contains("extract_secrets") || body.contains("self.sign("); + let expected_conditional = !signs_directly; + let marked_conditional = mode == GateMode::AutoSubmit; + + assert_eq!( + marked_conditional, + expected_conditional, + "password-gating.json marks `{endpoint}` as {mode:?}, but its implementation \ + {}. Endpoints that reach a secret directly must be \"always\" (or \ + \"fingerprint\"); endpoints that only forward the password to \ + `transact`/`transact_with` must be \"auto_submit\".", + if signs_directly { + "reaches the keychain on every call" + } else { + "only uses the password when `auto_submit` is set" + }, + ); + } + } + + /// Extracts the body of `fn (` from the endpoint implementations, + /// up to the closing brace at method indentation. + fn endpoint_body(sources: &[String], endpoint: &str) -> Option { + let needle = format!("fn {endpoint}("); + + for source in sources { + let lines: Vec<&str> = source.lines().collect(); + let Some(start) = lines.iter().position(|line| { + let trimmed = line.trim_start(); + (trimmed.starts_with("pub fn ") + || trimmed.starts_with("pub async fn ") + || trimmed.starts_with("pub(crate) fn ") + || trimmed.starts_with("pub(crate) async fn ")) + && trimmed.contains(&needle) + }) else { + continue; + }; + + let mut body = String::new(); + for line in &lines[start..] { + body.push_str(line); + body.push('\n'); + if *line == " }" { + break; + } + } + return Some(body); + } + + None + } + + /// Whether the `pub struct` for a `snake_case` endpoint name carries a + /// `pub auto_submit: bool` field. + fn struct_has_auto_submit(source: &str, endpoint: &str) -> bool { + let pascal: String = endpoint + .split('_') + .map(|word| { + let mut characters = word.chars(); + match characters.next() { + Some(first) => first.to_uppercase().collect::() + characters.as_str(), + None => String::new(), + } + }) + .collect(); + + let header = format!("pub struct {pascal} {{"); + let lines: Vec<&str> = source.lines().collect(); + let Some(start) = lines.iter().position(|line| line.trim_end() == header) else { + return false; + }; + + lines[start + 1..] + .iter() + .take_while(|line| **line != "}") + .any(|line| line.trim() == "pub auto_submit: bool,") + } + /// Scans Rust source for `pub struct Name {` blocks containing a /// `pub password: Option` field, returning `snake_case` names /// mapped to whether the same struct also carries a `fingerprint` field. diff --git a/docs/superpowers/specs/2026-08-21-password-gate-design.md b/docs/superpowers/specs/2026-08-21-password-gate-design.md index bab2860a8..4d1deb0fc 100644 --- a/docs/superpowers/specs/2026-08-21-password-gate-design.md +++ b/docs/superpowers/specs/2026-08-21-password-gate-design.md @@ -70,8 +70,32 @@ active one, and are driven from the logged-out wallet list where there is no act `resolve` would both prompt for the wrong wallet's password and fail with `NotLoggedIn`, so those endpoints use the fingerprint-targeted form. The `password_protected` lookup searches `wallet_config.wallets` by fingerprint and needs no active wallet, so this path never calls -`Sage::wallet()`. The macro picks the form from `crates/sage-api/password-gated-fingerprint.json`, -a subset of `password-gated.json` kept honest by a drift test in `crates/sage-api/src/lib.rs`. +`Sage::wallet()`. + +### Gating manifest + +`crates/sage-api/password-gating.json` maps each password-bearing endpoint to one of three modes, +and the macro's `maybe_unlock` expands accordingly: + +| Mode | Expansion | Applies to | +| --- | --- | --- | +| `always` | `resolve(...)` on every call | endpoints that reach a secret unconditionally | +| `fingerprint` | `resolve_for_fingerprint(..., req.fingerprint)` on every call | `delete_key`, `get_secret_key` | +| `auto_submit` | `resolve(...)` only when `req.auto_submit` is set, `req.password = None` otherwise | endpoints that only forward the password to `Sage::transact`/`transact_with` | + +The `auto_submit` mode exists because those endpoints build a transaction for the confirmation +dialog first and touch no key until the caller asks for it to be signed and submitted. Prompting +unconditionally there asks the user for a password that is then discarded, and then asks again after +they confirm — two dialogs for one send. + +Note that carrying an `auto_submit` field is *not* the criterion: `sign_coin_spends` and `take_offer` +both have one but reach the keychain on every call, so both are `always`. The criterion is how the +implementation consumes the password, and a drift test in `crates/sage-api/src/lib.rs` enforces +exactly that by scanning `crates/sage/src/endpoints/` — an endpoint whose body calls +`extract_secrets` or `self.sign` must not be `auto_submit`, and one that only forwards to +`transact`/`transact_with` must be. Sibling tests keep the manifest's key set equal to the set of +request types carrying a `password` field, keep `fingerprint` equal to those carrying a +`fingerprint` field, and reject unknown mode strings. ### Transport diff --git a/docs/superpowers/specs/2026-08-21-password-gate-smoke-tests.md b/docs/superpowers/specs/2026-08-21-password-gate-smoke-tests.md index e9e7731eb..bfa9d4a2a 100644 --- a/docs/superpowers/specs/2026-08-21-password-gate-smoke-tests.md +++ b/docs/superpowers/specs/2026-08-21-password-gate-smoke-tests.md @@ -17,7 +17,10 @@ pnpm run tauri:dev ## 1. Main window, protected wallet -- [ ] Send a small amount of XCH. The password dialog appears. +- [ ] Send a small amount of XCH. **No dialog appears while the transaction is being + built** — the password dialog comes up only after Submit on the confirmation + dialog, and exactly once. Two dialogs for one send means an endpoint is filed + as `always` in `password-gating.json` when it should be `auto_submit`. - [ ] Enter the wrong password twice. It re-prompts each time, showing the remaining attempt count, and reads "1 attempt remaining" (singular) on the last try. - [ ] Enter the correct password. The transaction proceeds. @@ -27,7 +30,7 @@ pnpm run tauri:dev ## 2. Main window, unprotected wallet -- [ ] Send XCH. No dialog appears at all. +- [ ] Send XCH. No dialog appears at all, at either stage. - [ ] Mobile only, biometrics enabled: the biometric gate appears, and a second operation within five minutes does not re-prompt. From a750ae4b9f6d66888b517be921aa4950fec954df Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sat, 22 Aug 2026 10:55:55 -0500 Subject: [PATCH 30/36] move generated docs --- .../2026-03-15-password-protection-design.md | 0 .../2026-03-16-protection-matrix.md | 0 .../2026-08-21-password-gate-design.md | 15 +- .../plans/2026-08-21-password-gate.md | 1905 ----------------- .../2026-08-21-password-gate-smoke-tests.md | 89 - 5 files changed, 8 insertions(+), 2001 deletions(-) rename docs/{superpowers/specs => generated}/2026-03-15-password-protection-design.md (100%) rename docs/{superpowers/specs => generated}/2026-03-16-protection-matrix.md (100%) rename docs/{superpowers/specs => generated}/2026-08-21-password-gate-design.md (94%) delete mode 100644 docs/superpowers/plans/2026-08-21-password-gate.md delete mode 100644 docs/superpowers/specs/2026-08-21-password-gate-smoke-tests.md diff --git a/docs/superpowers/specs/2026-03-15-password-protection-design.md b/docs/generated/2026-03-15-password-protection-design.md similarity index 100% rename from docs/superpowers/specs/2026-03-15-password-protection-design.md rename to docs/generated/2026-03-15-password-protection-design.md diff --git a/docs/superpowers/specs/2026-03-16-protection-matrix.md b/docs/generated/2026-03-16-protection-matrix.md similarity index 100% rename from docs/superpowers/specs/2026-03-16-protection-matrix.md rename to docs/generated/2026-03-16-protection-matrix.md diff --git a/docs/superpowers/specs/2026-08-21-password-gate-design.md b/docs/generated/2026-08-21-password-gate-design.md similarity index 94% rename from docs/superpowers/specs/2026-08-21-password-gate-design.md rename to docs/generated/2026-08-21-password-gate-design.md index 4d1deb0fc..7e3782c25 100644 --- a/docs/superpowers/specs/2026-08-21-password-gate-design.md +++ b/docs/generated/2026-08-21-password-gate-design.md @@ -77,10 +77,10 @@ endpoints use the fingerprint-targeted form. The `password_protected` lookup sea `crates/sage-api/password-gating.json` maps each password-bearing endpoint to one of three modes, and the macro's `maybe_unlock` expands accordingly: -| Mode | Expansion | Applies to | -| --- | --- | --- | -| `always` | `resolve(...)` on every call | endpoints that reach a secret unconditionally | -| `fingerprint` | `resolve_for_fingerprint(..., req.fingerprint)` on every call | `delete_key`, `get_secret_key` | +| Mode | Expansion | Applies to | +| ------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `always` | `resolve(...)` on every call | endpoints that reach a secret unconditionally | +| `fingerprint` | `resolve_for_fingerprint(..., req.fingerprint)` on every call | `delete_key`, `get_secret_key` | | `auto_submit` | `resolve(...)` only when `req.auto_submit` is set, `req.password = None` otherwise | endpoints that only forward the password to `Sage::transact`/`transact_with` | The `auto_submit` mode exists because those endpoints build a transaction for the confirmation @@ -88,7 +88,7 @@ dialog first and touch no key until the caller asks for it to be signed and subm unconditionally there asks the user for a password that is then discarded, and then asks again after they confirm — two dialogs for one send. -Note that carrying an `auto_submit` field is *not* the criterion: `sign_coin_spends` and `take_offer` +Note that carrying an `auto_submit` field is _not_ the criterion: `sign_coin_spends` and `take_offer` both have one but reach the keychain on every call, so both are `always`. The criterion is how the implementation consumes the password, and a drift test in `crates/sage-api/src/lib.rs` enforces exactly that by scanning `crates/sage/src/endpoints/` — an endpoint whose body calls @@ -105,7 +105,7 @@ must not ride an event broadcast. - **Event** `PasswordRequest { request_id, requires_password: bool, attempt: u8, error: Option }`, emitted with `emit_to(SAGE_WEBVIEW_LABEL, ...)` rather than a plain `emit`. - Targeting `main` is where the request is *meant* to land, not a guarantee of where it *can* land. + Targeting `main` is where the request is _meant_ to land, not a guarantee of where it _can_ land. Tauri resolves an `AnyLabel` target through `Listeners::emit_js_filter` → `match_any_or_filter`, which short-circuits to true for any listener registered with `EventTarget::Any`, never consulting the target. `src-tauri/capabilities/apps.json` grants app webviews `core:event:allow-listen`, and @@ -115,9 +115,10 @@ must not ride an event broadcast. `error.attemptsRemaining` are retained because the dialog needs them and a bare retry counter identifies nothing an observer could not already infer from the re-prompt timing itself. - What keeps the *password* safe is direction, not targeting: the secret only ever travels back on + What keeps the _password_ safe is direction, not targeting: the secret only ever travels back on `submit_password_response`, a command absent from both `apps.json` and `system-apps.json`, and apps hold no `core:event:allow-emit` with which to forge a request. + - **Command** `submit_password_response(request_id, outcome)` where `outcome = Password(String) | NoAuthNeeded | Cancelled`. - **State** `PasswordGateState { pending: Mutex>> }`. diff --git a/docs/superpowers/plans/2026-08-21-password-gate.md b/docs/superpowers/plans/2026-08-21-password-gate.md deleted file mode 100644 index 9c2ac63be..000000000 --- a/docs/superpowers/plans/2026-08-21-password-gate.md +++ /dev/null @@ -1,1905 +0,0 @@ -# Password Gate Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Move the decision of _when_ a password is required out of the frontend and into the Rust -Tauri host, so callers never supply a password, and app-bridge requests can operate on -password-protected wallets with the dialog rendering only in the trusted `main` webview. - -**Architecture:** A new `password_gate` module in the Tauri host sits above `Sage` and above -`app_state.lock()`. It emits a `PasswordRequest` event to the `main` webview only, awaits a reply -delivered back through a `submit_password_response` command, verifies the answer against the keychain, -and hands the verified password down through the existing `password: Option` API field. The -`sage` and `sage-rpc` crates are not touched — `sage-rpc` is headless and its clients legitimately -supply passwords in the request body. - -**Tech Stack:** Rust, Tauri 2, tauri-specta, tokio (`oneshot`, `Mutex`), proc-macro (`sage-api-macro`), -React 18 + TypeScript, pnpm, Vite. - -**Spec:** `docs/superpowers/specs/2026-08-21-password-gate-design.md` - -## Global Constraints - -- **Branch:** `password-gate`, off `password`. Do not merge to `main`. -- **Commit per task on `password-gate` only.** The user has authorised commits on this branch for the - duration of this plan. Never push, never merge, never commit on `main` or `password`. The standing - "no auto-commit" preference still applies everywhere outside this branch. -- **Every `cargo` command requires `export SDKROOT="$(xcrun --show-sdk-path)"` first**, or the build - fails with `stdlib.h not found`. -- **Never run `pnpm run extract`** (lingui). `.po` churn is batched into a separate pre-release pass. -- **Do not modify `crates/sage/**`or`crates/sage-rpc/**`.** Not `Sage::sign`, not any endpoint in - `crates/sage/src/endpoints/`. If a task seems to need this, stop and report. -- **Do not remove `password: Option` from any `sage-api` request type.** It is the RPC contract. -- **`ChangePassword` is out of scope.** Its `old_password` / `new_password` stay caller-supplied. -- **Regenerate bindings with `pnpm run generate:bindings`**, never by hand-editing `src/bindings.ts`. -- The exact set of 32 password-gated endpoints is listed in Task 3. It is 1:1 with the `sage-api` - request types carrying a `password` field. - ---- - -## File Structure - -**New files** - -| File | Responsibility | -| ------------------------------------------- | --------------------------------------------------------------------- | -| `crates/sage-password-gate/Cargo.toml` | New crate manifest | -| `crates/sage-password-gate/src/lib.rs` | Public surface: `PasswordGateState`, `resolve()`, `Error`, re-exports | -| `crates/sage-password-gate/src/types.rs` | `PasswordRequest` event, `PasswordOutcome`, `PasswordAttemptError` | -| `crates/sage-password-gate/src/prompter.rs` | `Prompter` trait + `TauriPrompter` (emit to `main` webview) | -| `crates/sage-password-gate/src/resolve.rs` | Verify-and-retry loop; unit-tested against a mock `Prompter` | - -The gate lives in its own crate rather than in `src-tauri/src/` because **both** `sage-tauri` and -`sage-apps` must call it — `sage-apps` cannot depend on the `sage-tauri` binary crate. Putting it here -from the start avoids extracting it later. It depends only on `sage`, `sage-api`, `sage-keychain`, and -`tauri`; nothing depends on it except the two hosts. - -Splitting the gate across four small files keeps the testable core (`resolve.rs`) free of any Tauri -`AppHandle` dependency. `resolve.rs` talks only to the `Prompter` trait, so its tests need no running -Tauri app. This is the single most important structural decision in the plan — do not collapse these -into one file. - -**Modified files** - -| File | Change | -| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -| `crates/sage-api/macro/src/lib.rs` | Add `maybe_unlock` token expansion | -| `crates/sage-api/password-gated.json` | New: the 32 gated endpoint names | -| `crates/sage-api/src/lib.rs` | Drift test module | -| `src-tauri/src/commands.rs` | Repeat block gains the gate; new `submit_password_response` | -| `src-tauri/src/lib.rs` | Register state, command, event | -| `crates/sage-apps/src/bridge/bridge_request.rs` | Gate call in `process_after_approval` | -| `crates/sage-apps/src/bridge/types.rs` | `BridgeTools` carries the resolved password | -| `crates/sage-apps/src/bridge/methods/user/wallet/{send_xch,sign_message,sign_coin_spends}.rs` | Use the resolved password; force approval when protected | -| `src/contexts/PasswordContext.tsx` | Invert to a responder; add `requireLocalAuth` | -| `src/hooks/usePassword.ts` | Export the new shape | -| `src/pages/Settings.tsx` | Use `requireLocalAuth` | -| `src/components/{WalletCard,ConfirmationDialog}.tsx`, `src/hooks/useOfferProcessor.ts`, `src/pages/Offer.tsx` | Drop password plumbing | -| `src/contexts/WalletConnectContext.tsx`, `src/walletconnect/{handler,commands/chip0002,commands/high-level,commands/offers}.ts` | Drop password plumbing | -| `src/bindings.ts` | Regenerated | - ---- - -## Task 1: Gate types and the testable resolve loop - -This task builds the entire decision core with **no Tauri dependency**, so it is fully unit-testable. - -**Files:** - -- Create: `crates/sage-password-gate/Cargo.toml` -- Create: `crates/sage-password-gate/src/lib.rs` -- Create: `crates/sage-password-gate/src/types.rs` -- Create: `crates/sage-password-gate/src/prompter.rs` -- Create: `crates/sage-password-gate/src/resolve.rs` (tests live in a `#[cfg(test)] mod tests` here) -- Modify: `Cargo.toml` (workspace members + dependency entry) - -**Interfaces:** - -- Consumes: `sage_keychain::{Keychain, KeychainError}`, `sage_api::ErrorKind`, `sage::Sage` -- Produces: - - `pub enum PasswordOutcome { Password { password: String }, NoAuthNeeded, Cancelled }` (serde `tag = "kind"`, snake_case) - - `pub struct PasswordAttemptError { pub attempts_remaining: u8 }` - - `pub struct PasswordRequest { pub request_id: String, pub fingerprint: u32, pub requires_password: bool, pub attempt: u8, pub error: Option }` - - `#[async_trait] pub trait Prompter { async fn prompt(&self, request: PasswordRequest) -> Result; }` - - `#[async_trait] pub trait PasswordVerifier { async fn verify(&self, fingerprint: u32, password: &str) -> Result; }` — `Ok(false)` means wrong password; `Err` means a real failure - - `pub struct Error { pub kind: ErrorKind, pub reason: String }` and `pub type Result = std::result::Result` — the crate's own error, structurally identical to `src-tauri`'s so `From` is trivial - - `pub const MAX_ATTEMPTS: u8 = 3;` - - `pub const CANCELLED_REASON: &str = "Password entry cancelled";` - - `pub async fn resolve_with(prompter: &dyn Prompter, verifier: &dyn PasswordVerifier, fingerprint: u32, requires_password: bool) -> Result>` - -- [ ] **Step 1: Create the crate** - -Create `crates/sage-password-gate/Cargo.toml`: - -```toml -[package] -name = "sage-password-gate" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -sage = { workspace = true } -sage-api = { workspace = true, features = ["tauri"] } -sage-keychain = { workspace = true } -async-trait = "0.1.89" -serde = { workspace = true, features = ["derive"] } -specta = { workspace = true } -tauri = { workspace = true } -tauri-specta = { workspace = true } -tokio = { workspace = true, features = ["sync"] } -uuid = { version = "1.19.0", features = ["v4"] } - -[dev-dependencies] -bip39 = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync"] } -``` - -`async-trait` and `uuid` are **not** in the root `[workspace.dependencies]` table — `crates/sage-apps` -pins them locally, and the versions above match it exactly. Do not change them to `workspace = true`. -Copy the `version`/`edition`/`license` spellings and the `workspace = true` dependency forms from -`crates/sage-apps/Cargo.toml`. - -Register the crate in the root `Cargo.toml`: add `"crates/sage-password-gate"` to `[workspace] members` -(if members are globbed as `crates/*`, no change is needed), and add -`sage-password-gate = { path = "./crates/sage-password-gate" }` to `[workspace.dependencies]`. - -Create `crates/sage-password-gate/src/lib.rs`: - -```rust -mod prompter; -mod resolve; -mod types; - -use sage_api::ErrorKind; - -pub use prompter::{PasswordVerifier, Prompter}; -pub use resolve::{CANCELLED_REASON, MAX_ATTEMPTS, resolve_with}; -pub use types::{PasswordAttemptError, PasswordOutcome, PasswordRequest}; - -/// This crate's error. Structurally identical to `sage-tauri`'s `Error`, so the -/// host converts with a trivial `From` impl. -#[derive(Debug, Clone)] -pub struct Error { - pub kind: ErrorKind, - pub reason: String, -} - -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.reason) - } -} - -impl std::error::Error for Error {} - -pub type Result = std::result::Result; -``` - -Add to `src-tauri/src/error.rs` so host commands can use `?` on gate calls: - -```rust -impl From for Error { - fn from(error: sage_password_gate::Error) -> Self { - Self { kind: error.kind, reason: error.reason } - } -} -``` - -and add `sage-password-gate = { workspace = true }` to `src-tauri/Cargo.toml` dependencies. In -`src-tauri/src/lib.rs`, alias it for the shorter call sites used later: -`use sage_password_gate as password_gate;` - -- [ ] **Step 2: Write the types** - -Create `crates/sage-password-gate/src/types.rs`: - -```rust -use serde::{Deserialize, Serialize}; -use specta::Type; - -/// How the frontend answered a password request. -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum PasswordOutcome { - /// The user supplied a password. - Password { password: String }, - /// No authentication was required, or a biometric gate already passed. - NoAuthNeeded, - /// The user dismissed the prompt. - Cancelled, -} - -/// Attached to a re-prompt after an incorrect password. -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct PasswordAttemptError { - pub attempts_remaining: u8, -} - -/// Emitted to the `main` webview only. Never broadcast. -#[derive(Debug, Clone, Serialize, Deserialize, Type, tauri_specta::Event)] -#[serde(rename_all = "camelCase")] -pub struct PasswordRequest { - pub request_id: String, - pub fingerprint: u32, - /// Advisory: the wallet's stored `password_protected` flag. The frontend - /// still decides between password dialog, biometric gate, and no auth, - /// because Rust does not know whether biometrics are enabled. - pub requires_password: bool, - /// 1-based. Increments on each incorrect-password re-prompt. - pub attempt: u8, - pub error: Option, -} -``` - -- [ ] **Step 3: Write the Prompter trait** - -Create `crates/sage-password-gate/src/prompter.rs`: - -```rust -use async_trait::async_trait; - -use super::types::{PasswordOutcome, PasswordRequest}; -use crate::Result; - -/// Abstracts the round-trip to the frontend so the resolve loop can be -/// unit-tested without a running Tauri app. -#[async_trait] -pub trait Prompter: Send + Sync { - async fn prompt(&self, request: PasswordRequest) -> Result; -} - -/// Checks a candidate password against the keychain. -/// -/// This is a trait rather than a borrowed `&Keychain` on purpose. `Keychain` -/// is not `Clone` and owns a `ChaCha20Rng`; cloning it to escape the app lock -/// would duplicate an RNG stream, which is a nonce-reuse hazard the moment a -/// clone ever encrypts. Instead the production implementation takes the Sage -/// lock briefly for each attempt and drops it before the next prompt, so no -/// lock is ever held across an await. -#[async_trait] -pub trait PasswordVerifier: Send + Sync { - /// `Ok(true)` = correct, `Ok(false)` = wrong password, - /// `Err` = a genuine failure (not a wrong password). - async fn verify(&self, fingerprint: u32, password: &str) -> Result; -} -``` - -`async-trait` was declared in the crate manifest in Step 1 with an explicit version, matching -`crates/sage-apps`. It is not a workspace dependency. - -- [ ] **Step 4: Write the failing tests** - -Create `crates/sage-password-gate/src/resolve.rs` with **only** this test module for now (the file will -not compile yet — that is expected and is the point of the next step): - -```rust -#[cfg(test)] -mod tests { - use std::sync::Mutex; - - use async_trait::async_trait; - use sage_api::ErrorKind; - use sage_keychain::{Keychain, KeychainError}; - - use super::*; - use crate::types::{PasswordOutcome, PasswordRequest}; - use crate::{Error, PasswordVerifier, Prompter, Result}; - - /// Replays a scripted sequence of outcomes and records what it was asked. - struct MockPrompter { - scripted: Mutex>, - seen: Mutex>, - } - - impl MockPrompter { - fn new(scripted: Vec) -> Self { - Self { - scripted: Mutex::new(scripted.into_iter().rev().collect()), - seen: Mutex::new(Vec::new()), - } - } - - fn seen(&self) -> Vec { - self.seen.lock().unwrap().clone() - } - } - - #[async_trait] - impl Prompter for MockPrompter { - async fn prompt(&self, request: PasswordRequest) -> Result { - self.seen.lock().unwrap().push(request); - Ok(self.scripted.lock().unwrap().pop().expect("prompted more times than scripted")) - } - } - - /// A verifier backed by a real keychain holding one mnemonic key - /// encrypted with `password`. - struct KeychainVerifier { - keychain: Keychain, - } - - #[async_trait] - impl PasswordVerifier for KeychainVerifier { - async fn verify(&self, fingerprint: u32, password: &str) -> Result { - match self.keychain.extract_secrets(fingerprint, password.as_bytes()) { - Ok(_) => Ok(true), - Err(KeychainError::Decrypt) => Ok(false), - Err(err) => Err(Error { kind: ErrorKind::Internal, reason: err.to_string() }), - } - } - } - - fn protected_keychain(password: &str) -> (KeychainVerifier, u32) { - let mut keychain = Keychain::default(); - let mnemonic = bip39::Mnemonic::from_entropy(&[7u8; 32]).unwrap(); - let fingerprint = keychain - .add_mnemonic(&mnemonic, password.as_bytes()) - .expect("failed to add mnemonic"); - (KeychainVerifier { keychain }, fingerprint) - } - - fn pw(s: &str) -> PasswordOutcome { - PasswordOutcome::Password { password: s.to_string() } - } - - #[tokio::test] - async fn correct_password_resolves_on_first_attempt() { - let (verifier, fingerprint) = protected_keychain("hunter2"); - let prompter = MockPrompter::new(vec![pw("hunter2")]); - - let result = resolve_with(&prompter, &verifier, fingerprint, true).await.unwrap(); - - assert_eq!(result, Some("hunter2".to_string())); - let seen = prompter.seen(); - assert_eq!(seen.len(), 1); - assert_eq!(seen[0].attempt, 1); - assert!(seen[0].requires_password); - assert!(seen[0].error.is_none()); - } - - #[tokio::test] - async fn wrong_then_right_reprompts_with_attempts_remaining() { - let (verifier, fingerprint) = protected_keychain("hunter2"); - let prompter = MockPrompter::new(vec![pw("wrong"), pw("hunter2")]); - - let result = resolve_with(&prompter, &verifier, fingerprint, true).await.unwrap(); - - assert_eq!(result, Some("hunter2".to_string())); - let seen = prompter.seen(); - assert_eq!(seen.len(), 2); - assert_eq!(seen[1].attempt, 2); - assert_eq!(seen[1].error.as_ref().unwrap().attempts_remaining, 2); - } - - #[tokio::test] - async fn three_wrong_attempts_fails_unauthorized() { - let (verifier, fingerprint) = protected_keychain("hunter2"); - let prompter = MockPrompter::new(vec![pw("a"), pw("b"), pw("c")]); - - let error = resolve_with(&prompter, &verifier, fingerprint, true).await.unwrap_err(); - - assert!(matches!(error.kind, ErrorKind::Unauthorized)); - assert_eq!(prompter.seen().len(), MAX_ATTEMPTS as usize); - } - - #[tokio::test] - async fn cancellation_fails_immediately_without_reprompting() { - let (verifier, fingerprint) = protected_keychain("hunter2"); - let prompter = MockPrompter::new(vec![PasswordOutcome::Cancelled]); - - let error = resolve_with(&prompter, &verifier, fingerprint, true).await.unwrap_err(); - - assert!(matches!(error.kind, ErrorKind::Unauthorized)); - assert_eq!(error.reason, CANCELLED_REASON); - assert_eq!(prompter.seen().len(), 1); - } - - #[tokio::test] - async fn no_auth_needed_resolves_to_none() { - let (verifier, fingerprint) = protected_keychain("hunter2"); - let prompter = MockPrompter::new(vec![PasswordOutcome::NoAuthNeeded]); - - let result = resolve_with(&prompter, &verifier, fingerprint, false).await.unwrap(); - - assert_eq!(result, None); - assert_eq!(prompter.seen().len(), 1); - assert!(!prompter.seen()[0].requires_password); - } -} -``` - -- [ ] **Step 5: Run the tests to verify they fail** - -```bash -export SDKROOT="$(xcrun --show-sdk-path)" -cargo test -p sage-password-gate -``` - -Expected: FAIL to compile, with errors like `cannot find function 'resolve_with' in this scope` and -`cannot find value 'CANCELLED_REASON' in this scope`. - -- [ ] **Step 6: Write the resolve loop** - -Prepend to `crates/sage-password-gate/src/resolve.rs`, above the test module: - -```rust -use sage_api::ErrorKind; - -use super::types::{PasswordAttemptError, PasswordOutcome, PasswordRequest}; -use crate::{Error, Result}; - -/// Maximum password entry attempts before the operation is refused. -pub const MAX_ATTEMPTS: u8 = 3; - -/// Reason string used when the user dismisses the prompt, so the frontend can -/// distinguish a deliberate cancel from a genuine auth failure and stay silent. -pub const CANCELLED_REASON: &str = "Password entry cancelled"; - -fn unauthorized(reason: &str) -> Error { - Error { kind: ErrorKind::Unauthorized, reason: reason.to_string() } -} - -/// Prompts the frontend for a password and verifies it against the keychain, -/// retrying up to `MAX_ATTEMPTS` times on an incorrect password. -/// -/// Returns `Ok(None)` when no authentication was required. The caller places -/// the returned value directly into the request's `password` field. -/// -/// This runs *before* `app_state.lock()` is taken. Verifying here keeps a wrong -/// password cheap (one keychain decrypt, no partially built transaction) and -/// avoids awaiting the frontend while holding the app lock. -pub async fn resolve_with( - prompter: &dyn Prompter, - verifier: &dyn PasswordVerifier, - fingerprint: u32, - requires_password: bool, -) -> Result> { - let mut error: Option = None; - - for attempt in 1..=MAX_ATTEMPTS { - let request = PasswordRequest { - request_id: uuid::Uuid::new_v4().to_string(), - fingerprint, - requires_password, - attempt, - error: error.take(), - }; - - match prompter.prompt(request).await? { - PasswordOutcome::NoAuthNeeded => return Ok(None), - PasswordOutcome::Cancelled => return Err(unauthorized(CANCELLED_REASON)), - PasswordOutcome::Password { password } => { - if verifier.verify(fingerprint, &password).await? { - return Ok(Some(password)); - } - error = Some(PasswordAttemptError { - attempts_remaining: MAX_ATTEMPTS - attempt, - }); - } - } - } - - Err(unauthorized("Too many incorrect password attempts")) -} -``` - -Both `uuid` and `bip39` were declared in the crate manifest in Step 1 — `uuid` with an explicit -version (it is not a workspace dependency), `bip39` as `workspace = true`. - -- [ ] **Step 7: Run the tests to verify they pass** - -```bash -export SDKROOT="$(xcrun --show-sdk-path)" -cargo test -p sage-password-gate -``` - -Expected: PASS, 5 tests. - -- [ ] **Step 8: Commit (only with user approval)** - -```bash -git add crates/sage-password-gate Cargo.toml src-tauri/Cargo.toml src-tauri/src/error.rs src-tauri/src/lib.rs -git commit -m "feat(password-gate): add testable password resolve loop" -``` - ---- - -## Task 2: Tauri transport — state, event emission, response command - -Wires the abstract `Prompter` to the real `main` webview. - -**Files:** - -- Modify: `crates/sage-password-gate/src/prompter.rs` (add `TauriPrompter`) -- Modify: `crates/sage-password-gate/src/lib.rs` (add `PasswordGateState`, `resolve`) -- Modify: `src-tauri/src/commands.rs` (add `submit_password_response`) -- Modify: `src-tauri/src/lib.rs` (register state, command, event) - -**Interfaces:** - -- Consumes: `Prompter`, `PasswordOutcome`, `PasswordRequest`, `resolve_with`, `MAX_ATTEMPTS` from Task 1 -- Produces: - - `pub struct PasswordGateState { pending: Mutex>> }` with `Default` - - `pub async fn resolve(app_handle: &AppHandle, state: &AppState, gate: &PasswordGateState) -> Result>` - - `pub fn submit_password_response(gate: State<'_, PasswordGateState>, request_id: String, outcome: PasswordOutcome) -> Result<()>` - - Constant `SAGE_WEBVIEW_LABEL: &str = "main"` - -- [ ] **Step 1: Write the failing test for the pending-map handoff** - -Append to `crates/sage-password-gate/src/lib.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn resolving_a_pending_request_delivers_the_outcome() { - let gate = PasswordGateState::default(); - let (tx, rx) = tokio::sync::oneshot::channel(); - gate.register("req-1".to_string(), tx).await; - - gate.deliver("req-1", PasswordOutcome::NoAuthNeeded) - .expect("delivery should succeed"); - - assert!(matches!(rx.await.unwrap(), PasswordOutcome::NoAuthNeeded)); - } - - #[tokio::test] - async fn delivering_an_unknown_request_id_is_an_error() { - let gate = PasswordGateState::default(); - - let error = gate - .deliver("nope", PasswordOutcome::Cancelled) - .expect_err("unknown id must error"); - - assert!(error.reason.contains("nope")); - } - - #[tokio::test] - async fn a_request_id_can_only_be_delivered_once() { - let gate = PasswordGateState::default(); - let (tx, _rx) = tokio::sync::oneshot::channel(); - gate.register("req-2".to_string(), tx).await; - - gate.deliver("req-2", PasswordOutcome::Cancelled).unwrap(); - - assert!(gate.deliver("req-2", PasswordOutcome::Cancelled).is_err()); - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -export SDKROOT="$(xcrun --show-sdk-path)" -cargo test -p sage-password-gate -``` - -Expected: FAIL to compile — `cannot find type 'PasswordGateState'`, `no method named 'register'`. - -- [ ] **Step 3: Implement the state** - -Add to `crates/sage-password-gate/src/lib.rs`, above the test module: - -```rust -use std::collections::HashMap; - -use tokio::sync::{Mutex, oneshot}; - -/// Tracks in-flight password requests awaiting a frontend reply. -#[derive(Default)] -pub struct PasswordGateState { - pending: Mutex>>, -} - -impl PasswordGateState { - pub(crate) async fn register(&self, request_id: String, tx: oneshot::Sender) { - self.pending.lock().await.insert(request_id, tx); - } - - pub(crate) async fn cancel(&self, request_id: &str) { - self.pending.lock().await.remove(request_id); - } - - /// Hands an outcome to the waiting resolve loop. Consumes the entry, so a - /// given request id can only be answered once. - pub(crate) fn deliver(&self, request_id: &str, outcome: PasswordOutcome) -> Result<()> { - let sender = self - .pending - .blocking_lock() - .remove(request_id) - .ok_or_else(|| Error { - kind: ErrorKind::NotFound, - reason: format!("no pending password request with id {request_id}"), - })?; - - sender.send(outcome).map_err(|_| Error { - kind: ErrorKind::Internal, - reason: "password request was abandoned".to_string(), - }) - } -} -``` - -If `blocking_lock` panics in an async context during the tests, change `deliver` to `async fn` using -`self.pending.lock().await`, and make `submit_password_response` in Step 5 `async` to match. Prefer the -async form if in doubt — it is the safer choice inside Tauri's async command runtime. - -- [ ] **Step 4: Run the tests to verify they pass** - -```bash -export SDKROOT="$(xcrun --show-sdk-path)" -cargo test -p sage-password-gate -``` - -Expected: PASS, 8 tests. - -- [ ] **Step 5: Implement `TauriPrompter` and the public `resolve`** - -Append to `crates/sage-password-gate/src/prompter.rs`: - -```rust -use tauri::{AppHandle, Emitter}; -use tauri_specta::Event; - -use super::{Error, PasswordGateState}; -use sage_api::ErrorKind; - -/// The Sage React webview. App runtimes are sibling webviews in the same -/// window, so emission MUST target this label — a plain `emit` would deliver -/// the request to app-land. -pub const SAGE_WEBVIEW_LABEL: &str = "main"; - -pub struct TauriPrompter<'a> { - pub app_handle: &'a AppHandle, - pub gate: &'a PasswordGateState, -} - -#[async_trait] -impl Prompter for TauriPrompter<'_> { - async fn prompt(&self, request: PasswordRequest) -> Result { - let request_id = request.request_id.clone(); - let (tx, rx) = tokio::sync::oneshot::channel(); - self.gate.register(request_id.clone(), tx).await; - - if let Err(err) = request.emit_to(self.app_handle, SAGE_WEBVIEW_LABEL) { - self.gate.cancel(&request_id).await; - return Err(Error { - kind: ErrorKind::Internal, - reason: format!("failed to emit password request: {err}"), - }); - } - - rx.await.map_err(|_| Error { - kind: ErrorKind::Internal, - reason: "password request channel closed".to_string(), - }) - } -} -``` - -If `tauri_specta::Event` does not provide `emit_to` in this version, use -`self.app_handle.emit_to(SAGE_WEBVIEW_LABEL, "password-request", &request)` instead and declare the -event name explicitly. Verify against the `SyncEvent` usage in `src-tauri/src/app_state.rs:52`. - -Append to `crates/sage-password-gate/src/lib.rs`: - -```rust -use std::sync::Arc; - -use sage::Sage; -use tauri::AppHandle; - -/// The host's shared Sage handle. Mirrors `sage_tauri::app_state::AppState`. -pub type SharedSage = Arc>; - -/// Resolves a password for the active wallet, prompting the `main` webview. -/// -/// Reads the wallet fingerprint and its stored `password_protected` flag, then -/// releases the app lock *before* the frontend round-trip. Uses the cheap -/// config flag rather than `Keychain::is_password_protected`, which runs an -/// Argon2 decrypt probe on every call. -pub async fn resolve( - app_handle: &AppHandle, - state: &SharedSage, - gate: &PasswordGateState, -) -> Result> { - let (fingerprint, requires_password) = { - let sage = state.lock().await; - let fingerprint = sage - .wallet() - .map_err(|err| Error { kind: err.kind(), reason: err.to_string() })? - .fingerprint; - let requires_password = sage - .wallet_config - .wallets - .iter() - .find(|wallet| wallet.fingerprint == fingerprint) - .is_some_and(|wallet| wallet.password_protected); - (fingerprint, requires_password) - }; - - let prompter = prompter::TauriPrompter { app_handle, gate }; - let verifier = SageVerifier { state }; - resolve_with(&prompter, &verifier, fingerprint, requires_password).await -} - -/// Verifies a candidate password by taking the Sage lock briefly, then -/// releasing it before the next prompt. Never holds the lock across an await. -struct SageVerifier<'a> { - state: &'a SharedSage, -} - -#[async_trait::async_trait] -impl PasswordVerifier for SageVerifier<'_> { - async fn verify(&self, fingerprint: u32, password: &str) -> Result { - let sage = self.state.lock().await; - match sage.keychain.extract_secrets(fingerprint, password.as_bytes()) { - Ok(_) => Ok(true), - Err(sage_keychain::KeychainError::Decrypt) => Ok(false), - Err(err) => Err(Error { - kind: sage_api::ErrorKind::Internal, - reason: err.to_string(), - }), - } - } -} -``` - -`Keychain` is deliberately **not** cloned and `crates/sage-keychain/` is **not** modified. Both lock -scopes above are tight and neither spans an await, so the frontend round-trip always happens with the -Sage lock released. Add `sage-keychain = { workspace = true }` to the gate crate's manifest if Step 1 -omitted it. - -- [ ] **Step 6: Add the response command** - -Add to `src-tauri/src/commands.rs`: - -```rust -use sage_password_gate::{PasswordGateState, PasswordOutcome}; - -#[command] -#[specta] -pub async fn submit_password_response( - gate: State<'_, PasswordGateState>, - request_id: String, - outcome: PasswordOutcome, -) -> Result<()> { - Ok(gate.deliver(&request_id, outcome)?) -} -``` - -(Make it `gate.deliver(...).await` if Step 3 chose the async form.) - -- [ ] **Step 7: Register state, command, and event** - -In `src-tauri/src/lib.rs`: - -1. Add `commands::submit_password_response,` to the `sage_commands!` list in `collect_commands![...]`. -2. Change **both** `.events(collect_events![SyncEvent])` occurrences (the `specta_builder` one near - line 187 and the `#[cfg(mobile)]` one near line 215) to - `.events(collect_events![SyncEvent, password_gate::PasswordRequest])`. -3. Add `.manage(password_gate::PasswordGateState::default())` to the `tauri::Builder` chain, alongside - the other `.manage(...)` calls. -4. Add `use crate::password_gate;` if not already in scope. - -- [ ] **Step 8: Build and regenerate bindings** - -```bash -export SDKROOT="$(xcrun --show-sdk-path)" -cargo build -p sage-password-gate -p sage-tauri -pnpm run generate:bindings -git diff --stat src/bindings.ts -``` - -Expected: builds clean; `src/bindings.ts` gains `submitPasswordResponse`, `PasswordRequest`, -`PasswordOutcome`, `PasswordAttemptError`, and a `passwordRequest` entry in `events`. - -- [ ] **Step 9: Commit (only with user approval)** - -```bash -git add crates/sage-password-gate crates/sage-keychain/src src-tauri/src src/bindings.ts -git commit -m "feat(password-gate): wire main-window transport for password requests" -``` - ---- - -## Task 3: Macro support and the drift-proof gated endpoint set - -**Files:** - -- Create: `crates/sage-api/password-gated.json` -- Modify: `crates/sage-api/macro/src/lib.rs` -- Modify: `crates/sage-api/src/lib.rs` (drift test) - -**Interfaces:** - -- Produces: a `maybe_unlock` token usable inside `impl_endpoints_tauri!`'s `repeat` block, expanding to - the gate call for gated endpoints and to nothing otherwise. - -- [ ] **Step 1: Create the gated endpoint set** - -Create `crates/sage-api/password-gated.json`. These 32 names are exactly the endpoints whose request -type carries a `password: Option` field: - -```json -[ - "add_nft_uri", - "assign_nfts_to_did", - "auto_combine_cat", - "auto_combine_xch", - "bulk_mint_nfts", - "bulk_send_cat", - "bulk_send_xch", - "cancel_offer", - "cancel_offers", - "combine", - "create_did", - "create_transaction", - "delete_key", - "exercise_options", - "finalize_clawback", - "get_secret_key", - "increase_derivation_index", - "issue_cat", - "make_offer", - "mint_option", - "multi_send", - "normalize_dids", - "send_cat", - "send_xch", - "sign_coin_spends", - "sign_message_by_address", - "sign_message_with_public_key", - "split", - "take_offer", - "transfer_dids", - "transfer_nfts", - "transfer_options" -] -``` - -- [ ] **Step 2: Write the failing drift test** - -Add to `crates/sage-api/src/lib.rs`: - -```rust -#[cfg(test)] -mod password_gate_drift { - use std::collections::BTreeSet; - - /// The gated set must match, exactly, the request types carrying a - /// `password` field. If this fails you either added a signing endpoint - /// without gating it (a security hole) or gated one that takes no - /// password (a spurious prompt). - #[test] - fn gated_set_matches_request_types_with_password_field() { - let gated: BTreeSet = - serde_json::from_str(include_str!("../password-gated.json")).unwrap(); - - let mut discovered = BTreeSet::new(); - for source in [ - include_str!("requests/action_system.rs"), - include_str!("requests/actions.rs"), - include_str!("requests/keys.rs"), - include_str!("requests/offers.rs"), - include_str!("requests/transactions.rs"), - include_str!("requests/wallet_connect.rs"), - ] { - discovered.extend(structs_with_password_field(source)); - } - - assert_eq!( - gated, discovered, - "password-gated.json is out of sync with the request types.\n\ - Only in password-gated.json: {:?}\n\ - Only in request types: {:?}", - gated.difference(&discovered).collect::>(), - discovered.difference(&gated).collect::>(), - ); - } - - /// Scans Rust source for `pub struct Name {` blocks containing a - /// `pub password: Option` field, returning snake_case names. - fn structs_with_password_field(source: &str) -> BTreeSet { - let mut found = BTreeSet::new(); - let lines: Vec<&str> = source.lines().collect(); - let mut index = 0; - - while index < lines.len() { - let line = lines[index].trim_end(); - let Some(rest) = line.strip_prefix("pub struct ") else { - index += 1; - continue; - }; - let Some(name) = rest.strip_suffix(" {") else { - index += 1; - continue; - }; - - let mut cursor = index + 1; - let mut has_password = false; - while cursor < lines.len() && lines[cursor] != "}" { - if lines[cursor].trim() == "pub password: Option," { - has_password = true; - } - cursor += 1; - } - - if has_password { - found.insert(to_snake_case(name)); - } - index = cursor + 1; - } - - found - } - - fn to_snake_case(name: &str) -> String { - let mut out = String::new(); - for (position, character) in name.char_indices() { - if character.is_uppercase() && position != 0 { - out.push('_'); - } - out.extend(character.to_lowercase()); - } - out - } -} -``` - -Note the empty-struct guard: `pub struct DeleteDatabaseResponse {}` is written on one line and so is -correctly skipped by the `" {"` suffix check. Do not loosen that check. - -- [ ] **Step 3: Run the test to verify it fails** - -```bash -export SDKROOT="$(xcrun --show-sdk-path)" -cargo test -p sage-api password_gate_drift -``` - -Expected: FAIL — `couldn't read ../password-gated.json` if Step 1 was skipped, otherwise it should -already PASS. If it fails with a set mismatch, **the JSON in Step 1 is authoritative only if the code -agrees** — re-derive the list from the actual source and fix the JSON, do not weaken the test. - -- [ ] **Step 4: Add `maybe_unlock` to the macro** - -In `crates/sage-api/macro/src/lib.rs`, inside `generate()`, load the gated set next to the endpoints: - -```rust -let password_gated: std::collections::BTreeSet = - serde_json::from_str(include_str!("../../password-gated.json")) - .expect("Invalid password-gated endpoint file"); -``` - -Thread `&password_gated` through `convert()` as an extra parameter (alongside `endpoints`), then add a -branch in the `TokenTree::Ident` arm, next to the existing `maybe_async` / `maybe_await` branches: - -```rust -} else if ident == "maybe_unlock" { - if password_gated.contains(endpoint) { - output.extend(quote!( - req.password = sage_password_gate::resolve(&app_handle, state.inner(), gate.inner()).await?; - )); - } -} -``` - -The `maybe_unlock` identifier is snake_case, so this branch **must** appear before the -`ident.is_case(Case::Snake)` branch, exactly as `maybe_async` and `maybe_await` do. Otherwise it will -be rewritten into an endpoint name instead of expanded. - -- [ ] **Step 5: Verify the macro compiles** - -```bash -export SDKROOT="$(xcrun --show-sdk-path)" -cargo build -p sage-api-macro -cargo test -p sage-api password_gate_drift -``` - -Expected: both PASS. - -- [ ] **Step 6: Commit (only with user approval)** - -```bash -git add crates/sage-api/password-gated.json crates/sage-api/macro/src/lib.rs crates/sage-api/src/lib.rs -git commit -m "feat(password-gate): add maybe_unlock macro token and drift test" -``` - ---- - -## Task 4: Gate every endpoint command - -**Files:** - -- Modify: `src-tauri/src/commands.rs:67-75` - -**Interfaces:** - -- Consumes: `password_gate::resolve` (Task 2), `maybe_unlock` (Task 3) -- Produces: all 32 gated Tauri commands now resolve their own password - -- [ ] **Step 1: Update the repeat block** - -Replace the `impl_endpoints_tauri!` block at `src-tauri/src/commands.rs:67-75` with: - -```rust -impl_endpoints_tauri! { - (repeat - #[command] - #[specta] - pub async fn endpoint( - app_handle: AppHandle, - state: State<'_, AppState>, - gate: State<'_, PasswordGateState>, - mut req: Endpoint, - ) -> Result { - maybe_unlock; - Ok(state.lock().await.endpoint(req) maybe_await?) - } - ) -} -``` - -Two consequences to expect from the compiler: - -- Ungated endpoints now take `app_handle`, `gate`, and `mut req` without using them. Silence this by - prefixing with underscores is **not** possible inside the repeat block, so instead add - `#[allow(unused_variables, unused_mut)]` immediately above `pub async fn endpoint`. -- Adding parameters changes the generated command signatures. Tauri injects `AppHandle` and `State` - automatically, so the **TypeScript call signatures are unchanged** — `req` remains the only argument. - Confirm this in the bindings diff in Step 3. - -- [ ] **Step 2: Build** - -```bash -export SDKROOT="$(xcrun --show-sdk-path)" -cargo build -p sage-password-gate -p sage-tauri -``` - -Expected: clean build. If a specific endpoint fails because its request type has no `password` field -but appears in `password-gated.json`, the drift test in Task 3 was wrong — fix the JSON, not this block. - -- [ ] **Step 3: Regenerate bindings and confirm TS signatures did not change** - -```bash -pnpm run generate:bindings -git diff src/bindings.ts | grep -E '^\-.*sendXch|^\+.*sendXch' -``` - -Expected: no change to `sendXch`'s TypeScript signature. If the signature gained parameters, the -`AppHandle`/`State` injection is not being recognized — stop and report. - -- [ ] **Step 4: Confirm the RPC path is untouched** - -```bash -export SDKROOT="$(xcrun --show-sdk-path)" -cargo test -p sage-rpc -``` - -Expected: PASS, unchanged. This is the regression canary for the headless path — if these fail, the -gate has leaked into the core. - -- [ ] **Step 5: Commit (only with user approval)** - -```bash -git add src-tauri/src/commands.rs src/bindings.ts -git commit -m "feat(password-gate): resolve passwords in all gated endpoint commands" -``` - ---- - -## Task 5: Bridge — gate app requests after approval - -**Files:** - -- Modify: `crates/sage-apps/src/bridge/bridge_request.rs:83-135` (`process_after_approval`) -- Modify: `crates/sage-apps/src/bridge/types.rs` (`BridgeTools`) -- Modify: `crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs` -- Modify: `crates/sage-apps/src/bridge/methods/user/wallet/sign_message.rs` -- Modify: `crates/sage-apps/src/bridge/methods/user/wallet/sign_coin_spends.rs` -- Modify: `crates/sage-apps/src/runtime/manager.rs` (expose `hide_runtime_inner` to the bridge) - -**Interfaces:** - -- Consumes: `password_gate::resolve` (Task 2) -- Produces: `BridgeTools` gains `pub password: Option`, defaulted to `None` on every existing - construction site. - -- [ ] **Step 1: Add the field to `BridgeTools`** - -In `crates/sage-apps/src/bridge/types.rs`, add to the `BridgeTools` struct: - -```rust -/// Password resolved by the main-window gate, for methods that sign. -/// `None` when the wallet is unprotected or the method does not sign. -pub password: Option, -``` - -Then fix every construction of `BridgeTools` in `bridge_request.rs` (there are three: in -`process_shared`'s `prepare_approval` call, in `process_shared`'s non-approval `execute_bridge_request` -call, and in `execute_bridge_request` itself) to pass `password: None` for now. The build must be green -before moving on. - -- [ ] **Step 2: Thread the password through `execute_bridge_request`** - -Change `execute_bridge_request` in `bridge_request.rs` to take an extra parameter and use it: - -```rust -async fn execute_bridge_request( - app_handle: &AppHandle, - app_state: &State<'_, AppState>, - origin: &BridgeOrigin, - registry: BridgeRegistry, - request: &RustBridgeRequest, - password: Option, -) -> RustBridgeResponse { -``` - -and inside, set `password` on the `BridgeTools` it constructs. Update both call sites in -`process_shared` to pass `None`, and add the same parameter to `process_shared` so -`process_after_approval` can supply it. - -- [ ] **Step 3: Call the gate in `process_after_approval`** - -In `process_after_approval`, the final `else` branch currently calls `process_shared(...)`. Replace that -branch with: - -```rust -} else { - // Hide the approval app before prompting: app runtimes are sibling - // webviews inside the same window and would cover the main webview's - // password dialog. - hide_bridge_approval_runtime(app_handle, apps_state).await; - - match password_gate_resolve(app_handle, app_state).await { - Ok(password) => { - process_shared( - app_handle, - app_state, - &origin, - pending.registry_kind, - &pending.request, - true, - password, - ) - .await? - } - Err(err) => RustBridgeInvokeResult::error( - &pending.request.id, - "unauthorized", - err.to_string(), - ), - } -} -``` - -The existing `expires_at_ms` check stays exactly where it is, _above_ this branch. Because the check -already ran before the prompt, add a second check immediately after the gate returns, so a prompt that -outlives the deadline fails correctly: - -```rust -if unix_timestamp_ms() as u64 > pending.expires_at_ms { - RustBridgeInvokeResult::error( - &pending.request.id, - "approval_timeout", - "Approval expired during password entry".to_string(), - ) -} -``` - -Place this as a guard inside the `Ok(password)` arm, before `process_shared`. - -- [ ] **Step 4: Add the two helper functions** - -`hide_bridge_approval_runtime` finds the runtime whose app id is `SYSTEM_APP_BRIDGE_APPROVAL_ID` -(`crates/sage-apps/src/system_apps.rs:22`) and calls the existing `hide_runtime_inner` -(`crates/sage-apps/src/runtime/manager.rs:395`), then emits the resulting `RuntimeChangeSet`. Make -`hide_runtime_inner` and `RuntimeChangeSet` visible to the bridge module by widening their visibility -from private to `pub(crate)`. Failure to hide is logged with `tracing::warn!` and does not abort the -request — a covered dialog is a UX problem, not a security one. - -`password_gate_resolve` is a thin call into `sage-password-gate`, which `sage-apps` depends on -directly — this is exactly why the gate was made its own crate in Task 1 rather than living in -`src-tauri/src/`: - -```rust -async fn password_gate_resolve( - app_handle: &AppHandle, - app_state: &State<'_, AppState>, -) -> Result, String> { - let gate = app_handle.state::(); - sage_password_gate::resolve(app_handle, app_state.inner(), &gate) - .await - .map_err(|err| err.reason) -} -``` - -Add `sage-password-gate = { workspace = true }` to `crates/sage-apps/Cargo.toml`. `AppState` in -`sage-apps` is already `Arc>`, matching `sage_password_gate::SharedSage`; if the types do -not unify, pass `app_state.inner().clone()` and take a reference to that. - -- [ ] **Step 5: Use the password in the three wallet methods** - -In each of `send_xch.rs`, `sign_message.rs`, and `sign_coin_spends.rs`, the `From` impl -currently hardcodes `password: None`. Remove the `password` field from those `From` impls and set it in -`handle` instead. For `send_xch.rs`: - -```rust -async fn handle( - &self, - _ctx: BridgeContext<'_>, - tools: BridgeTools<'_>, - request: &RustBridgeRequest, -) -> BridgeHandleResult { - let params: WalletSendXchParams = parse_required_params(self, request)?; - let mut req: SendXch = params.into(); - req.password = tools.password.clone(); - - let result = tools - .app_state - .lock() - .await - .send_xch(req) - .await - .map_err(|err| { - BridgeMethodHandleError::internal_error(format!("{} failed: {err}", self.name())) - })?; - - Ok(Box::new(result)) -} -``` - -Apply the same shape to `sign_message.rs` (`SignMessageWithPublicKey`) and `sign_coin_spends.rs` -(`SignCoinSpends`). Keep `password: None` in the `From` impls only if removing it breaks struct -initialization — in that case leave `password: None` there and let the `handle` assignment override it. - -- [ ] **Step 6: Build and test** - -```bash -export SDKROOT="$(xcrun --show-sdk-path)" -cargo build --workspace -cargo test -p sage-apps -``` - -Expected: clean build, existing `sage-apps` tests pass. - -- [ ] **Step 7: Commit (only with user approval)** - -```bash -git add crates/sage-apps crates/sage-password-gate src-tauri -git commit -m "feat(password-gate): prompt in main window for app bridge requests" -``` - ---- - -## Task 6: Force approval on protected wallets despite auto-submit - -**Files:** - -- Modify: `crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs` (`approval_request`) -- Test: `crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs` (`#[cfg(test)] mod tests`) - -**Interfaces:** - -- Consumes: `BridgeContext` (existing), the wallet `password_protected` flag -- Produces: no new public interface - -- [ ] **Step 1: Write the failing test** - -Add to `send_xch.rs`: - -```rust -#[cfg(test)] -mod tests { - /// A password-protected wallet must always produce an approval, even when - /// the app holds WalletSendXchAutoSubmit. Silent auto-submit is - /// incompatible with password protection: there would be no UI moment in - /// which to collect the password. - #[test] - fn protected_wallet_forces_approval_despite_auto_submit_grant() { - assert!(super::requires_approval( - /* auto_submit_granted */ true, - /* wallet_protected */ true, - )); - } - - #[test] - fn unprotected_wallet_still_honours_auto_submit_grant() { - assert!(!super::requires_approval(true, false)); - } - - #[test] - fn without_the_grant_approval_is_always_required() { - assert!(super::requires_approval(false, false)); - assert!(super::requires_approval(false, true)); - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -```bash -export SDKROOT="$(xcrun --show-sdk-path)" -cargo test -p sage-apps send_xch -``` - -Expected: FAIL to compile — `cannot find function 'requires_approval'`. - -- [ ] **Step 3: Extract the predicate and use it** - -Add to `send_xch.rs`: - -```rust -/// Whether this request needs a user approval step. -fn requires_approval(auto_submit_granted: bool, wallet_protected: bool) -> bool { - !auto_submit_granted || wallet_protected -} -``` - -Then change `approval_request` so its early return consults the predicate. The existing body is: - -```rust -if ctx - .app - .is_capability_granted(UserBridgeCapability::WalletSendXchAutoSubmit.into()) -{ - return Ok(None); -} -``` - -Replace with a call to `requires_approval`, reading the active wallet's `password_protected` flag from -`ctx`. `approval_request` is synchronous and `BridgeContext` currently carries only `app`, so add the -flag to `BridgeContext` — populate it where `BridgeContext { app }` is constructed in -`bridge_request.rs` (three sites), reading it from `app_state` the same way `active_wallet_fingerprint` -does. Return early only when `requires_approval(..)` is `false`. - -- [ ] **Step 4: Run the tests to verify they pass** - -```bash -export SDKROOT="$(xcrun --show-sdk-path)" -cargo test -p sage-apps -``` - -Expected: PASS. - -- [ ] **Step 5: Commit (only with user approval)** - -```bash -git add crates/sage-apps/src/bridge -git commit -m "feat(password-gate): force approval for protected wallets on auto-submit" -``` - ---- - -## Task 7: Invert PasswordContext into a responder - -**Files:** - -- Modify: `src/contexts/PasswordContext.tsx` -- Modify: `src/hooks/usePassword.ts` -- Modify: `src/pages/Settings.tsx:1106`, `src/pages/Settings.tsx:1123` - -**Interfaces:** - -- Consumes: `events.passwordRequest`, `commands.submitPasswordResponse` from `src/bindings.ts` -- Produces: `PasswordContextType { requireLocalAuth: () => Promise }`. - `requestPassword` is **removed** — later tasks depend on it being gone. - -- [ ] **Step 1: Rewrite the provider** - -Replace the body of `src/contexts/PasswordContext.tsx` with: - -```tsx -import { PasswordDialog } from '@/components/dialogs/PasswordDialog'; -import { useBiometric } from '@/hooks/useBiometric'; -import { commands, events, PasswordRequest } from '@/bindings'; -import { platform } from '@tauri-apps/plugin-os'; -import { - createContext, - ReactNode, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; - -const isMobile = platform() === 'ios' || platform() === 'android'; - -// Biometric caching interval (5 minutes) -const BIOMETRIC_CACHE_MS = 5 * 60 * 1000; - -export interface PasswordContextType { - /** - * UI-only authentication gate for actions that touch no wallet secret - * (starting the RPC server, toggling run-on-startup). Returns true if the - * caller may proceed. This deliberately does NOT go through the Rust - * password gate — there is no unlock operation behind it. - */ - requireLocalAuth: () => Promise; -} - -export const PasswordContext = createContext( - undefined, -); - -export function PasswordProvider({ children }: { children: ReactNode }) { - const [pending, setPending] = useState(null); - const { enabled: biometricEnabled } = useBiometric(); - const lastBiometricPromptRef = useRef(null); - - const runBiometric = useCallback(async (): Promise => { - const now = performance.now(); - if ( - lastBiometricPromptRef.current !== null && - now - lastBiometricPromptRef.current < BIOMETRIC_CACHE_MS - ) { - return true; - } - try { - const { authenticate } = await import('@tauri-apps/plugin-biometric'); - await authenticate('Authenticate to continue', { - allowDeviceCredential: false, - }); - lastBiometricPromptRef.current = now; - return true; - } catch { - return false; - } - }, []); - - const requireLocalAuth = useCallback(async (): Promise => { - if (!biometricEnabled || !isMobile) return true; - return runBiometric(); - }, [biometricEnabled, runBiometric]); - - useEffect(() => { - const unlisten = events.passwordRequest.listen(async ({ payload }) => { - // Case 1: password takes precedence — show the dialog and wait. - if (payload.requiresPassword) { - setPending(payload); - return; - } - - // Case 2: no password, biometric enabled — standalone gate with cache. - if (biometricEnabled && isMobile) { - const ok = await runBiometric(); - await commands.submitPasswordResponse( - payload.requestId, - ok ? { kind: 'no_auth_needed' } : { kind: 'cancelled' }, - ); - return; - } - - // Case 3: no password, no biometric — nothing to do. - await commands.submitPasswordResponse(payload.requestId, { - kind: 'no_auth_needed', - }); - }); - - return () => { - unlisten.then((fn) => fn()); - }; - }, [biometricEnabled, runBiometric]); - - const handleSubmit = useCallback( - (password: string) => { - if (!pending) return; - setPending(null); - commands.submitPasswordResponse(pending.requestId, { - kind: 'password', - password, - }); - }, - [pending], - ); - - const handleCancel = useCallback(() => { - if (!pending) return; - setPending(null); - commands.submitPasswordResponse(pending.requestId, { kind: 'cancelled' }); - }, [pending]); - - return ( - - {children} - - - ); -} -``` - -Verify the generated `PasswordOutcome` discriminant names against `src/bindings.ts` — the Rust type uses -`#[serde(tag = "kind", rename_all = "snake_case")]`, so `no_auth_needed`, `cancelled`, and `password` -are expected. If the generated shape differs, match the bindings, not this snippet. - -`src/hooks/usePassword.ts` needs no change — it re-exports whatever the context provides. - -- [ ] **Step 2: Show the retry error in the dialog** - -`PasswordDialog` currently takes no error prop. Add an optional one so a wrong password re-prompts -visibly rather than silently reopening: - -In `src/components/dialogs/PasswordDialog.tsx`, extend `PasswordDialogProps` with -`attemptsRemaining?: number`, and render, below the existing `DialogDescription`: - -```tsx -{ - attemptsRemaining !== undefined && ( -

- Incorrect password. {attemptsRemaining} attempts remaining. -

- ); -} -``` - -Pass it from the provider: `attemptsRemaining={pending?.error?.attemptsRemaining}`. - -Do **not** run `pnpm run extract` for the new string — see Global Constraints. - -- [ ] **Step 3: Convert the Settings call sites** - -In `src/pages/Settings.tsx`, replace both `requestPassword(false)` gates. At line ~1106: - -```tsx -const start = async () => { - if (!(await requireLocalAuth())) return; - - commands - .startRpcServer() - .catch(addError) - .then(() => setIsRunning(true)); -}; -``` - -At line ~1123: - -```tsx -const toggleRunOnStartup = async (checked: boolean) => { - if (!(await requireLocalAuth())) return; - - commands - .setRpcRunOnStartup(checked) - .catch(addError) - .then(() => setRunOnStartup(checked)); -}; -``` - -Change the destructure at line ~1083 from `const { requestPassword } = usePassword();` to -`const { requireLocalAuth } = usePassword();`. - -- [ ] **Step 4: Verify** - -```bash -pnpm run build:frontend -pnpm run lint -``` - -Expected: `tsc -b` fails **only** in the files Tasks 8 and 9 will fix (`WalletCard.tsx`, -`ConfirmationDialog.tsx`, `useOfferProcessor.ts`, `Offer.tsx`, `WalletConnectContext.tsx`, -`src/walletconnect/*`), because `requestPassword` no longer exists. `Settings.tsx` and -`PasswordContext.tsx` must be clean. Record the remaining error list — it is the worklist for the next -two tasks. - -- [ ] **Step 5: Commit (only with user approval)** - -```bash -git add src/contexts/PasswordContext.tsx src/components/dialogs/PasswordDialog.tsx src/pages/Settings.tsx -git commit -m "feat(password-gate): invert PasswordContext into a responder" -``` - ---- - -## Task 8: Remove password plumbing from React call sites - -**Files:** - -- Modify: `src/components/WalletCard.tsx:69,82,180,196` -- Modify: `src/components/ConfirmationDialog.tsx:70,534,663` -- Modify: `src/hooks/useOfferProcessor.ts:31,65,188` -- Modify: `src/pages/Offer.tsx:37,111` -- Modify: `src/pages/Settings.tsx` — the `WalletSettings` component's `usePassword()` at ~:1167 and its `requestPassword` call gating `increaseDerivationIndex` - -**Interfaces:** - -- Consumes: `PasswordContextType` from Task 7 (no `requestPassword`) -- Produces: no component's public props change - -- [ ] **Step 1: Apply the mechanical edit in each file** - -Note on `Settings.tsx`: Task 7 converted two call sites in that file to `requireLocalAuth()` because they gate UI-only actions (starting the RPC server, run-on-startup) with no wallet secret behind them. The `WalletSettings` site is **different** and must be treated as an ordinary Task 8 site: `increase_derivation_index` is one of the 32 password-gated endpoints, so Rust now resolves its password itself. Strip the plumbing — do NOT convert it to `requireLocalAuth()`. After this task `usePassword()` should remain in the file only for the two `requireLocalAuth` consumers. - -In every listed file the pattern is identical. Remove: - -1. The `const { requestPassword } = usePassword();` destructure (and the whole `usePassword` import if - nothing else in the file uses it). -2. The `const password = await requestPassword(...); if (password === undefined) return;` guard. -3. The `password` property from the command argument object. -4. `requestPassword` from any `useCallback` / `useMemo` dependency array. - -Concretely, `src/pages/Offer.tsx:111` currently reads: - -```tsx -const password = await requestPassword(wallet?.has_password ?? false); -if (password === undefined) return; -``` - -Delete both lines, and drop `password` from the `commands.takeOffer({ ... })` argument below them. - -Keep the surrounding `try`/`catch` and `addError` handling exactly as-is. A cancelled prompt now -surfaces as a rejected command with the `Unauthorized` kind and the reason -`"Password entry cancelled"` — Task 10 makes the error handler silent for that case. - -- [ ] **Step 2: Verify** - -```bash -pnpm run build:frontend -pnpm run lint -``` - -Expected: the four files in this task no longer appear in the `tsc` error list. Only -`WalletConnectContext.tsx` and `src/walletconnect/*` remain. - -- [ ] **Step 3: Commit (only with user approval)** - -```bash -git add src/components/WalletCard.tsx src/components/ConfirmationDialog.tsx src/hooks/useOfferProcessor.ts src/pages/Offer.tsx -git commit -m "refactor(password-gate): drop password plumbing from React call sites" -``` - ---- - -## Task 9: Remove password plumbing from the WalletConnect layer - -**Files:** - -- Modify: `src/walletconnect/handler.ts:28` -- Modify: `src/walletconnect/commands/chip0002.ts:79,106` -- Modify: `src/walletconnect/commands/high-level.ts:50,87,97` -- Modify: `src/walletconnect/commands/offers.ts:9,38,55` -- Modify: `src/contexts/WalletConnectContext.tsx:69,109,146` - -**Interfaces:** - -- Consumes: `PasswordContextType` from Task 7 -- Produces: the handler context type loses both `requestPassword` and `hasPassword` - -- [ ] **Step 1: Narrow the handler context type** - -In `src/walletconnect/handler.ts`, delete line 28: - -```ts -requestPassword: (hasPassword: boolean) => Promise; -``` - -and delete the `hasPassword` field from the same interface if present. Nothing replaces them — the -Rust gate now owns this. - -- [ ] **Step 2: Strip the prompts from the command modules** - -In `chip0002.ts`, `high-level.ts`, and `offers.ts`, at each listed line, delete the pair: - -```ts -const password = await context.requestPassword(context.hasPassword); -if (password === undefined) throw new Error('Authentication failed'); -``` - -and remove `password` from the command argument object immediately below. In `chip0002.ts:109` the -call is `commands.signMessageWithPublicKey({ ...params, password })` — it becomes -`commands.signMessageWithPublicKey({ ...params })`. - -- [ ] **Step 3: Stop supplying the removed fields** - -In `src/contexts/WalletConnectContext.tsx`: - -- Delete `const { requestPassword } = usePassword();` (line ~69) and the `usePassword` import if now - unused. -- Delete `requestPassword,` from the context object passed to the handler (line ~109). -- Remove `requestPassword` and `wallet?.has_password` from the `useMemo`/`useCallback` dependency array - (line ~146). Leave `signClient`, `addError`, and `isReadOnly` in place. - -- [ ] **Step 4: Verify** - -```bash -pnpm run build:frontend -pnpm run lint -``` - -Expected: `tsc -b` now PASSES with zero errors, and `eslint` reports no new warnings. If -`wallet?.has_password` is now unused in this file, remove the `wallet` destructure too. - -**`tsc` alone does not prove this task is complete.** The four `src/walletconnect/` files type-check -against their own local `HandlerContext` interface rather than against `PasswordContextType`, so they -did not appear as compiler errors even while still calling `requestPassword`. A green build is -therefore necessary but not sufficient. Confirm the removal directly: - -```bash -grep -rn "requestPassword\|hasPassword" src/walletconnect/ src/contexts/WalletConnectContext.tsx -``` - -Expected: no matches. Any hit is an unremoved call site regardless of what `tsc` reports. - -- [ ] **Step 5: Commit (only with user approval)** - -```bash -git add src/walletconnect src/contexts/WalletConnectContext.tsx -git commit -m "refactor(password-gate): drop password plumbing from WalletConnect layer" -``` - ---- - -## Task 10: Silence cancelled-prompt errors - -A user dismissing the password dialog must not produce an error toast. Before this task, cancelling any -operation surfaces `"Password entry cancelled"` as a failure. - -**Files:** - -- Modify: `src/contexts/ErrorContext.tsx` - -**Interfaces:** - -- Consumes: `CANCELLED_REASON` value `"Password entry cancelled"` from Task 1, and `ErrorKind` - `unauthorized` -- Produces: no new public interface - -- [ ] **Step 1: Read the current error handling** - -```bash -grep -n "IncorrectPassword\|incorrect_password\|addError" src/contexts/ErrorContext.tsx | head -20 -``` - -Note how `addError` decides to surface an error, and whether an existing branch already special-cases -password errors. - -- [ ] **Step 2: Add the silent branch** - -In `src/contexts/ErrorContext.tsx`, inside `addError`, return early without displaying anything when -the error is a deliberate cancellation: - -```ts -const PASSWORD_CANCELLED_REASON = 'Password entry cancelled'; - -// ... inside addError, before any state update: -if ( - error.kind === 'unauthorized' && - error.reason === PASSWORD_CANCELLED_REASON -) { - return; -} -``` - -Match the exact string to `CANCELLED_REASON` in -`crates/sage-password-gate/src/resolve.rs`. If they -drift the toast reappears, so keep the comment noting the coupling. - -- [ ] **Step 3: Verify** - -```bash -pnpm run build:frontend -pnpm run lint -``` - -Expected: PASS. - -- [ ] **Step 4: Commit (only with user approval)** - -```bash -git add src/contexts/ErrorContext.tsx -git commit -m "fix(password-gate): stay silent when the user cancels the prompt" -``` - ---- - -## Task 11: Full verification and manual smoke - -No new code. This task proves the feature works end to end and that nothing regressed. - -**Files:** none modified. - -- [ ] **Step 1: Full workspace build and test** - -```bash -export SDKROOT="$(xcrun --show-sdk-path)" -cargo build --workspace -cargo test --workspace -``` - -Expected: all PASS. Pay particular attention to `sage-rpc` — those tests exercise the headless -password path and must be unchanged. Any failure there means the gate leaked into the core. - -- [ ] **Step 1b: Clear the one pre-existing clippy denial in `sage-rpc`** - -`crates/sage-rpc/src/tests.rs:353` has `old_password: "".to_string()`, which `clippy::manual_string_new` -denies. It predates this branch — verified identical on the `password` base branch — but Step 2 gates on -`-D warnings`, so it must go or the gate fails on code this plan did not write. - -This is the one authorised exception to the "do not modify `crates/sage-rpc/**`" constraint. That -constraint exists to stop the password gate leaking into the headless core; a `String::new()` lint fix in -a test file is not that. Change it to `old_password: String::new(),` and nothing else. Commit it -separately, labelled as a pre-existing lint fix, so it stays trivially separable from the feature. - -```bash -git add crates/sage-rpc/src/tests.rs -git commit -m "chore: fix pre-existing manual_string_new lint in sage-rpc tests" -``` - -- [ ] **Step 2: Lint and format** - -```bash -export SDKROOT="$(xcrun --show-sdk-path)" -cargo clippy --workspace --all-targets -- -D warnings -cargo fmt --all --check -pnpm run lint -pnpm run prettier:check -``` - -Expected: clean. Run `cargo fmt --all` and `pnpm run prettier` to fix formatting if needed. - -- [ ] **Step 3: Confirm no forbidden files changed** - -```bash -git diff --name-only password...HEAD | grep -E '^crates/sage/|^crates/sage-rpc/' || echo "clean: core untouched" -``` - -Expected: `clean: core untouched`. If anything is listed, review it against the Global Constraints -before proceeding. - -- [ ] **Step 4: Confirm no .po churn** - -```bash -git diff --name-only password...HEAD | grep '\.po$' || echo "clean: no lingui churn" -``` - -Expected: `clean: no lingui churn`. - -- [ ] **Step 5: Manual smoke — the three responder branches** - -```bash -export SDKROOT="$(xcrun --show-sdk-path)" -pnpm run tauri:dev -``` - -Walk through each and confirm: - -1. **Protected wallet, main UI.** Send a small amount of XCH. The password dialog appears. Enter the - wrong password twice — it re-prompts each time showing the remaining attempt count. Enter the - correct password — the transaction proceeds. -2. **Protected wallet, three strikes.** Repeat with three wrong passwords. The operation fails with an - authorization error, and the dialog closes. -3. **Cancel.** Start a send and dismiss the dialog. No error toast appears, and nothing is submitted. -4. **Unprotected wallet.** Send XCH. No dialog appears at all. -5. **Unprotected wallet with biometrics (mobile only).** The biometric gate appears, and a second - operation within five minutes does not re-prompt. - -- [ ] **Step 6: Manual smoke — the app bridge** - -With a **password-protected** wallet active, launch an app that calls `wallet.sendXch` and confirm: - -1. The `bridge-approval` app shows the transaction summary. -2. Approving it hides that app and reveals the password dialog **in the main window** — not inside the - app's webview. -3. The correct password completes the request; the app receives a success response. -4. Cancelling the password dialog fails the request and the app receives an error. -5. Grant the app `WalletSendXchAutoSubmit` and repeat: an approval **still** appears, because the - wallet is protected. -6. Switch to an **unprotected** wallet, keep the auto-submit grant, and repeat: no approval appears. - -- [ ] **Step 7: Manual smoke — WalletConnect** - -Pair a WalletConnect dApp against a protected wallet and confirm a signing request prompts for the -password in the main window and completes. - -- [ ] **Step 8: Commit any formatting fixes (only with user approval)** - -```bash -git add -A -git commit -m "chore(password-gate): formatting and lint fixes" -``` diff --git a/docs/superpowers/specs/2026-08-21-password-gate-smoke-tests.md b/docs/superpowers/specs/2026-08-21-password-gate-smoke-tests.md deleted file mode 100644 index bfa9d4a2a..000000000 --- a/docs/superpowers/specs/2026-08-21-password-gate-smoke-tests.md +++ /dev/null @@ -1,89 +0,0 @@ -# Password Gate — Manual Smoke Test Checklist - -**Branch:** `password-gate` -**Status:** outstanding — no automated coverage exists for these paths - -Every automated gate on this branch passes: workspace build (warning-free), full test -suite, the `sage-rpc` canary, clippy `-D warnings`, `cargo fmt`, eslint, prettier, and -the frontend build. What follows cannot be automated: the repository has no frontend -test runner, and these paths need a human at a running app. - -Launch with: - -```bash -export SDKROOT="$(xcrun --show-sdk-path)" -pnpm run tauri:dev -``` - -## 1. Main window, protected wallet - -- [ ] Send a small amount of XCH. **No dialog appears while the transaction is being - built** — the password dialog comes up only after Submit on the confirmation - dialog, and exactly once. Two dialogs for one send means an endpoint is filed - as `always` in `password-gating.json` when it should be `auto_submit`. -- [ ] Enter the wrong password twice. It re-prompts each time, showing the remaining - attempt count, and reads "1 attempt remaining" (singular) on the last try. -- [ ] Enter the correct password. The transaction proceeds. -- [ ] Repeat with three wrong passwords. A toast reads "Too many incorrect password - attempts" and the dialog closes. **It must not fail silently.** -- [ ] Start a send and dismiss the dialog. No error toast appears and nothing is submitted. - -## 2. Main window, unprotected wallet - -- [ ] Send XCH. No dialog appears at all, at either stage. -- [ ] Mobile only, biometrics enabled: the biometric gate appears, and a second - operation within five minutes does not re-prompt. - -## 3. Logged-out wallet list — the regression the final review caught - -These two broke completely and silently at one point; they are the highest-value checks here. - -- [ ] Log out. On the wallet list, open "Wallet details" on an **unprotected** wallet. - It must work. -- [ ] Same, on a **protected** wallet — including one that is not the last-active - wallet. It must prompt for **that wallet's** password, not another's. -- [ ] Delete a protected wallet from the logged-out list. Same expectation. -- [ ] While logged in to wallet A, delete or inspect protected wallet B. The prompt - must name B and accept B's password. - -## 4. App bridge, protected wallet - -- [ ] Launch an app that calls `wallet.sendXch`. The `bridge-approval` app shows the - transaction summary. -- [ ] Approving hides that app and reveals the password dialog **in the main window** — - not inside the app's webview. -- [ ] The correct password completes the request; the app receives success. -- [ ] Cancelling the dialog fails the request; the app receives an error. -- [ ] Grant the app `WalletSendXchAutoSubmit` and repeat: an approval **still** appears, - because the wallet is protected. -- [ ] Queue two approvals and answer the first. The second must remain reachable — - the approval window must not be left hidden. - -## 5. App bridge, unprotected wallet - -- [ ] With the auto-submit grant, send XCH: no approval appears. -- [ ] Approve a `wallet.getSecretKey` request: the approval window must **not** flicker - away and back for a prompt that never comes. - -## 6. WalletConnect - -- [ ] Pair a dApp against a protected wallet. A signing request prompts for the password - in the main window and completes. - -## 7. Timeout - -- [ ] Start a gated operation and leave the prompt untouched for five minutes. A toast - reads "Password prompt timed out". A late Submit or Cancel must not throw an - unhandled rejection in the console. - -## 8. Trust boundary (optional, verifies a known limitation) - -The `password-request` event targets the `main` webview, but Tauri's listener matching -short-circuits for `EventTarget::Any` listeners, so an app webview holding -`core:event:allow-listen` can observe it. The payload was reduced to carry nothing that -identifies a wallet, and the password itself only ever returns via -`submit_password_response`, which is ACL-denied to app webviews. - -- [ ] In an installed app's devtools, listen for `password-request` and trigger a gated - operation. Confirm the payload contains **no** `fingerprint` — only `requestId`, - `requiresPassword`, `attempt`, and `error`. From 13ca0e2c823b75716f913b46137ec0cbec207bcc Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sat, 22 Aug 2026 11:03:49 -0500 Subject: [PATCH 31/36] docs: reconcile the password docs with the implementation The 2026-03-15 design and the 2026-03-16 matrix both described password prompting as a frontend responsibility -- requestPassword at ~16 call sites, enforcement split between ConfirmationDialog and per-call-site prompts. None of that survives the password gate. Corrected against the code rather than against the earlier drafts: - has_password reads Wallet::password_protected from the wallet config, not a field on KeyData::Secret. keys.bin was never migrated, so the migration section describing a versioned deserialization fallback was fiction. - delete_key verifies in Rust; the frontend no longer probes get_secret_key first. - ImportKey has no password field. Keys import under the b"" sentinel and are protected afterwards through Settings. - Error feedback is a table of the six ErrorContext branches, including the incorrect_password kind and the reconcileKeyProtection self-heal. The matrix is rebuilt around gate modes, with a WalletConnect table and a new app-bridge table, and records one over-prompt the gate introduced: increase_derivation_index is `always`, but its extract_secrets call sits inside `if hardened`, so an unhardened-only call now prompts for a password it will not use. Co-Authored-By: Claude Opus 5 --- .../2026-03-15-password-protection-design.md | 184 ++++++++----- .../generated/2026-03-16-protection-matrix.md | 246 ++++++++++++------ .../2026-08-21-password-gate-design.md | 55 ++-- 3 files changed, 321 insertions(+), 164 deletions(-) diff --git a/docs/generated/2026-03-15-password-protection-design.md b/docs/generated/2026-03-15-password-protection-design.md index ace7d0d2c..93f247cef 100644 --- a/docs/generated/2026-03-15-password-protection-design.md +++ b/docs/generated/2026-03-15-password-protection-design.md @@ -2,17 +2,29 @@ **Issue:** [xch-dev/sage#206](https://github.com/xch-dev/sage/issues/206) **Date:** 2026-03-15 -**Status:** Implemented +**Status:** Implemented; the frontend half superseded by +[2026-08-21-password-gate-design.md](2026-08-21-password-gate-design.md) + +> **What is still current:** the keychain and encryption model, the sentinel convention, the set of +> protected operations, and password management in Settings. +> +> **What has moved:** who decides that authentication is needed, and who collects it. This document +> describes a frontend that calls `requestPassword(hasPassword)` at ~16 call sites and threads the +> result into each command. That is no longer how it works. Rust now decides and prompts, and no +> caller supplies a password. The sections below marked _(superseded)_ are kept for the history of +> why the backend looks the way it does; read the password-gate design for current behaviour. ## 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. 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. +The prompt itself is now driven from Rust rather than from the call site; the decision tree below survives intact, but it runs inside `PasswordContext` as a _responder_ to a `PasswordRequest` event. + ## 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. +- **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. Which key's password is being asked for is resolved in Rust: the gate uses the active wallet, except for endpoints carrying a `fingerprint`, which name their own wallet. - **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 @@ -36,9 +48,21 @@ The empty byte string `b""` is the "no password" sentinel. This is what existing ### 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`. +`KeyInfo` carries `has_password: bool`, returned by `get_key()` / `get_keys()`. + +**As implemented**, the flag is read from `Wallet::password_protected` in the wallet config +(`crates/sage/src/endpoints/keys.rs:357` and `:419`) — a cheap field lookup, not a trial decrypt. +`keys.bin` is untouched; `KeyData::Secret` has no such field. An early draft proposed storing it in +`KeyData::Secret`, which would have changed the `keys.bin` serialization format and required a +versioned deserialization fallback. That draft was not built. + +Because a config file and a `keys.bin` can drift apart, two things reconcile the flag against +reality, both by trial-decrypting with `b""` via `Keychain::is_password_protected`: -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. +- `Sage::switch_wallet` self-heals on login. +- The `reconcile_key_protection` endpoint self-heals on demand. The frontend calls it from + `ErrorContext` when a decrypt fails on a wallet whose flag says "no password" — that mismatch is + the drift signal — so the next attempt prompts correctly. ### Biometric Gate (Mobile) @@ -54,13 +78,15 @@ Biometric is a frontend-only concern, mutually exclusive with password. It serve ## 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. +There are 8 `extract_secrets` call sites, plus 2 encrypt-at-creation sites that still use the `b""` +sentinel. Because `sign()` is reached through `transact()` and `transact_with()`, the password flows +through a much larger API surface than that count suggests. ### 1. Display mnemonic/secret key (1 site) | Call site | Function | | --------------------------------------- | ------------------ | -| `crates/sage/src/endpoints/keys.rs:353` | `get_secret_key()` | +| `crates/sage/src/endpoints/keys.rs:367` | `get_secret_key()` | ### 2. Sign transactions and offers @@ -74,11 +100,12 @@ endpoint method → transact() / transact_with() → sign() → extract_secrets( | 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()` | +| `crates/sage/src/utils/spends.rs:17` | `sign()` — called by `transact_with()` and `sign_coin_spends()` | +| `crates/sage/src/endpoints/offers.rs:172` | `make_offer()` — calls `extract_secrets` directly | +| `crates/sage/src/endpoints/offers.rs:212` | `take_offer()` — calls `extract_secrets` directly | +| `crates/sage/src/endpoints/wallet_connect.rs:186` | `sign_message_with_public_key()` | +| `crates/sage/src/endpoints/wallet_connect.rs:227` | `sign_message_by_address()` | +| `crates/sage/src/endpoints/keys.rs:279` | `delete_key()` — verifies before an irreversible delete | **Transaction endpoints that flow through `transact()` → `sign()`** (21 endpoints): @@ -88,24 +115,35 @@ Plus `cancel_offer`, `cancel_offers`, and `create_transaction` (action system) w ### 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. +Password-protected wallets require password verification before deletion. This is enforced in Rust: +`delete_key()` takes a `password` and calls `extract_secrets` itself when +`keychain.is_password_protected(req.fingerprint)` +(`crates/sage/src/endpoints/keys.rs:277`). Deletion is irreversible, so it does not trust a caller to +have checked. -| Call site | Function | -| ------------------- | --------------------------------------------------------------- | -| `WalletCard.tsx:81` | `deleteSelf()` — verifies via `getSecretKey` before `deleteKey` | +An earlier draft verified on the frontend by calling `get_secret_key()` first and blocking the delete +if decryption failed. `WalletCard.deleteSelf()` now simply calls `deleteKey`. + +| Call site | Function | +| ---------------- | --------------------------------------------------- | +| `WalletCard.tsx` | `deleteSelf()` — calls `deleteKey` and nothing else | ### 4. Generate hardened keys (1 site) | Call site | Function | | ------------------------------------------ | ----------------------------- | -| `crates/sage/src/endpoints/actions.rs:201` | `increase_derivation_index()` | +| `crates/sage/src/endpoints/actions.rs:204` | `increase_derivation_index()` | -### 4. Key import — encrypt at creation (2 sites) +### 5. 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 | +| `crates/sage/src/endpoints/keys.rs:143` | `import_key()` — secret key path | +| `crates/sage/src/endpoints/keys.rs:180` | `import_key()` — mnemonic path | + +**Import takes no password.** Both sites pass the `b""` sentinel, and `ImportKey` has no `password` +field at all — an early draft added one, but the shipped design sets a password afterwards through +Settings instead. This is why `import_key` is absent from the gating manifest. 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. @@ -140,7 +178,6 @@ Add `password: Option` to **all request structs that trigger signing, se **Direct secret access:** -- `ImportKey` - `GetSecretKey` - `DeleteKey` — deletion is irreversible, so `delete_key` verifies the password itself rather than trusting the frontend to have checked @@ -160,20 +197,27 @@ Add `password: Option` to **all request structs that trigger signing, se - `SignMessageWithPublicKey`, `SignMessageByAddress` -**New request/response pair:** +**New request/response pairs:** - `ChangePassword { fingerprint: u32, old_password: String, new_password: String }` - `ChangePasswordResponse {}` +- `ReconcileKeyProtection { fingerprint: u32 }` +- `ReconcileKeyProtectionResponse { has_password: bool }` **`KeyInfo`** — add `has_password: bool` field. +Every request type carrying `password: Option` is now also an entry in +`crates/sage-api/password-gating.json`, and a drift test fails the build if the two sets diverge. The +field stays because `sage-rpc` clients supply it over mTLS; the Tauri host layer overwrites it with a +password it resolved itself. + ### `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`. +**`keys.rs`**: `get_secret_key()` and `delete_key()` pass password to `extract_secrets()`. `get_key()`/`get_keys()` populate `has_password` from the wallet config. `import_key()` is unchanged — it still encrypts with `b""`. **`offers.rs`**: `make_offer()`, `take_offer()` pass password to `extract_secrets()`. `cancel_offer()`, `cancel_offers()` pass password to `transact()`. @@ -185,21 +229,16 @@ New `change_password()` endpoint. ### Frontend (TypeScript/React) -#### PasswordContext (`src/contexts/PasswordContext.tsx`) +#### PasswordContext (`src/contexts/PasswordContext.tsx`) — _(superseded)_ -A React context provider that serves as the **single entry point for all operation authentication** — password or biometric (never both). Provides: +**As originally built**, this provider was the single entry point callers invoked: ```typescript -requestPassword(hasPassword: boolean, fingerprint?: number): Promise +requestPassword(hasPassword: boolean, fingerprint?: number): Promise; ``` -**Return values:** - -- `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 (mutually exclusive):** +`string` meant "use this password", `null` meant "no auth needed", `undefined` meant "cancelled — +abort". Its decision tree was: ```text hasPassword=true → show password dialog (password always takes precedence) @@ -208,53 +247,63 @@ hasPassword=false, biometric not enabled → return null (no auth needed) cancelled at any point → return undefined ``` -On desktop (no biometric available), the biometric path is skipped — behaves as if biometric is not enabled. +**As it works now**, the provider is a responder, not a callable. It subscribes to the Rust +`PasswordRequest` event, runs that same three-way decision tree unchanged, and replies with +`submit_password_response(request_id, Password | NoAuthNeeded | Cancelled)`. Requests are queued by +`requestId`, so overlapping prompts cannot interleave. `requestPassword` is gone; the only export is +`requireLocalAuth()`, a UI-only biometric gate for the two `Settings.tsx` actions that touch no wallet +secret (starting the RPC server, toggling run-on-startup) and therefore have no Rust operation to hang +a prompt off. -Uses a `useRef`-based pending promise pattern to bridge the dialog UI with the async call site. +The mutual-exclusivity rule, the 5-minute biometric cache, and the desktop fallback are all preserved +bit-for-bit. What changed is the direction of the call. See +[2026-08-21-password-gate-design.md](2026-08-21-password-gate-design.md). -**Provider placement:** Inside `I18nProvider` and `WalletProvider`. Wraps `WalletConnectProvider` and all downstream providers, so `usePassword()` is available everywhere. +**Provider placement:** Inside `I18nProvider` and `WalletProvider`. Wraps `WalletConnectProvider` and +all downstream providers. -Provider tree: `BiometricProvider` → `I18nProvider` → `WalletProvider` → `PasswordProvider` → `PeerProvider` → `WalletConnectProvider` → `PriceProvider` → `RouterProvider` +Provider tree: `BiometricProvider` → `I18nProvider` → `WalletProvider` → `PasswordProvider` → +`PeerProvider` → `WalletConnectProvider` → `PriceProvider` → `RouterProvider` #### PasswordDialog (`src/components/dialogs/PasswordDialog.tsx`) -A reusable modal dialog rendered by `PasswordProvider`. Features: +A reusable modal dialog rendered by `PasswordProvider`. Unchanged by the password gate. 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 `undefined` (auth cancelled) +- Cancel resolves the prompt as cancelled + +Retries are driven from Rust: a wrong password re-emits the same `requestId` with an incremented +`attempt` and the remaining-attempt count, up to three attempts. #### usePassword hook (`src/hooks/usePassword.ts`) Thin wrapper around `PasswordContext` with a guard that throws if used outside `PasswordProvider`. +`Settings.tsx` is its only consumer. -#### Call site pattern +#### Call site pattern — _(superseded)_ -Every protected operation follows the same unified pattern — a single call that handles password or biometric: +Every protected operation used to open with: ```typescript const password = await requestPassword(wallet?.has_password ?? false); 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: +**No call site does this any more.** `requestPassword` has no remaining references in `src/`. Call +sites simply invoke the command; if authentication is needed, Rust prompts before executing and +returns `Unauthorized` if it is refused. The files that carried the old plumbing — +`ConfirmationDialog.tsx`, `WalletCard.tsx`, `Settings.tsx` (`increaseDerivationIndex`), `Offers.tsx`, +`OfferRowCard.tsx`, `useOfferProcessor.ts`, `Offer.tsx`, and the WalletConnect command layer — were +all stripped. -| File | Operations | -| -------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `ConfirmationDialog.tsx` | `signCoinSpends` (Sign Transaction button, Submit button) | -| `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) | -| `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 — _(superseded)_ -#### 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. +`HandlerContext` was extended with `requestPassword` and `hasPassword`, and each handler prompted +before executing. Both fields have since been removed from the handler context, and +`src/walletconnect/commands/{chip0002,high-level,offers}.ts` no longer prompt. Because these +handlers set `auto_submit: true`, the gate fires inside the Rust command. #### Password management in Settings @@ -268,7 +317,19 @@ All three operations call `commands.changePassword()` with appropriate `old_pass #### 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. +All password feedback is centralized in `ErrorContext.addError`, so no call site handles it: + +| Error | Result | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `incorrect_password` | "Incorrect password" toast, then a `reconcileKeyProtection` probe if the flag says the wallet is unprotected | +| `unauthorized` / "Password entry cancelled" | Silent — a deliberate cancellation is not a failure | +| `unauthorized` / "Too many incorrect password attempts" | Translated toast | +| `unauthorized` / "Password prompt timed out" | Translated toast | +| `unauthorized` containing "not found" / "No secret" | Toast with the raw reason | +| other `unauthorized` | Silent — `NotLoggedIn` / `NoSigningKey` during wallet transitions | + +The three gate reasons are matched on the exact Rust reason string and re-rendered as translated +text; the constants are duplicated in `ErrorContext.tsx` with comments naming their Rust source. #### Settings UI changes @@ -279,7 +340,7 @@ The biometric toggle remains in the **Preferences** section of Global Settings ( - **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. 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. +- **Unified auth entry point** — a single decision tree handles password and biometric, subsuming the standalone `promptIfEnabled()` check. `BiometricContext` continues to exist for state management (`enabled`, `available`) and to run the prompt, but nothing calls `promptIfEnabled()` at an operation site. Since the password gate, the entry point is a Rust event rather than a function call. - **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. @@ -295,7 +356,11 @@ The biometric toggle remains in the **Preferences** section of Global Settings ( 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. +**No `keys.bin` migration is needed.** The has-password flag lives in the wallet config, which +defaults `password_protected` to `false`, so existing `config.toml` files deserialize unchanged and +existing `keys.bin` files are read by the same code as before. (An earlier draft put the flag in +`KeyData::Secret` and would have needed a versioned deserialization fallback; see +**Has-Password Indicator**.) ## What's NOT Changing @@ -303,5 +368,6 @@ The `keys.bin` format change (adding `password_protected` to `KeyData::Secret`) - `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 gate. It's entirely frontend. +- Backend — no backend changes for the biometric gate. Rust never learns whether biometrics are enabled; it emits a prompt request and the frontend decides how to satisfy it. +- `keys.bin` format — `KeyData::Secret` is unchanged. - Biometric — remains as a standalone gate for no-password wallets. `BiometricContext` provides `enabled`/`available` state; `PasswordContext` handles the actual biometric prompt internally. diff --git a/docs/generated/2026-03-16-protection-matrix.md b/docs/generated/2026-03-16-protection-matrix.md index 323f3f44c..b6b800d40 100644 --- a/docs/generated/2026-03-16-protection-matrix.md +++ b/docs/generated/2026-03-16-protection-matrix.md @@ -1,90 +1,166 @@ # Operation Protection Matrix -Analysis of biometric vs. password protection across all protected operations in Sage wallet. +Coverage of password and biometric protection across every operation in Sage that can reach a wallet +secret. + +**Revised 2026-08-22** for the password gate. The previous revision described enforcement as a +frontend responsibility split between `ConfirmationDialog` and per-call-site `requestPassword` calls. +Neither exists any more — see +[2026-08-21-password-gate-design.md](2026-08-21-password-gate-design.md). ## 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 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 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 | ✅ | Direct `requestPassword` | OK | `Offer.tsx:105` — signs unconditionally; page prompts | -| Cancel offer | ✅ | ConfirmationDialog | OK | `OfferRowCard.tsx` — `cancel_offer` defers to `transact()` | -| Cancel all offers | ✅ | ConfirmationDialog | OK | `Offers.tsx` — `cancel_offers` defers to `transact()` | -| **Secrets / Key Management** | | | | | -| View mnemonic / secret key | ✅ | Direct `requestPassword` | OK | `WalletCard.tsx:194` | -| Delete wallet key | ✅ | Direct `requestPassword` | OK | `WalletCard.tsx:82` — enforced in `delete_key` (Rust) | -| Import key (secret/mnemonic) | ❌ | None | OK | UI sends no password; set later in Settings (API field exists) | -| Set / Change / Remove password | ✅ | Inline form (not `requestPassword`) | OK | `Settings.tsx:1238` | -| **Key Derivation** | | | | | -| Increase derivation (hardened) | ✅ | Direct `requestPassword` | OK | `Settings.tsx:1269` | -| Increase derivation (unhardened) | ❌ | None | OK | No private key needed | -| **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 | -| 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 | - -## Remaining Design Considerations - -1. **Session caching asymmetry** — biometric caches for 5 minutes; password never caches. +Rust decides when authentication is required and prompts for it. No caller supplies a password. + +`crates/sage-api/password-gating.json` assigns every password-bearing endpoint one of three modes, +and the endpoint macro expands the gate accordingly: + +| Mode | Gate behaviour | +| ------------- | ----------------------------------------------------------------------------- | +| `always` | prompt on every call, against the active wallet | +| `fingerprint` | prompt on every call, against `req.fingerprint` rather than the active wallet | +| `auto_submit` | prompt only when `req.auto_submit` is set; otherwise clear `req.password` | + +`auto_submit` mode exists because those endpoints only build a transaction until the caller asks for +it to be submitted. This is what decides **where the prompt lands**: + +- **Normal UI flows** call the endpoint with `auto_submit` unset, get unsigned coin spends back, and + render `ConfirmationDialog`. Nothing is asked for during the build. The dialog's Submit button + calls `sign_coin_spends`, which is `always`, so exactly one prompt appears — after Submit. + (If the user pressed Sign first, Submit reuses that signature and does not prompt again.) +- **WalletConnect handlers** set `auto_submit: true`, so signing happens inside the command and the + prompt lands there. +- **App bridge requests** do not go through the endpoint macro. They have their own gate in + `process_after_approval`, which runs after the user approves the request summary and before the + handler executes. It is unconditional for the four approval bodies that reach a secret. + +Password and biometric remain mutually exclusive. `PasswordContext` now answers a Rust +`PasswordRequest` event instead of being called, but its three-way decision — password dialog, +biometric gate with a 5-minute cache, or `NoAuthNeeded` — is unchanged. A wallet with a password set +never triggers biometric. + +## Matrix — UI operations + +Legend: ✅ = reaches a secret and is gated, ❌ = reaches no secret, no gate needed + +| Operation | Endpoint | Mode | Prompt appears | Call site | +| --------------------------------- | --------------------------- | ------------- | ------------------ | --------------------------------------- | +| **Transactions** | | | | | +| Send XCH | `send_xch` | `auto_submit` | ConfirmationDialog | `Send.tsx` | +| Send CAT | `send_cat` | `auto_submit` | ConfirmationDialog | `Send.tsx` | +| Bulk send XCH | `bulk_send_xch` | `auto_submit` | ConfirmationDialog | `Send.tsx` | +| Bulk send CAT | `bulk_send_cat` | `auto_submit` | ConfirmationDialog | `Send.tsx` | +| Combine coins | `combine` | `auto_submit` | ConfirmationDialog | `OwnedCoinsCard.tsx` | +| Split coins | `split` | `auto_submit` | ConfirmationDialog | `OwnedCoinsCard.tsx` | +| Auto-combine XCH / CAT | `auto_combine_xch` / `_cat` | `auto_submit` | ConfirmationDialog | `OwnedCoinsCard.tsx` | +| Issue CAT | `issue_cat` | `auto_submit` | ConfirmationDialog | `IssueToken.tsx` | +| Multi-send | `multi_send` | `auto_submit` | — | No frontend binding | +| Action system | `create_transaction` | `auto_submit` | ConfirmationDialog | No frontend binding | +| Sign coin spends (Sign button) | `sign_coin_spends` | `always` | immediately | `ConfirmationDialog.tsx` | +| Sign coin spends (Submit button) | `sign_coin_spends` | `always` | immediately | `ConfirmationDialog.tsx` | +| **NFTs / DIDs** | | | | | +| Bulk mint NFTs | `bulk_mint_nfts` | `auto_submit` | ConfirmationDialog | `MintNft.tsx` | +| Transfer NFTs | `transfer_nfts` | `auto_submit` | ConfirmationDialog | `MultiSelectActions.tsx` | +| Burn NFTs | `transfer_nfts` | `auto_submit` | ConfirmationDialog | `MultiSelectActions.tsx` | +| Add NFT URI | `add_nft_uri` | `auto_submit` | ConfirmationDialog | `NftCard.tsx` | +| Assign NFTs to DID | `assign_nfts_to_did` | `auto_submit` | ConfirmationDialog | `NftCard.tsx`, `MultiSelectActions.tsx` | +| Create DID | `create_did` | `auto_submit` | ConfirmationDialog | `CreateProfile.tsx` | +| Transfer / burn DIDs | `transfer_dids` | `auto_submit` | ConfirmationDialog | `DidList.tsx` | +| Normalize DIDs | `normalize_dids` | `auto_submit` | ConfirmationDialog | `DidList.tsx` | +| **Options** | | | | | +| Mint option | `mint_option` | `auto_submit` | ConfirmationDialog | `MintOption.tsx` | +| Transfer / burn options | `transfer_options` | `auto_submit` | ConfirmationDialog | `useOptionActions.tsx` | +| Exercise options | `exercise_options` | `auto_submit` | ConfirmationDialog | `useOptionActions.tsx` | +| **Clawback** | | | | | +| Claw back coins | `combine` | `auto_submit` | ConfirmationDialog | `ClawbackCoinsCard.tsx` | +| Finalize clawback | `finalize_clawback` | `auto_submit` | ConfirmationDialog | `ClawbackCoinsCard.tsx` | +| **Offers** | | | | | +| Make offer | `make_offer` | `always` | immediately | `useOfferProcessor.ts` | +| Take offer | `take_offer` | `always` | immediately | `Offer.tsx` | +| Cancel offer | `cancel_offer` | `auto_submit` | ConfirmationDialog | `OfferRowCard.tsx` | +| Cancel all offers | `cancel_offers` | `auto_submit` | ConfirmationDialog | `Offers.tsx` | +| **Secrets / key management** | | | | | +| View mnemonic / secret key | `get_secret_key` | `fingerprint` | immediately | `WalletCard.tsx` | +| Delete wallet key | `delete_key` | `fingerprint` | immediately | `WalletCard.tsx` | +| Import key | `import_key` | ❌ ungated | — | `CreateWallet.tsx`, `ImportWallet.tsx` | +| Set / change / remove password | `change_password` | ❌ ungated | inline form | `PasswordManagementDialog.tsx` | +| **Key derivation** | | | | | +| Increase derivation (hardened) | `increase_derivation_index` | `always` | immediately | `Settings.tsx` | +| Increase derivation (unhardened) | `increase_derivation_index` | `always` | immediately | `Settings.tsx` | +| **Ungated by design** | | | | | +| Start RPC server, run-on-startup | — | ❌ | `requireLocalAuth` | `Settings.tsx` | +| Submit pre-signed transaction | `submit_transaction` | ❌ | — | Operates on a signed bundle | +| View coin spends | `view_coin_spends` | ❌ | — | Read-only | +| Balances, addresses, NFTs | — | ❌ | — | Read-only | +| Login / logout | — | ❌ | — | No secret access | +| Rename / resync / emoji | — | ❌ | — | Metadata only | +| Enable / disable biometric toggle | — | ❌ | — | No-op on protected wallets | + +Notes: + +- **`import_key` is genuinely ungated.** `ImportKey` has no `password` field; keys are imported + encrypted with the `b""` sentinel and protected afterwards through Settings. +- **`change_password` is deliberately ungated.** Its `old_password` is management form data, not a + wallet unlock, and the form collects it directly. +- **`increase_derivation_index` over-prompts on the unhardened path.** Its `extract_secrets` call is + inside `if hardened` (`crates/sage/src/endpoints/actions.rs:204`), so an unhardened-only call + reaches no secret — but the mode is per-endpoint, so the gate prompts anyway. `Settings.tsx` calls + it with `hardened: false` whenever the wallet has no secrets or hardened derivation is off, and a + protected wallet gets a password dialog for a request that will not use the answer. Before the + password gate the frontend suppressed this itself with `has_secrets && hardened`. Expressing it + would need a fourth gating mode conditioned on `req.hardened`; the same shape as `auto_submit`. +- **Clawback and auto-combine reuse `combine`**, which is why they inherit its mode. +- Two endpoints carry an `auto_submit` field but are `always`: `sign_coin_spends` and `take_offer` + both reach the keychain before consulting it. A drift test enforces this against the + implementations rather than against the field's presence. + +## Matrix — WalletConnect operations + +WC handlers set `auto_submit: true` (except `chip0002_signCoinSpends`), so the gate fires inside the +command. `HandlerContext` no longer carries `requestPassword` or `hasPassword`. + +| Operation | Endpoint | `auto_submit` | Mode | Prompts | +| ------------------------------------- | ------------------------------ | :-----------: | ------------- | :-----: | +| `chia_send` (XCH / CAT) | `send_xch` / `send_cat` | `true` | `auto_submit` | ✅ | +| `chia_bulkMintNfts` | `bulk_mint_nfts` | `true` | `auto_submit` | ✅ | +| `chia_createOffer` | `make_offer` | no field | `always` | ✅ | +| `chia_takeOffer` | `take_offer` | `true` | `always` | ✅ | +| `chia_cancelOffer` | `cancel_offer` | `true` | `auto_submit` | ✅ | +| `chip0002_signCoinSpends` | `sign_coin_spends` | `false` | `always` | ✅ | +| `chip0002_signMessage` | `sign_message_with_public_key` | no field | `always` | ✅ | +| `chia_signMessageByAddress` | `sign_message_by_address` | no field | `always` | ✅ | +| WC read-only (connect, chainId, etc.) | — | — | ❌ | — | + +`chip0002_signCoinSpends` is the one case where `auto_submit: false` still prompts, because +`sign_coin_spends` signs before it looks at the flag. + +## Matrix — app bridge operations + +Bridge requests are gated in `process_after_approval`, not by the endpoint macro. The gate runs after +the user approves the request summary in the `bridge-approval` system app and before the handler +executes; on a protected wallet the approval runtime is hidden first, because app runtimes are +sibling webviews that would otherwise cover the main webview's dialog. + +| Approval body | Bridge method | Gated | Target wallet | +| ----------------------- | ---------------------------- | :---: | -------------------- | +| `SendXch` | `wallet.sendXch` | ✅ | active | +| `SignCoinSpends` | `wallet.signCoinSpends` | ✅ | active | +| `SignMessage` | `wallet.signMessage` | ✅ | active | +| `GetSecretKey` | `wallet.getSecretKey` | ✅ | named by fingerprint | +| `CapabilityGrant` | `app.requestCapabilityGrant` | ❌ | — | +| `NetworkWhitelistGrant` | — | ❌ | — | + +`approval_requires_password` matches the body exhaustively with no catch-all, so a new approval body +must decide deliberately. + +A password-protected wallet always gets an approval dialog even when the app holds the +`wallet.send_xch_auto_submit` capability. Silent auto-submit is incompatible with password +protection. + +## Remaining design considerations + +1. **Session caching asymmetry** — biometric caches for 5 minutes; password never caches. Session + unlock is deferred; see the password-gate design's non-goals. +2. **`requires_password` is observable** — the `PasswordRequest` event can be received by an app + webview, so it deliberately carries no wallet identity and no password. `requires_password` still + tells a listener whether the active wallet is protected. diff --git a/docs/generated/2026-08-21-password-gate-design.md b/docs/generated/2026-08-21-password-gate-design.md index 7e3782c25..ce2ab415a 100644 --- a/docs/generated/2026-08-21-password-gate-design.md +++ b/docs/generated/2026-08-21-password-gate-design.md @@ -2,7 +2,7 @@ **Date:** 2026-08-21 **Branch:** `password-gate` (off `password`) -**Status:** Approved design, ready for implementation planning +**Status:** Implemented ## Problem @@ -146,37 +146,37 @@ decrypt, not a partially built transaction. ### Gating the endpoints -Every endpoint command is generated from a single `repeat` block (`src-tauri/src/commands.rs:67-75`) +Every endpoint command is generated from a single `repeat` block (`src-tauri/src/commands.rs:68-83`) driven by `crates/sage-api/endpoints.json`. The gate therefore goes in exactly one place. -Add a `maybe_unlock` token to `crates/sage-api/macro/src/lib.rs` alongside `maybe_async` and -`maybe_await`, driven by a new gated-endpoint set. The repeat block becomes: +A `maybe_unlock` token in `crates/sage-api/macro/src/lib.rs` sits alongside `maybe_async` and +`maybe_await`, driven by the gating manifest above. The repeat block is: ```rust +#[command] +#[specta] +#[allow(unused_variables, unused_mut)] pub async fn endpoint( app_handle: AppHandle, state: State<'_, AppState>, gate: State<'_, PasswordGateState>, mut req: Endpoint, ) -> Result { - maybe_unlock; // expands to: req.password = gate.resolve(&app_handle, &state).await?; + maybe_unlock Ok(state.lock().await.endpoint(req) maybe_await?) } ``` -`maybe_unlock` expands to nothing for ungated endpoints. - -Drift is prevented by `sage-api` tests asserting that the gated set is exactly the set of request -types carrying a `password` field, and that the fingerprint-targeted subset is exactly the gated -request types that also carry a `fingerprint` field. Adding a signing endpoint without gating it -fails the build; adding a fingerprint to a gated request type without listing it fails the test. +`maybe_unlock` expands to nothing for endpoints absent from the manifest, so the ~91 ungated +commands are byte-identical to what they were before the gate existed. The `#[allow]` covers those +expansions, where `app_handle`, `gate`, and `mut` all go unused. ### Bridge path (apps) The two dialogs are sequential. The user approves the request summary in the `bridge-approval` system app exactly as today; the password is then collected in the main webview. -In `process_after_approval` (`crates/sage-apps/src/bridge/bridge_request.rs:83`), after `approved == +In `process_after_approval` (`crates/sage-apps/src/bridge/bridge_request.rs:86`), after `approved == true` and the wallet-binding check passes, and before `process_shared`: 1. If the target wallet is password-protected, hide the `bridge-approval` runtime via @@ -194,9 +194,19 @@ Because approval and password are now two phases, the original `expires_at_ms` k the prompt. A lapse mid-prompt fails with `approval_timeout`. One clock, no new concept, and an app cannot hold a signing path open indefinitely. +Only the four approval bodies that reach a wallet secret are gated — `GetSecretKey`, `SendXch`, +`SignCoinSpends`, `SignMessage`. `approval_requires_password` matches on the body exhaustively, with +no catch-all, so `CapabilityGrant` and `NetworkWhitelistGrant` prompt for nothing and a new body must +opt in deliberately. + The verified password is threaded into the handler through `BridgeTools`, so the conversions in -`send_xch.rs`, `sign_message.rs`, and `sign_coin_spends.rs` set `Some(...)` instead of the hardcoded -`None`. +`send_xch.rs`, `sign_message.rs`, `sign_coin_spends.rs`, and `get_secret_key.rs` set `Some(...)` +instead of the hardcoded `None`. `BridgeTools` carries a hand-written `Debug` that prints the field +as `Some("")`. + +The bridge does not go through the endpoint macro, so the gating manifest does not apply to it. Both +`send_xch` and `sign_coin_spends` reach a secret on every bridge call — the former hardcodes +`auto_submit: true`, the latter signs unconditionally — so the bridge gate is unconditional too. Separately, `WalletSendXch::approval_request` stops returning `Ok(None)` on a protected wallet even when `WalletSendXchAutoSubmit` is granted. A password-protected wallet always gets an approval; silent @@ -215,7 +225,7 @@ All call sites then drop their password plumbing: - `src/walletconnect/` — `chip0002.ts`, `high-level.ts`, and `offers.ts` lose their prompts; `handler.ts` and `WalletConnectContext.tsx` drop `requestPassword` and `hasPassword` from the handler context entirely. -- `Settings.tsx:1106` and `:1123` gate starting the RPC server and toggling run-on-startup. No wallet +- Two sites in `Settings.tsx` gate starting the RPC server and toggling run-on-startup. No wallet secret is involved, so there is no Rust unlock operation to hang them off. They get a new, explicitly named `requireLocalAuth()` from the same provider: a UI-only biometric gate with no Rust round-trip. @@ -236,8 +246,10 @@ All call sites then drop their password plumbing: **Rust** - Gate unit tests against a mock responder: correct password; wrong-then-right; three strikes; - cancellation; expiry mid-prompt. -- The drift test asserting gated set == request types with a `password` field. + cancellation; prompt timeout. +- The drift tests over `password-gating.json` described under **Gating manifest**: key set == + request types with a `password` field, `fingerprint` mode == those with a `fingerprint` field, + `auto_submit` mode == endpoints that only forward the password to `transact`/`transact_with`. - A bridge test that a protected wallet forces an approval despite the `WalletSendXchAutoSubmit` grant. - Existing `sage-rpc` password tests must pass **unchanged** — the regression canary proving the core was not disturbed. @@ -262,10 +274,13 @@ with biometrics on, and immediate `NoAuthNeeded` otherwise. - `src-tauri/src/lib.rs` — register state, command, and event - `src-tauri/src/error.rs` — `From` - `crates/sage-apps/Cargo.toml`, `src-tauri/Cargo.toml`, root `Cargo.toml` — new crate wiring -- `crates/sage-api/macro/src/lib.rs` — `maybe_unlock` -- `crates/sage-api/endpoints.json` (or a sibling gated-endpoint set) +- `crates/sage-api/macro/src/lib.rs` — `maybe_unlock` and `GateMode` +- `crates/sage-api/password-gating.json` — the gating manifest +- `crates/sage-api/src/lib.rs` — the drift tests - `crates/sage-apps/src/bridge/bridge_request.rs` — gate call in `process_after_approval` -- `crates/sage-apps/src/bridge/methods/user/wallet/{send_xch,sign_message,sign_coin_spends}.rs` +- `crates/sage-apps/src/bridge/methods/user/wallet/{send_xch,sign_message,sign_coin_spends,get_secret_key}.rs` +- `crates/sage-apps/src/bridge/methods/shared.rs` — `BridgeTools.password` +- `src-tauri/permissions/main-host.toml` — `submit_password_response` in `commands.allow` **TypeScript** From 1b2d18dc0de05799b23eb4a24db46c6a7a424e29 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sat, 22 Aug 2026 19:27:27 -0500 Subject: [PATCH 32/36] feat: enhance password protection for bridge approvals - Added `requires_password` and `password_attempts` fields to `PendingBridgeApprovalView` to track password requirements and attempts. - Updated `write_pending_approval` to handle password-related logic, including timeout adjustments for approvals requiring a password. - Introduced `peek_pending_approval` and `record_password_attempt` functions to manage password attempts without consuming approvals. - Modified `ResolveBridgeApprovalArgs` to include a password field, ensuring sensitive data is handled securely. - Implemented logic in `process_after_approval` to manage password verification inline with approval processing. - Updated the reconciliation process for wallet password protection to ensure consistency between the keychain and configuration. - Enhanced tests to validate the new password protection features and ensure correct behavior under various scenarios. --- Cargo.lock | 1 + .../system/apps/bridge-approval/src/App.tsx | 84 +++- crates/sage-apps/Cargo.toml | 1 + crates/sage-apps/src/bridge/bridge_request.rs | 457 +++++++++++------- .../methods/system/bridge_approvals/list.rs | 7 + .../system/bridge_approvals/resolve.rs | 9 +- crates/sage-apps/src/bridge/state.rs | 40 +- crates/sage-apps/src/bridge/ts_exports.rs | 22 +- crates/sage-apps/src/bridge/types.rs | 49 +- crates/sage-password-gate/src/lib.rs | 4 +- crates/sage-password-gate/src/resolve.rs | 6 +- crates/sage-rpc/src/tests.rs | 83 +++- crates/sage/src/endpoints/keys.rs | 42 ++ crates/sage/src/sage.rs | 23 +- .../2026-03-15-password-protection-design.md | 21 +- .../generated/2026-03-16-protection-matrix.md | 8 +- .../2026-08-21-password-gate-design.md | 63 ++- packages/sage-system-app-sdk/src/runtime.ts | 5 +- packages/sage-system-app-sdk/src/types.ts | 4 +- src-tauri/src/commands.rs | 18 + 20 files changed, 719 insertions(+), 228 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 06b86180c..958cd7339 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6554,6 +6554,7 @@ dependencies = [ "reqwest 0.12.24", "sage", "sage-api", + "sage-keychain", "sage-password-gate", "serde", "serde_json", diff --git a/builtin-apps/src/system/apps/bridge-approval/src/App.tsx b/builtin-apps/src/system/apps/bridge-approval/src/App.tsx index 013e6b811..8df7a32ae 100644 --- a/builtin-apps/src/system/apps/bridge-approval/src/App.tsx +++ b/builtin-apps/src/system/apps/bridge-approval/src/App.tsx @@ -74,6 +74,11 @@ export function App() { const [loaded, setLoaded] = useState(false); const [now, setNow] = useState(() => Date.now()); const [error, setError] = useState(null); + const [password, setPassword] = useState(''); + const [passwordError, setPasswordError] = useState(null); + // Set when the host tells us an approval needs a password that its queued + // hint did not predict, so the field appears even on a stale view. + const [passwordForced, setPasswordForced] = useState(false); async function refreshActiveRuntime() { const active = await sage.runtimeManager.getActiveTaskbarRuntime(); @@ -148,24 +153,63 @@ export function App() { ? formatCountdown(activeApproval.expiresAtMs, now) : null; + // Never carry a typed password across approvals. useEffect(() => { setExpanded(false); setError(null); + setPassword(''); + setPasswordError(null); + setPasswordForced(false); }, [activeApproval?.approvalId]); + const needsPassword = + (activeApproval?.requiresPassword ?? false) || passwordForced; + async function resolve(approved: boolean) { if (!activeApproval || working) return; + if (approved && needsPassword && password.length === 0) return; setWorking(true); setError(null); try { - await sage.bridgeApprovals.resolve({ + const result = await sage.bridgeApprovals.resolve({ approvalId: activeApproval.approvalId, approved, reason: approved ? null : 'User denied the request', + password: approved && needsPassword ? password : null, }); + switch (result.kind) { + case 'wrongPassword': + // The approval is still queued; keep the card up for another try. + setPassword(''); + setPasswordError( + result.attemptsRemaining === 1 + ? 'Incorrect password. 1 attempt remaining.' + : `Incorrect password. ${result.attemptsRemaining} attempts remaining.`, + ); + break; + + case 'passwordRequired': + // The queued hint was stale — this wallet is protected after all. + setPasswordForced(true); + setPassword(''); + setPasswordError('This wallet requires its password.'); + break; + + case 'tooManyAttempts': + setPassword(''); + setPasswordError(null); + setError('Too many incorrect password attempts. Request rejected.'); + break; + + case 'resolved': + setPassword(''); + setPasswordError(null); + break; + } + setApprovals(await sage.bridgeApprovals.listPending()); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -240,7 +284,7 @@ export function App() {