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
7 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 −3
manifest.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": {

view file

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",

view file

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": {

view file

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 }

view file

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={

view file

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         />

view file

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));

view file