Skip to content

Shrink the unlocked-session key footprint - #129

Open
dmnyc wants to merge 2 commits into
mainfrom
fix/108-unlocked-footprint
Open

Shrink the unlocked-session key footprint#129
dmnyc wants to merge 2 commits into
mainfrom
fix/108-unlocked-footprint

Conversation

@dmnyc

@dmnyc dmnyc commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #108 (M-5 + L-11).

Three reductions to what lives in memory while the wallet is unlocked:

  • Decrypt-on-demand (L-11). unlock() and SW-wake ensureLoaded() used to decrypt every wallet's mnemonic at once into a warm cache for the whole session. Now only the derived key is recovered; getMnemonic() is async and decrypts a wallet's seed the first time that wallet actually signs, caching it until lock. A plaintext seed exists in service-worker memory one at a time, only for wallets in use — never all of them for hours.
  • Step-up on panel reopen when auto-lock is "never" (M-5 residual). With auto-lock off, the unlocked session lives until browser exit and closing the panel restricts nothing. The panel now mints a random session id per document load; the SW remembers the last one in storage.session (same lifetime as the session key it guards — dies at browser exit, survives SW eviction so an open panel isn't re-prompted) and a new panel session gets needsStepUp in getState, rendering a password re-verify screen before the wallet UI. Verified passwords via the new wallet/stepUp (shares the unlock throttle — same password oracle as unlock/reveal), wallet/unlock, moving off "never", lock, and reset all clear the pending prompt. Background signing still requires its own per-action step-up, unchanged.
  • Assert the session access level. storage.session.setAccessLevel({ accessLevel: "TRUSTED_CONTEXTS" }) is now called explicitly at SW startup (the default, and the only level this design tolerates) so a future widening to trusted-and-untrusted contexts is a deliberate, visible edit rather than drift. Firefox has no setAccessLevel; its storage.session is extension-only by construction.

Verified: pnpm typecheck, pnpm test (325 passing), Chrome and Firefox production builds.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Note (from review): the step-up is advisory at the UI layer — the SW returns needsStepUp but doesn't refuse wallet operations from a panel that owes one. Signing paths (wallet/send, wallet/swap) already demand the password when auto-lock is 0; the residual surface is reads and non-signing ops from extension contexts.

@dmnyc

dmnyc commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Aug 15, 2026

Copy link
Copy Markdown

Claude finished @dmnyc's task in 5m 25s —— View job


Review: Shrink the unlocked-session key footprint

  • Read repo context (keystore, SW router, panel)
  • Review the diff across all 6 files
  • Trace the step-up state machine through lock / reset / unlock / setAutoLock / SW-eviction
  • Post review

Solid, well-motivated change. The decrypt-on-demand rework (keystore.ts:523-537) is clean and correct — changePassword re-derives derivedKey before any later getMnemonic, so on-demand decrypt stays valid, and all six call sites are awaited. The wallet/* trust boundary at index.ts:3007 already covers the new wallet/stepUp, and sharing verifyPassword's throttle is the right call.

The step-up state machine is where I found problems. It stores one {session, pending} slot, and several transitions don't line up with it.


1. Two open panels invalidate each other's step-up forever (medium)

src/background/index.ts:471-484PanelStepUp holds a single session id, but the side panel is per-window (side_panel.default_path on Chrome, sidebar_action on Firefox), so two browser windows means two documents with two PANEL_SESSION ids.

With auto-lock "never" and panels A and B both open:

  1. B opens → mismatch → stores {B, pending:true} → B prompts.
  2. User steps up in B → {B, false}.
  3. A calls getState on its next refresh()A !== B → recomputes pending = true, stores {A, true}A now demands a step-up it already passed.
  4. B's next refresh() → mismatch again → prompts again.

The two panels ping-pong indefinitely; neither can stay past the step-up screen. It fails closed (no security hole), but the wallet is effectively unusable with two windows open. Storing a map of sessionId → pending (capped, or just pruned on lock) instead of a single slot fixes it.

Fix this →

2. Step-up prompt fires immediately after re-onboarding (medium)

wallet/reset removes PANEL_STEPUP_KEY (index.ts:515), but nothing re-registers the panel session on the way back in: wallet/initializeKeystore (index.ts:486), wallet/create and wallet/restore don't touch it, and keystore.reset() doesn't clear AUTOLOCK_KEY — so a user who had auto-lock on "never" still has it after the wipe.

Reset → create/restore a wallet → the panel's next getState sees rec.session === "", recomputes pending = initialized && !locked && nevertrue, and the user is asked to re-enter the password they typed thirty seconds ago, in the same panel session they never closed.

Clearing the pending flag in initializeKeystore is the natural fix — the user just proved the password by setting it.

Fix this →

3. The step-up screen is a dead end once the throttle hard-locks (medium)

src/sidepanel/screens/Unlock.tsx:264-303StepUp shares the unlock throttle, so 21 failed attempts trigger UNLOCK_BLOCKED, whose text is "Too many failed attempts. Restore from your recovery phrase or reset Apogee to continue." But StepUp deliberately omits the forgot/reset paths, and it has no lock button either. The panel is now pinned to a form that refuses every submission, with instructions the screen can't carry out. The only escape is quitting the browser (which kills the session key → real lock screen → recovery paths return) — which the UI never tells the user.

Two things would close it: render the throttle countdown/blocked state proactively like Unlock does (Unlock.tsx:39-51), and give StepUp a "Lock wallet" action that drops to the real lock screen where restore/reset live.

Fix this →

4. Unhandled rejection on setAccessLevel (low)

src/background/index.ts:195-196void-ed with no .catch. If Chrome ever rejects it, that's an unhandled rejection in the SW. The chrome.sidePanel.setPanelBehavior call at index.ts:124-127 sets the precedent with .catch((err) => console.error(...)); worth matching, especially since a silent failure of an access-level assertion is exactly what the comment above it says it wants to avoid.

Notes (no action needed, but worth recording)

  • The step-up is advisory, not enforced. The SW returns needsStepUp but doesn't refuse wallet operations from a panel that owes one — only the React tree hides the UI. Since wallet/send and wallet/swap already demand a password when auto-lock is 0 (index.ts:830-834, index.ts:859-864), the residual gap is read surface plus non-signing ops. Given [M-5 + L-11] Reduce the unlocked-session key footprint #108 is an audit item, it's worth saying so explicitly in the PR body / audit note so it isn't later read as an enforcement boundary.
  • unlock() no longer fails fast on a corrupt seed record. The old eager loop surfaced an undecryptable enc at unlock; now it surfaces at first sign, as an "engine error"-adjacent failure on a specific wallet. Arguably an improvement (one bad record no longer blocks the whole vault), just a behavior change.
  • getMnemonic has no in-flight dedupe (keystore.ts:527) — two concurrent signs for a cold wallet each run decryptString. Correct, just redundant; not worth a mutex at this call volume.
  • No test coverage. There are no keystore/background tests in the repo at all, so the 325 passing tests don't touch any of this. The step-up state machine has enough transitions (lock, reset, setAutoLock, unlock, SW eviction, multi-session) that findings Side-panel UX polish: status bar, animated lock screen, auto-lock, send/connect flows #1 and Set wallet amounts in a 2001-inspired telemetry face #2 would both have been caught by a small unit test over handleUi's wallet/getState with a faked storage.session. Worth considering as a follow-up.

Verification

I could not run pnpm typecheck / pnpm test here — pnpm install needs network access this environment doesn't grant. Findings above are from static reading; I'm taking the PR's report of 325 passing at face value, noting that none of those tests exercise the changed code.


· branch fix/108-unlocked-footprint

@dmnyc

dmnyc commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Review feedback addressed in 5da84dd:

On the notes: the advisory-not-enforced nature is a fair read — worth stating in the PR body, done here: the step-up gates the panel UI only; wallet/send/wallet/swap already demand the password when auto-lock is 0, and reads remain available to any extension context by design. A SW-side refusal is a bigger design change than this PR should absorb; happy to spin it as an issue if you want it tracked.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[M-5 + L-11] Reduce the unlocked-session key footprint

1 participant