Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 97 additions & 13 deletions src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,52 @@ async function rescheduleAutoLock(): Promise<void> {
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<Record<string, boolean>> {
const v = (await browser.storage.session.get(PANEL_STEPUP_KEY))[PANEL_STEPUP_KEY];
return v && typeof v === "object" ? (v as Record<string, boolean>) : {};
}
async function savePanelStepUp(map: Record<string, boolean>): Promise<void> {
// 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<void> {
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
Expand All @@ -182,6 +228,7 @@ const AUTOLOCK_DEFERRING = new Set<WalletRequest["type"]>([
"wallet/swapQuote",
"wallet/revealMnemonic",
"wallet/verifyPassword",
"wallet/stepUp",
"wallet/setAutoLock",
"wallet/setChainServer",
"wallet/getAddress",
Expand Down Expand Up @@ -435,22 +482,55 @@ async function walletInfo(walletId?: string) {
async function handleUi(msg: WalletRequest): Promise<unknown> {
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();
Expand Down Expand Up @@ -601,8 +681,8 @@ async function handleUi(msg: WalletRequest): Promise<unknown> {

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);
}
Expand Down Expand Up @@ -709,9 +789,13 @@ async function handleUi(msg: WalletRequest): Promise<unknown> {
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.
Expand Down Expand Up @@ -769,7 +853,7 @@ async function handleUi(msg: WalletRequest): Promise<unknown> {
// A local wallet signs in the offscreen engine with the unlocked mnemonic.
const sent = await engine<SendResult>({
kind: "signBroadcast",
mnemonic: keystore.getMnemonic(info.id),
mnemonic: await keystore.getMnemonic(info.id),
descriptor: info.descriptor,
network: info.network,
pset: msg.pset,
Expand All @@ -795,7 +879,7 @@ async function handleUi(msg: WalletRequest): Promise<unknown> {
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);
Expand Down Expand Up @@ -2596,7 +2680,7 @@ async function handleApprovalDecision(
} else {
const signed = await engine<ProviderPsetSignResultDTO>({
kind: "signProviderPset",
mnemonic: keystore.getMnemonic(pending.walletId),
mnemonic: await keystore.getMnemonic(pending.walletId),
descriptor: pending.descriptor,
network: pending.network,
pset: pending.pset,
Expand Down Expand Up @@ -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<SendResult>({
kind: "signBroadcast",
mnemonic,
Expand Down
10 changes: 8 additions & 2 deletions src/engine/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 22 additions & 15 deletions src/keystore/keystore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -270,7 +273,7 @@ export async function initialize(password: string): Promise<void> {
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<void> {
const store = await loadStore();
if (!store) throw new Error("Keystore not initialized");
Expand All @@ -286,11 +289,10 @@ export async function unlock(password: string): Promise<void> {
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);
}
Expand Down Expand Up @@ -518,12 +520,20 @@ export async function getDescriptor(id: string): Promise<string> {
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<string> {
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 ----
Expand All @@ -550,10 +560,7 @@ export async function ensureLoaded(): Promise<void> {
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;
}
11 changes: 9 additions & 2 deletions src/sidepanel/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -592,6 +596,9 @@ function Body({
if (state.locked) {
return <Unlock onDone={refresh} onImport={onImport} onReset={onReset} />;
}
if (state.needsStepUp) {
return <StepUp onDone={refresh} />;
}
return (
<Wallet state={state} view={view} onView={onView} onToast={onToast} onReset={onReset} />
);
Expand Down
Loading
Loading