From 5c508b09f8589c7aa33f74c70a69345bbb0fd580 Mon Sep 17 00:00:00 2001 From: "GLM 5.3" Date: Fri, 14 Aug 2026 23:16:48 -0400 Subject: [PATCH 1/2] feat(security): shrink the unlocked-session key footprint --- src/background/index.ts | 89 ++++++++++++++++++++++++++++---- src/engine/protocol.ts | 8 ++- src/keystore/keystore.ts | 37 +++++++------ src/sidepanel/App.tsx | 11 +++- src/sidepanel/screens/Unlock.tsx | 45 ++++++++++++++++ src/sidepanel/wallet-client.ts | 10 +++- 6 files changed, 170 insertions(+), 30 deletions(-) diff --git a/src/background/index.ts b/src/background/index.ts index 231dca5..6ea7926 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -164,6 +164,38 @@ async function rescheduleAutoLock(): Promise { await armAutoLock(minutes); } +// ---- auto-lock "never" step-up ---------------------------------------------- +// +// With auto-lock "never", the unlocked session (and its storage.session key +// cache) lives until the browser exits — closing the panel restricts nothing. So +// each NEW panel session must re-verify the password before the wallet UI is +// shown. The panel mints a random id at document load; this record remembers the +// last one seen and whether it still owes a step-up. Kept in storage.session +// (not SW memory) so an SW eviction doesn't re-prompt the same open panel, and +// with the same lifetime as the unlocked session it guards: gone at browser exit. +const PANEL_STEPUP_KEY = "apogee:panelStepUp"; +interface PanelStepUp { + session: string; + pending: boolean; +} +async function panelStepUpState(): Promise { + const v = (await browser.storage.session.get(PANEL_STEPUP_KEY))[PANEL_STEPUP_KEY]; + return v && typeof v === "object" ? (v as PanelStepUp) : { session: "", pending: false }; +} +async function savePanelStepUp(rec: PanelStepUp): Promise { + await browser.storage.session.set({ [PANEL_STEPUP_KEY]: rec }); +} + +// Pin storage.session to trusted contexts — the default, and the only level this +// design tolerates: extension pages and the SW only, never content scripts or +// web pages, which is what keeps the session key cache unreadable outside the +// extension. Stated explicitly so widening it later is a deliberate, visible +// edit. Firefox has no setAccessLevel; its storage.session is extension-only by +// construction. +if (typeof browser.storage.session?.setAccessLevel === "function") { + void browser.storage.session.setAccessLevel({ accessLevel: "TRUSTED_CONTEXTS" }); +} + // wallet/* messages that count as genuine user activity and so defer the idle // auto-lock. Passive/polled reads (getState, sync, getTransactions, getBalance, // getRate, getAsset, qr, getConnectedSites, getAutoLock) are intentionally @@ -182,6 +214,7 @@ const AUTOLOCK_DEFERRING = new Set([ "wallet/swapQuote", "wallet/revealMnemonic", "wallet/verifyPassword", + "wallet/stepUp", "wallet/setAutoLock", "wallet/setChainServer", "wallet/getAddress", @@ -435,22 +468,51 @@ async function walletInfo(walletId?: string) { async function handleUi(msg: WalletRequest): Promise { await keystore.ensureLoaded(); // recover unlocked state after SW eviction switch (msg.type) { - case "wallet/getState": - return keystore.getState(); + case "wallet/getState": { + const s = await keystore.getState(); + // Auto-lock "never": a panel session the SW hasn't seen must re-verify the + // password before it gets the wallet UI (the unlocked session otherwise + // lives until the browser exits). See panelStepUpState for the lifetime. + if (!msg.panelSession) return s; + const rec = await panelStepUpState(); + if (msg.panelSession === rec.session) { + return { ...s, needsStepUp: rec.pending && s.initialized && !s.locked }; + } + const pending = s.initialized && !s.locked && (await autoLockMinutes()) === 0; + await savePanelStepUp({ session: msg.panelSession, pending }); + return { ...s, needsStepUp: pending }; + } case "wallet/initializeKeystore": return keystore.initialize(msg.password); - case "wallet/unlock": - return keystore.unlock(msg.password); + case "wallet/unlock": { + const r = await keystore.unlock(msg.password); + // The user just proved the password — no step-up owed for this panel. + await savePanelStepUp({ session: (await panelStepUpState()).session, pending: false }); + return r; + } + + case "wallet/stepUp": { + // Same password oracle as unlock/verifyPassword, so it shares the throttle. + const ok = await keystore.verifyPassword(msg.password); + if (ok) { + await savePanelStepUp({ session: msg.panelSession ?? (await panelStepUpState()).session, pending: false }); + } + return ok; + } case "wallet/lock": // A parked scanned phrase must not outlive the session that scanned it. clearQrSecret(); + await browser.storage.session.remove(PANEL_STEPUP_KEY); return keystore.lock(); case "wallet/reset": { clearQrSecret(); + // The step-up record guards the vault being destroyed — don't let it + // (or a pending prompt) survive into the next one. + await browser.storage.session.remove(PANEL_STEPUP_KEY); // Revoke connected dapp sessions on a wipe, so any connected app // disconnects (its next call gets NOT_CONNECTED) instead of going stale. await removeAllConnectedSites(); @@ -601,8 +663,8 @@ async function handleUi(msg: WalletRequest): Promise { case "wallet/revealMnemonic": { // Step-up auth: verifyPassword re-derives + checks the password, but the - // returned seed comes from the in-memory unlocked cache (getMnemonic), not a - // fresh decrypt — the wallet is already unlocked here. + // returned seed comes from the unlocked cache (getMnemonic), not a fresh + // decrypt keyed to this attempt — the wallet is already unlocked here. if (!(await keystore.verifyPassword(msg.password))) throw new Error("Incorrect password"); return keystore.getMnemonic(msg.walletId); } @@ -709,9 +771,14 @@ async function handleUi(msg: WalletRequest): Promise { case "wallet/getAutoLock": return autoLockMinutes(); - case "wallet/setAutoLock": + case "wallet/setAutoLock": { await browser.storage.local.set({ [AUTOLOCK_KEY]: msg.minutes }); + // Moving off "never" ends the step-up regime — a pending prompt is moot. + if (msg.minutes > 0) { + await savePanelStepUp({ session: (await panelStepUpState()).session, pending: false }); + } return; + } case "wallet/touch": // No-op here; the AUTOLOCK_DEFERRING branch in the router re-arms the alarm. @@ -769,7 +836,7 @@ async function handleUi(msg: WalletRequest): Promise { // A local wallet signs in the offscreen engine with the unlocked mnemonic. const sent = await engine({ kind: "signBroadcast", - mnemonic: keystore.getMnemonic(info.id), + mnemonic: await keystore.getMnemonic(info.id), descriptor: info.descriptor, network: info.network, pset: msg.pset, @@ -795,7 +862,7 @@ async function handleUi(msg: WalletRequest): Promise { throw new Error("Enter your password to swap."); } } - const mnemonic = keystore.getMnemonic(info.id); + const mnemonic = await keystore.getMnemonic(info.id); // The SideSwap client lives only for this swap call — connect, execute, // disconnect. A WebSocket in the service worker is fine (MV3 background). const client = new SideSwapClient(info.network); @@ -2596,7 +2663,7 @@ async function handleApprovalDecision( } else { const signed = await engine({ kind: "signProviderPset", - mnemonic: keystore.getMnemonic(pending.walletId), + mnemonic: await keystore.getMnemonic(pending.walletId), descriptor: pending.descriptor, network: pending.network, pset: pending.pset, @@ -2685,7 +2752,7 @@ async function handleApprovalDecision( } } try { - const mnemonic = keystore.getMnemonic(pending.walletId); + const mnemonic = await keystore.getMnemonic(pending.walletId); const result = await engine({ kind: "signBroadcast", mnemonic, diff --git a/src/engine/protocol.ts b/src/engine/protocol.ts index 2db1446..b0793a9 100644 --- a/src/engine/protocol.ts +++ b/src/engine/protocol.ts @@ -389,12 +389,18 @@ export interface WalletTxDTO { // ---- side panel / prompt → service worker ---------------------------------- export type WalletRequest = - | { type: "wallet/getState" } + // panelSession: random id minted per panel-document load. Lets the SW tell + // "same panel session" from "panel closed and reopened" for the auto-lock- + // "never" step-up. Absent from non-panel callers. + | { type: "wallet/getState"; panelSession?: string } | { type: "wallet/initializeKeystore"; password: string } | { type: "wallet/unlock"; password: string } | { type: "wallet/lock" } | { type: "wallet/reset" } | { type: "wallet/verifyPassword"; password: string } + // Auto-lock "never" step-up: re-verify the password from a reopened panel. + // Shares the unlock throttle with unlock/verifyPassword (same password oracle). + | { type: "wallet/stepUp"; panelSession?: string; password: string } // Unlock-attempt throttle state (fails / cooldown / hard lock) for the UI. | { type: "wallet/getUnlockThrottle" } // password (first run) initializes the keystore as part of the same call. diff --git a/src/keystore/keystore.ts b/src/keystore/keystore.ts index 448e1c3..d392b8f 100644 --- a/src/keystore/keystore.ts +++ b/src/keystore/keystore.ts @@ -83,6 +83,9 @@ export interface KeystoreState { locked: boolean; activeWalletId: string | null; wallets: WalletInfo[]; + // Set by the SW router (not the keystore): auto-lock is "never", the wallet is + // unlocked, and this panel session hasn't re-verified the password yet. + needsStepUp?: boolean; } /** Fields a caller supplies to persist a new wallet (derived via the engine). */ @@ -270,7 +273,7 @@ export async function initialize(password: string): Promise { await clearUnlockFailures(); // fresh vault — a stale counter must not guard it } -/** Derive the key from the password, verify it, and decrypt all mnemonics. */ +/** Derive the key from the password and verify it. Mnemonics decrypt on demand. */ export async function unlock(password: string): Promise { const store = await loadStore(); if (!store) throw new Error("Keystore not initialized"); @@ -286,11 +289,10 @@ export async function unlock(password: string): Promise { throw new Error("Incorrect password"); } await clearUnlockFailures(); + // Mnemonics are NOT decrypted here — getMnemonic decrypts on demand, so a + // plaintext seed enters SW memory only while that wallet is actually in use, + // never all wallets at once for the length of the session. unlockedMnemonics.clear(); - for (const id of store.order) { - const w = store.wallets[id]; - if (w?.enc) unlockedMnemonics.set(id, await decryptString(key, w.enc, mnemonicAad(id))); // skip hardware (no seed) - } derivedKey = key; await persistSession(key); } @@ -518,12 +520,20 @@ export async function getDescriptor(id: string): Promise { return rec.descriptor; } -/** Decrypted mnemonic for a wallet (requires unlock). For engine + reveal. */ -export function getMnemonic(id: string): string { - if (isLocked()) throw new Error("Keystore is locked"); - const m = unlockedMnemonics.get(id); - if (!m) throw new Error("No local seed for this wallet (hardware signer or not unlocked)"); - return m; +/** Decrypted mnemonic for a wallet (requires unlock). For engine + reveal. + * Decrypted on demand and cached until lock: unlock() deliberately does not + * warm every wallet's seed, so plaintext mnemonics exist in SW memory one at a + * time, only for wallets that actually sign. */ +export async function getMnemonic(id: string): Promise { + if (isLocked() || !derivedKey) throw new Error("Keystore is locked"); + const cached = unlockedMnemonics.get(id); + if (cached) return cached; + const store = await loadStore(); + const rec = store?.wallets[id]; + if (!rec?.enc) throw new Error("No local seed for this wallet (hardware signer)"); + const mnemonic = await decryptString(derivedKey, rec.enc, mnemonicAad(id)); + unlockedMnemonics.set(id, mnemonic); + return mnemonic; } // ---- MV3 session recovery ---- @@ -550,10 +560,7 @@ export async function ensureLoaded(): Promise { await sessionClear(SESSION_KEY); // stale session (password changed/tamper) return; } + // Same as unlock(): recover the key, decrypt nothing eagerly. unlockedMnemonics.clear(); - for (const id of store.order) { - const w = store.wallets[id]; - if (w?.enc) unlockedMnemonics.set(id, await decryptString(key, w.enc, mnemonicAad(id))); // skip hardware - } derivedKey = key; } diff --git a/src/sidepanel/App.tsx b/src/sidepanel/App.tsx index e192207..61781be 100644 --- a/src/sidepanel/App.tsx +++ b/src/sidepanel/App.tsx @@ -12,7 +12,7 @@ import { Scene, type SceneIntro } from "@/sidepanel/components/Scene"; import { useAnimations } from "@/sidepanel/use-animations"; import { useIdleHeartbeat } from "@/sidepanel/use-idle-heartbeat"; import { Onboarding } from "@/sidepanel/screens/Onboarding"; -import { Unlock } from "@/sidepanel/screens/Unlock"; +import { StepUp, Unlock } from "@/sidepanel/screens/Unlock"; import { Wallet, type View } from "@/sidepanel/screens/Wallet"; import { ApprovalOverlay } from "@/sidepanel/screens/Approval"; import type { ApprovalRequest } from "@/engine/protocol"; @@ -324,7 +324,11 @@ export function App() { return () => browser.runtime.onMessage.removeListener(onMsg); }, [refresh, showToast]); - const unlocked = Boolean(state && state.initialized && !state.locked && state.wallets.length > 0); + // needsStepUp: auto-lock is "never" and this panel session hasn't re-verified + // the password — treat it like locked for what the panel shows (see StepUp). + const unlocked = Boolean( + state && state.initialized && !state.locked && !state.needsStepUp && state.wallets.length > 0, + ); // Same first-run test useMoonIntro gates the cinematic on. Nothing to replay // once a wallet exists by any route (create / restore / watch-only / Jade), so // the debug control retires with the screen it belongs to. @@ -592,6 +596,9 @@ function Body({ if (state.locked) { return ; } + if (state.needsStepUp) { + return ; + } return ( ); diff --git a/src/sidepanel/screens/Unlock.tsx b/src/sidepanel/screens/Unlock.tsx index bc7b4bc..d2473ad 100644 --- a/src/sidepanel/screens/Unlock.tsx +++ b/src/sidepanel/screens/Unlock.tsx @@ -256,3 +256,48 @@ export function Unlock({ ); } + +/** Auto-lock "never" step-up: the wallet stays unlocked in the background for the + * whole browser session, so a freshly opened panel re-verifies the password + * before it gets the wallet UI. Not the lock screen — there's no vault to + * unlock, just an identity to prove — so the forgot/reset paths don't apply. */ +export function StepUp({ onDone }: { onDone: () => void }) { + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(""); + try { + if (!(await wallet.stepUp(password))) throw new Error("Incorrect password"); + onDone(); + } catch (err) { + // unlockErrMessage: the SW shares the unlock throttle with this oracle. + setError(unlockErrMessage(err)); + setPassword(""); + } finally { + setBusy(false); + } + } + + return ( + +
+ + setPassword(e.target.value)} + autoFocus + /> + + {error} + +
+
+ ); +} diff --git a/src/sidepanel/wallet-client.ts b/src/sidepanel/wallet-client.ts index 77d7666..38e43a2 100644 --- a/src/sidepanel/wallet-client.ts +++ b/src/sidepanel/wallet-client.ts @@ -30,6 +30,11 @@ import type { import { browser } from "@/lib/ext"; import type { UpdateCheck } from "@/lib/version-check"; +/** Random id minted per panel-document load. Lets the service worker tell + * "same panel session" from "panel closed and reopened" — the trigger for the + * auto-lock-"never" password step-up. */ +const PANEL_SESSION = crypto.randomUUID(); + async function call(msg: WalletRequest): Promise { const reply = (await browser.runtime.sendMessage(msg)) as Reply | undefined; if (!reply) throw new Error("no response from background"); @@ -38,11 +43,14 @@ async function call(msg: WalletRequest): Promise { } export const wallet = { - getState: () => call({ type: "wallet/getState" }), + getState: () => call({ type: "wallet/getState", panelSession: PANEL_SESSION }), unlock: (password: string) => call({ type: "wallet/unlock", password }), lock: () => call({ type: "wallet/lock" }), reset: () => call({ type: "wallet/reset" }), verifyPassword: (password: string) => call({ type: "wallet/verifyPassword", password }), + /** Re-verify the password on panel reopen while auto-lock is "never". */ + stepUp: (password: string) => + call({ type: "wallet/stepUp", panelSession: PANEL_SESSION, password }), getUnlockThrottle: () => call({ type: "wallet/getUnlockThrottle" }), create: (password: string, label: string, network: LiquidNetwork) => call({ type: "wallet/create", password, label, network }), From 5da84dd411043058a1bb43436d39d616083e1082 Mon Sep 17 00:00:00 2001 From: "GLM 5.3" Date: Fri, 14 Aug 2026 23:37:48 -0400 Subject: [PATCH 2/2] fix(security): track step-up per panel session, unblock the hard-locked step-up screen --- src/background/index.ts | 63 ++++++++++++++++++++------------ src/engine/protocol.ts | 2 +- src/sidepanel/screens/Unlock.tsx | 63 ++++++++++++++++++++++++++++++-- src/sidepanel/wallet-client.ts | 2 +- 4 files changed, 102 insertions(+), 28 deletions(-) diff --git a/src/background/index.ts b/src/background/index.ts index 6ea7926..550365a 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -174,16 +174,26 @@ async function rescheduleAutoLock(): Promise { // (not SW memory) so an SW eviction doesn't re-prompt the same open panel, and // with the same lifetime as the unlocked session it guards: gone at browser exit. const PANEL_STEPUP_KEY = "apogee:panelStepUp"; -interface PanelStepUp { - session: string; - pending: boolean; -} -async function panelStepUpState(): Promise { +// session id → still owes a step-up. A MAP, not a single slot: the side panel is +// per-window, so two open windows are two documents with two session ids, and a +// single record would have them overwrite each other and ping-pong between +// step-up prompts forever. Bounded so long-lived sessions can't accumulate. +const PANEL_STEPUP_MAX = 16; +async function panelStepUpState(): Promise> { const v = (await browser.storage.session.get(PANEL_STEPUP_KEY))[PANEL_STEPUP_KEY]; - return v && typeof v === "object" ? (v as PanelStepUp) : { session: "", pending: false }; + return v && typeof v === "object" ? (v as Record) : {}; +} +async function savePanelStepUp(map: Record): Promise { + // Prune to the most recent MAX entries (object key order = insertion order). + const entries = Object.entries(map); + const bounded = Object.fromEntries(entries.slice(-PANEL_STEPUP_MAX)); + await browser.storage.session.set({ [PANEL_STEPUP_KEY]: bounded }); } -async function savePanelStepUp(rec: PanelStepUp): Promise { - await browser.storage.session.set({ [PANEL_STEPUP_KEY]: rec }); +async function setPanelStepUp(session: string, pending: boolean): Promise { + const map = await panelStepUpState(); + delete map[session]; // re-insert so the entry moves to the back (LRU order) + map[session] = pending; + await savePanelStepUp(map); } // Pin storage.session to trusted contexts — the default, and the only level this @@ -193,7 +203,11 @@ async function savePanelStepUp(rec: PanelStepUp): Promise { // edit. Firefox has no setAccessLevel; its storage.session is extension-only by // construction. if (typeof browser.storage.session?.setAccessLevel === "function") { - void browser.storage.session.setAccessLevel({ accessLevel: "TRUSTED_CONTEXTS" }); + // Loud on failure: a silently widened access level is exactly what the + // comment above says this must never become. + browser.storage.session + .setAccessLevel({ accessLevel: "TRUSTED_CONTEXTS" }) + .catch((err: unknown) => console.error("[apogee] storage.session.setAccessLevel failed", err)); } // wallet/* messages that count as genuine user activity and so defer the idle @@ -474,31 +488,35 @@ async function handleUi(msg: WalletRequest): Promise { // password before it gets the wallet UI (the unlocked session otherwise // lives until the browser exits). See panelStepUpState for the lifetime. if (!msg.panelSession) return s; - const rec = await panelStepUpState(); - if (msg.panelSession === rec.session) { - return { ...s, needsStepUp: rec.pending && s.initialized && !s.locked }; + const known = (await panelStepUpState())[msg.panelSession]; + if (known !== undefined) { + return { ...s, needsStepUp: known && s.initialized && !s.locked }; } const pending = s.initialized && !s.locked && (await autoLockMinutes()) === 0; - await savePanelStepUp({ session: msg.panelSession, pending }); + await setPanelStepUp(msg.panelSession, pending); return { ...s, needsStepUp: pending }; } - case "wallet/initializeKeystore": - return keystore.initialize(msg.password); + case "wallet/initializeKeystore": { + const r = await keystore.initialize(msg.password); + // The user just SET the password — no step-up owed by any panel for this + // fresh vault (covers reset → re-onboard without a redundant prompt). + await browser.storage.session.remove(PANEL_STEPUP_KEY); + return r; + } case "wallet/unlock": { const r = await keystore.unlock(msg.password); // The user just proved the password — no step-up owed for this panel. - await savePanelStepUp({ session: (await panelStepUpState()).session, pending: false }); + if (msg.panelSession) await setPanelStepUp(msg.panelSession, false); + else await browser.storage.session.remove(PANEL_STEPUP_KEY); return r; } case "wallet/stepUp": { // Same password oracle as unlock/verifyPassword, so it shares the throttle. const ok = await keystore.verifyPassword(msg.password); - if (ok) { - await savePanelStepUp({ session: msg.panelSession ?? (await panelStepUpState()).session, pending: false }); - } + if (ok && msg.panelSession) await setPanelStepUp(msg.panelSession, false); return ok; } @@ -773,10 +791,9 @@ async function handleUi(msg: WalletRequest): Promise { case "wallet/setAutoLock": { await browser.storage.local.set({ [AUTOLOCK_KEY]: msg.minutes }); - // Moving off "never" ends the step-up regime — a pending prompt is moot. - if (msg.minutes > 0) { - await savePanelStepUp({ session: (await panelStepUpState()).session, pending: false }); - } + // Moving off "never" ends the step-up regime — a pending prompt is moot + // for every panel session, not just this one. + if (msg.minutes > 0) await browser.storage.session.remove(PANEL_STEPUP_KEY); return; } diff --git a/src/engine/protocol.ts b/src/engine/protocol.ts index b0793a9..69bf9a0 100644 --- a/src/engine/protocol.ts +++ b/src/engine/protocol.ts @@ -394,7 +394,7 @@ export type WalletRequest = // "never" step-up. Absent from non-panel callers. | { type: "wallet/getState"; panelSession?: string } | { type: "wallet/initializeKeystore"; password: string } - | { type: "wallet/unlock"; password: string } + | { type: "wallet/unlock"; panelSession?: string; password: string } | { type: "wallet/lock" } | { type: "wallet/reset" } | { type: "wallet/verifyPassword"; password: string } diff --git a/src/sidepanel/screens/Unlock.tsx b/src/sidepanel/screens/Unlock.tsx index d2473ad..1bb3ff6 100644 --- a/src/sidepanel/screens/Unlock.tsx +++ b/src/sidepanel/screens/Unlock.tsx @@ -260,11 +260,40 @@ export function Unlock({ /** Auto-lock "never" step-up: the wallet stays unlocked in the background for the * whole browser session, so a freshly opened panel re-verifies the password * before it gets the wallet UI. Not the lock screen — there's no vault to - * unlock, just an identity to prove — so the forgot/reset paths don't apply. */ + * unlock, just an identity to prove — so the forgot/reset paths don't apply + * HERE. But this oracle shares the unlock throttle, and the hard lock's + * message points at restore/reset, which only the real lock screen offers — so + * a "Lock wallet" escape must exist, or a throttled-out user is pinned to a + * form that refuses every submission until the browser restarts. */ export function StepUp({ onDone }: { onDone: () => void }) { const [password, setPassword] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + // Same display-only throttle view as the lock screen (see Unlock). + const [throttle, setThrottle] = useState(null); + const [now, setNow] = useState(() => Date.now()); + const refreshThrottle = useCallback(async () => { + try { + setNow(Date.now()); + setThrottle(await wallet.getUnlockThrottle()); + } catch { + /* display-only; the keystore still enforces on submit */ + } + }, []); + useEffect(() => { + void refreshThrottle(); + }, [refreshThrottle]); + + const blocked = throttle?.blocked ?? false; + const coolingDown = !blocked && throttle?.retryAt != null && throttle.retryAt > now; + useEffect(() => { + if (!coolingDown) return; + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, [coolingDown]); + useEffect(() => { + if (throttle?.retryAt != null && throttle.retryAt <= now) void refreshThrottle(); + }, [throttle, now, refreshThrottle]); async function submit(e: React.FormEvent) { e.preventDefault(); @@ -277,6 +306,22 @@ export function StepUp({ onDone }: { onDone: () => void }) { // unlockErrMessage: the SW shares the unlock throttle with this oracle. setError(unlockErrMessage(err)); setPassword(""); + void refreshThrottle(); + } finally { + setBusy(false); + } + } + + // Drops to the real lock screen, where the forgot/reset paths live — the + // escape hatch for the throttle hard-lock (and for a user who'd rather just + // lock than re-verify). onDone is the panel's refresh, so state re-reads. + async function lockWallet() { + setBusy(true); + try { + await wallet.lock(); + onDone(); + } catch (err) { + setError(errMessage(err)); } finally { setBusy(false); } @@ -291,12 +336,24 @@ export function StepUp({ onDone }: { onDone: () => void }) { value={password} onChange={(e) => setPassword(e.target.value)} autoFocus + disabled={blocked || coolingDown} /> - {error} - + ); diff --git a/src/sidepanel/wallet-client.ts b/src/sidepanel/wallet-client.ts index 38e43a2..32021b9 100644 --- a/src/sidepanel/wallet-client.ts +++ b/src/sidepanel/wallet-client.ts @@ -44,7 +44,7 @@ async function call(msg: WalletRequest): Promise { export const wallet = { getState: () => call({ type: "wallet/getState", panelSession: PANEL_SESSION }), - unlock: (password: string) => call({ type: "wallet/unlock", password }), + unlock: (password: string) => call({ type: "wallet/unlock", panelSession: PANEL_SESSION, password }), lock: () => call({ type: "wallet/lock" }), reset: () => call({ type: "wallet/reset" }), verifyPassword: (password: string) => call({ type: "wallet/verifyPassword", password }),