d07b72217b9d
harden recovery confirm against full browser restart: peach_recovery_pending moves to chrome.storage.local; handleUnlock checks flag after fresh password unlock and re-routes to recovery surface; LockView Match accepts forceRecoveryWords override (v2.35.1)
- time
- 2026-05-07 01:53 UTC · 5 months ago
- machine
- koh2
- parent
- 56ac0e7edbd8
↓ peach-browser-d07b7221.tar.gzatlas at this save
files changed7 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/recovery-pending.ts+13 −15src/popup/App.tsx+31 −17src/popup/views/Lock.tsx+6 −5src/popup/views/RecoveryWordDisplay.tsx+4 −3manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "2.35.0",4+ "version": "2.35.1",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "2.35.0",4+ "version": "2.35.1",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "2.35.0",3+ "version": "2.35.1",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/background/recovery-pending.ts+13 −15
@@ -1,22 +1,20 @@1-// Session-storage flag set while the user has just created a vault and is2-// reading their 12 recovery words but has not yet typed the 3 confirmation3-// words. While the flag is set, lock-on-close and auto-lock are suppressed4-// — locking would discard the derived key required to re-show the words on5-// reopen, and the words cannot be recovered without it.1+// Persistent flag set while the user has just created a vault and is reading2+// their 12 recovery words but has not yet typed the 3 confirmation words.3+// While the flag is set: (a) lock-on-close and auto-lock are suppressed so4+// the in-memory key needed to re-derive the words survives a popup close, and5+// (b) on the next successful unlock (even after a full browser restart), the6+// popup re-routes to the recovery-words display instead of the entries view.67 //7-// Lives in chrome.storage.session (RAM-only; cleared on browser restart).8-// If the browser closes before confirmation, the flag and the in-memory key9-// are both lost together — the recovery words become unreachable, which is10-// the same outcome the user would face with any unconfirmed-but-unsaved11-// secret.8+// Lives in chrome.storage.local so the flag survives browser restart. Cleared9+// on confirmation success and on DESTROY_VAULT (storage.local.clear()).1210 1311 const KEY = "peach_recovery_pending";1412 1513 export async function isRecoveryConfirmPending(): Promise<boolean> {1614 try {17- if (!chrome.storage?.session) return false;18- const out = await chrome.storage.session.get(KEY);19- return out[KEY] === "1";15+ if (!chrome.storage?.local) return false;16+ const out = await chrome.storage.local.get(KEY);17+ return (out as Record<string, unknown>)[KEY] === "1";2018 } catch {2119 return false;2220 }@@ -24,12 +22,12 @@2422 2523 export async function setRecoveryConfirmPending(): Promise<void> {2624 try {27- await chrome.storage.session?.set({ [KEY]: "1" });25+ await chrome.storage.local?.set({ [KEY]: "1" });2826 } catch { /* best-effort */ }2927 }3028 3129 export async function clearRecoveryConfirmPending(): Promise<void> {3230 try {33- await chrome.storage.session?.remove(KEY);31+ await chrome.storage.local?.remove(KEY);3432 } catch { /* best-effort */ }3533 }src/popup/App.tsx+31 −17
@@ -330,23 +330,21 @@330330 331331 // If the user created a vault but hasn't yet confirmed their recovery332332 // words, route back to the recovery-words screen instead of opening the333- // entries view. The SW suppresses lock-on-close while this flag is set334- // so the in-memory key needed to re-derive the words survives the popup335- // close → reopen round-trip. Cleared when confirmation succeeds.333+ // entries view. The flag lives in chrome.storage.local so it survives334+ // both popup close (SW suppresses lock-on-close) and full browser335+ // restart (handleUnlock checks the same flag after a fresh password336+ // unlock). Cleared when confirmation succeeds.336337 let recoveryPending = false;337338 try {338- const out = await chrome.storage.session?.get("peach_recovery_pending");339- recoveryPending = out?.peach_recovery_pending === "1";339+ const out = await chrome.storage.local?.get("peach_recovery_pending");340+ recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";340341 } catch { /* best-effort */ }341342 342343 if (recoveryPending && unlocked) {343- // LockView's <Match when={showRecoveryWords() && !props.hasVault}>344- // gates on hasVault being false. We pass hasVault=false to the lock345- // view so the recovery match fires; the actual vault state in346- // background is untouched.347- setHasVault(false);348- // Pass a hint so LockView force-shows the recovery display on mount.344+ // forceRecoveryWords overrides LockView's hasVault gate so the345+ // recovery surface renders even though the vault now exists.349346 setForceRecoveryWords(true);347+ setView("lock");350348 } else if (unlocked && vaultExists) {351349 await openVault(undefined, shouldCelebrate);352350 if (shouldCelebrate) {@@ -741,6 +739,24 @@741739 }742740 743741 async function handleUnlock() {742+ // If the user previously created a vault but hadn't yet confirmed their743+ // recovery words (and the browser was closed before they could), the744+ // peach_recovery_pending flag is still set in storage.local. Re-route745+ // back to the recovery surface so they finish the confirmation step746+ // before reaching the entries view — the words can be re-derived now747+ // that the vault is unlocked again.748+ let recoveryPending = false;749+ try {750+ const out = await chrome.storage.local?.get("peach_recovery_pending");751+ recoveryPending = (out as { peach_recovery_pending?: string })?.peach_recovery_pending === "1";752+ } catch { /* best-effort */ }753+ if (recoveryPending) {754+ setForceRecoveryWords(true);755+ setView("lock");756+ triggerPipPulse();757+ await restoreSettings();758+ return;759+ }744760 setHasVault(true);745761 triggerPipPulse();746762 await restoreSettings();@@ -925,12 +941,10 @@925941 isPro={proStatus()}926942 forceRecoveryWords={forceRecoveryWords()}927943 onUnlock={() => {928- // After recovery words are confirmed, restore real vault929- // state and proceed through the normal unlock path.930- if (forceRecoveryWords()) {931- setForceRecoveryWords(false);932- setHasVault(true);933- }944+ // RecoveryWordDisplay clears the storage flag before945+ // calling onDone, so handleUnlock will see no pending946+ // confirmation and proceed through the normal path.947+ if (forceRecoveryWords()) setForceRecoveryWords(false);934948 return handleUnlock();935949 }}936950 onBackToOnboarding={src/popup/views/Lock.tsx+6 −5
@@ -151,11 +151,12 @@151151 const result = response as { ok: boolean; error?: string };152152 if (result.ok) {153153 // Don't unlock yet — show the recovery words screen first. Mark the154- // confirmation as pending so lock-on-close / auto-lock won't discard155- // the in-memory key while the user is writing the words down. Cleared156- // when the user types the 3 confirmation words successfully.154+ // confirmation as pending in chrome.storage.local so it survives both155+ // popup close (lock-on-close skips while set) and full browser restart156+ // (next unlock re-routes to the recovery surface). Cleared when the157+ // user types the 3 confirmation words successfully.157158 try {158- await chrome.storage.session?.set({ peach_recovery_pending: "1" });159+ await chrome.storage.local?.set({ peach_recovery_pending: "1" });159160 } catch { /* best-effort */ }160161 setShowRecoveryWords(true);161162 setLoading(false);@@ -189,7 +190,7 @@189190 </Match>190191 191192 {/* ── Recovery words display after fresh vault creation ────────── */}192- <Match when={showRecoveryWords() && !props.hasVault}>193+ <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>193194 <RecoveryWordDisplayView194195 onDone={props.onUnlock}195196 />src/popup/views/RecoveryWordDisplay.tsx+4 −3
@@ -70,10 +70,11 @@7070 setConfirmed(true);7171 setConfirmError("");7272 // Clear the recovery-pending flag so lock-on-close / auto-lock resume73- // their normal behavior. Once the user has confirmed the words, losing74- // the in-memory key is no longer a data-loss event.73+ // their normal behavior and future unlocks no longer re-route here.74+ // Once the user has confirmed the words, losing the in-memory key is75+ // no longer a data-loss event.7576 try {76- await chrome.storage.session?.remove("peach_recovery_pending");77+ await chrome.storage.local?.remove("peach_recovery_pending");7778 } catch { /* best-effort */ }7879 // Small pause so user sees the success state, then proceed.7980 await new Promise((r) => setTimeout(r, 600));