diff --git a/Cargo.lock b/Cargo.lock index e36cb9f2c..958cd7339 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6521,6 +6521,7 @@ dependencies = [ "sage-api-macro", "sage-config", "serde", + "serde_json", "specta", "tauri-specta", "utoipa", @@ -6534,6 +6535,7 @@ dependencies = [ "indexmap 2.12.1", "proc-macro2", "quote", + "serde", "serde_json", "syn 2.0.111", ] @@ -6552,6 +6554,8 @@ dependencies = [ "reqwest 0.12.24", "sage", "sage-api", + "sage-keychain", + "sage-password-gate", "serde", "serde_json", "serde_path_to_error", @@ -6664,6 +6668,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 +6727,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/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() {