diff --git a/src/background/index.ts b/src/background/index.ts index 231dca5..550365a 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -164,6 +164,52 @@ 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"; +// 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 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 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 +// 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") { + // 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 // auto-lock. Passive/polled reads (getState, sync, getTransactions, getBalance, // getRate, getAsset, qr, getConnectedSites, getAutoLock) are intentionally @@ -182,6 +228,7 @@ const AUTOLOCK_DEFERRING = new Set([ "wallet/swapQuote", "wallet/revealMnemonic", "wallet/verifyPassword", + "wallet/stepUp", "wallet/setAutoLock", "wallet/setChainServer", "wallet/getAddress", @@ -435,22 +482,55 @@ 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 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 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": - 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. + 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 && msg.panelSession) await setPanelStepUp(msg.panelSession, 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 +681,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 +789,13 @@ 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 + // for every panel session, not just this one. + if (msg.minutes > 0) await browser.storage.session.remove(PANEL_STEPUP_KEY); return; + } case "wallet/touch": // No-op here; the AUTOLOCK_DEFERRING branch in the router re-arms the alarm. @@ -769,7 +853,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 +879,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 +2680,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 +2769,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..69bf9a0 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/unlock"; panelSession?: string; 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..1bb3ff6 100644 --- a/src/sidepanel/screens/Unlock.tsx +++ b/src/sidepanel/screens/Unlock.tsx @@ -256,3 +256,105 @@ 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 + * 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(); + 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(""); + 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); + } + } + + return ( + +
+ + setPassword(e.target.value)} + autoFocus + disabled={blocked || coolingDown} + /> + + {blocked ? ( + {UNLOCK_BLOCKED_TEXT} + ) : coolingDown ? ( + + {`Too many failed attempts. Try again in ${formatCooldown((throttle?.retryAt ?? 0) - now)}.`} + + ) : ( + error && {error} + )} + + +
+
+ ); +} diff --git a/src/sidepanel/wallet-client.ts b/src/sidepanel/wallet-client.ts index 77d7666..32021b9 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" }), - unlock: (password: string) => call({ type: "wallet/unlock", password }), + getState: () => call({ type: "wallet/getState", panelSession: PANEL_SESSION }), + 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 }), + /** 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 }),