4da393b64b5d

serialize lock/restore with vault epoch and awaited escrow clear (PB-26-204, v5.0.58)

time
2026-09-03 09:59 UTC · 1 month ago
machine
koh2
agent
muse-spark-1.3-contributor-free
session
PB-26-204 lock restore race
parent
1f5db0955af7
9 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/alarms.ts+2 −2src/background/entry.ts+1 −1src/background/messages.test.ts+39 −0src/background/messages.ts+2 −2src/background/vault.session-escrow.test.ts+152 −0src/background/vault.ts+57 −17
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.57",4+  "version": "5.0.58",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "5.0.57",5+  "version": "5.0.58",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.0.57",3+  "version": "5.0.58",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/alarms.ts+2 −2
@@ -53,7 +53,7 @@5353   chrome.alarms.onAlarm.addListener(async (alarm) => {5454     if (alarm.name !== AUTO_LOCK_ALARM) return;5555     if (await isRecoveryConfirmPending()) return;56-    lock();56+    await lock();5757     broadcastLocked();5858   });5959 }@@ -63,7 +63,7 @@6363     if (resolveAutoLockMinutes() <= 0) return;6464     if (state !== "idle" && state !== "locked") return;6565     if (await isRecoveryConfirmPending()) return;66-    lock();66+    await lock();6767     broadcastLocked();6868   });6969 }

view file

src/background/entry.ts+1 −1
@@ -53,7 +53,7 @@5353     const s = await ensureSettingsLoaded();5454     if (!isUnlocked() || s.lockOnClose !== true) return;5555     if (await isRecoveryConfirmPending()) return;56-    lock();56+    await lock();5757   });5858 });5959 

view file

src/background/messages.test.ts+39 −0
@@ -4,6 +4,14 @@44 import type { Message, MessageResponse } from "../shared/types";55 66 type StorageShape = Record<string, unknown>;7+8+function deferred(): { promise: Promise<void>; resolve: () => void } {9+  let resolve!: () => void;10+  const promise = new Promise<void>((done) => {11+    resolve = done;12+  });13+  return { promise, resolve };14+}715 type MessageListener = (816   msg: unknown,917   sender: chrome.runtime.MessageSender,@@ -640,6 +648,37 @@640648       dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })641649     ).resolves.toMatchObject({ ok: true });642650     expect(vault.isUnlocked()).toBe(true);651+  });652+653+  it("does not acknowledge LOCK until session cleanup finishes", async () => {654+    await expect(655+      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })656+    ).resolves.toMatchObject({ ok: true });657+658+    const removalStarted = deferred();659+    const releaseRemoval = deferred();660+    const remove = chrome.storage.session.remove as unknown as ReturnType<typeof vi.fn>;661+    remove.mockImplementation(async (keys: string | string[]) => {662+      removalStarted.resolve();663+      await releaseRemoval.promise;664+      for (const key of Array.isArray(keys) ? keys : [keys]) delete sessionStorage[key];665+    });666+667+    let responded = false;668+    const response = dispatch({ type: "LOCK" }).then((result) => {669+      responded = true;670+      return result;671+    });672+    await removalStarted.promise;673+674+    try {675+      await Promise.resolve();676+      expect(responded).toBe(false);677+    } finally {678+      releaseRemoval.resolve();679+    }680+681+    await expect(response).resolves.toMatchObject({ ok: true });643682   });644683 645684   it("remembers the last successful unlock method", async () => {

view file

src/background/messages.ts+2 −2
@@ -555,7 +555,7 @@555555         return { ok: true };556556       }557557       case "LOCK":558-        vault.lock();558+        await vault.lock();559559         return { ok: true };560560       case "IS_UNLOCKED":561561         return { ok: true, data: vault.isUnlocked() };@@ -1447,7 +1447,7 @@14471447           "peach_license",14481448           "peach_pro_celebrated",14491449         ]);1450-        vault.lock();1450+        await vault.lock();14511451         await storageClear();14521452         await vault.clearVaultHistory();14531453         const toRestore: Record<string, unknown> = {};

view file

src/background/vault.session-escrow.test.ts+152 −0
@@ -8,6 +8,14 @@88 99 type StorageShape = Record<string, unknown>;1010 11+function deferred(): { promise: Promise<void>; resolve: () => void } {12+  let resolve!: () => void;13+  const promise = new Promise<void>((done) => {14+    resolve = done;15+  });16+  return { promise, resolve };17+}18+1119 describe("vault session escrow refresh (PCH-01-008)", () => {1220   let local: StorageShape;1321   let session: StorageShape;@@ -128,5 +136,149 @@128136     expect(await v2.restoreFromSession()).toBe(false);129137     expect(v2.isUnlocked()).toBe(false);130138     expect(v2.getRootKeyHex()).toBeNull();139+  });140+141+  it("a lock invalidates a restore paused while reading the session key", async () => {142+    const v1 = await import("./vault");143+    await v1.createVault("strong-pass-abc123");144+    await v1.unlock("strong-pass-abc123");145+    await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());146+147+    const staleSessionKey = session.peach_session_dk;148+    const readStarted = deferred();149+    const releaseRead = deferred();150+    const sessionGet = chrome.storage.session.get as unknown as ReturnType<typeof vi.fn>;151+    sessionGet.mockImplementation(async (key?: string | string[]) => {152+      if (key === "peach_session_dk") {153+        readStarted.resolve();154+        await releaseRead.promise;155+        return { peach_session_dk: staleSessionKey };156+      }157+      if (!key) return session;158+      const keys = Array.isArray(key) ? key : [key];159+      return keys.reduce<StorageShape>((result, entry) => {160+        result[entry] = session[entry];161+        return result;162+      }, {});163+    });164+165+    vi.resetModules();166+    const v2 = await import("./vault");167+    const restoring = v2.restoreFromSession();168+    await readStarted.promise;169+170+    await v2.lock();171+    releaseRead.resolve();172+173+    await expect(restoring).resolves.toBe(false);174+    expect(v2.isUnlocked()).toBe(false);175+    expect(v2.getRootKeyHex()).toBeNull();176+  });177+178+  it("keeps decrypted restore state private until the final epoch-checked commit", async () => {179+    const v1 = await import("./vault");180+    await v1.createVault("strong-pass-abc123");181+    await v1.unlock("strong-pass-abc123");182+    await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());183+184+    const settingsReadStarted = deferred();185+    const releaseSettingsRead = deferred();186+    const localGet = chrome.storage.local.get as unknown as ReturnType<typeof vi.fn>;187+    localGet.mockImplementation(async (key?: string | string[]) => {188+      if (key === "peach_settings") {189+        settingsReadStarted.resolve();190+        await releaseSettingsRead.promise;191+      }192+      if (!key) return local;193+      const keys = Array.isArray(key) ? key : [key];194+      return keys.reduce<StorageShape>((result, entry) => {195+        result[entry] = local[entry];196+        return result;197+      }, {});198+    });199+200+    vi.resetModules();201+    const v2 = await import("./vault");202+    const restoring = v2.restoreFromSession();203+    await settingsReadStarted.promise;204+205+    try {206+      expect(v2.isUnlocked()).toBe(false);207+      const locking = Promise.resolve(v2.lock());208+      expect(v2.isUnlocked()).toBe(false);209+      releaseSettingsRead.resolve();210+      await locking;211+212+      await expect(restoring).resolves.toBe(false);213+      expect(v2.isUnlocked()).toBe(false);214+      expect(v2.getRootKeyHex()).toBeNull();215+    } finally {216+      releaseSettingsRead.resolve();217+      await Promise.allSettled([restoring]);218+    }219+  });220+221+  it("does not resolve lock until session escrow and recovery confirmation are absent", async () => {222+    const vault = await import("./vault");223+    const pending = await import("./recovery-pending");224+    await vault.createVault("strong-pass-abc123");225+    await vault.unlock("strong-pass-abc123");226+    await pending.setRecoveryConfirmPending();227+    await vi.waitFor(() => {228+      expect(session.peach_session_dk).toBeDefined();229+      expect(session.peach_recovery_pending).toBeDefined();230+    });231+232+    const removalStarted = deferred();233+    const releaseRemoval = deferred();234+    const sessionRemove = chrome.storage.session.remove as unknown as ReturnType<typeof vi.fn>;235+    sessionRemove.mockImplementation(async (keys: string | string[]) => {236+      removalStarted.resolve();237+      await releaseRemoval.promise;238+      for (const key of Array.isArray(keys) ? keys : [keys]) delete session[key];239+    });240+241+    let settled = false;242+    const locking = Promise.resolve(vault.lock()).then(() => {243+      settled = true;244+    });245+    expect(vault.isUnlocked()).toBe(false);246+    await removalStarted.promise;247+248+    try {249+      await Promise.resolve();250+      expect(settled).toBe(false);251+    } finally {252+      releaseRemoval.resolve();253+      await locking;254+    }255+256+    expect(session.peach_session_dk).toBeUndefined();257+    expect(session.peach_recovery_pending).toBeUndefined();258+  });259+260+  it("deduplicates simultaneous restores and runs the unlock hook once", async () => {261+    const v1 = await import("./vault");262+    await v1.createVault("strong-pass-abc123");263+    await v1.unlock("strong-pass-abc123");264+    await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());265+266+    vi.resetModules();267+    const v2 = await import("./vault");268+    const afterUnlock = vi.fn(async () => undefined);269+    v2.setLockedSyncHooks({270+      refresh: vi.fn(async () => undefined),271+      afterUnlock,272+    });273+    const sessionGet = chrome.storage.session.get as unknown as ReturnType<typeof vi.fn>;274+    sessionGet.mockClear();275+276+    await expect(Promise.all([277+      v2.restoreFromSession(),278+      v2.restoreFromSession(),279+    ])).resolves.toEqual([true, true]);280+281+    expect(sessionGet).toHaveBeenCalledTimes(1);282+    expect(afterUnlock).toHaveBeenCalledTimes(1);131283   });132284 });

view file

src/background/vault.ts+57 −17
@@ -823,7 +823,7 @@823823  * The call is idempotent: removing keys that aren't there is a no-op.824824  */825825 export async function rollbackAdoptedVault(): Promise<void> {826-  lock();826+  await lock();827827   try {828828     await chrome.storage.local.remove([829829       SLOT_A,@@ -1461,7 +1461,7 @@14611461     // without this escrow are outside the supported storage format.14621462     rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;14631463     if (!rootKey) {1464-      lock();1464+      await lock();14651465       return false;14661466     }14671467 @@ -1516,6 +1516,18 @@15161516   await chrome.storage.session.remove(SESSION_KEY_STORAGE);15171517 }15181518 1519+// A lock that only holds while the service worker stays alive is no lock at1520+// all: a cold worker's restoreFromSession() would find the surviving session1521+// key and silently re-unlock ~a minute after the user locked. So lock()1522+// bumps this epoch synchronously at entry and zeroes keys/indexes before any1523+// await; a restore that started earlier sees the mismatch after its next1524+// await and aborts instead of publishing.1525+let vaultEpoch = 0;1526+1527+// One in-flight restore shared by concurrent callers: storage is read once1528+// and the locked-sync unlock hook runs once.1529+let restoreInFlight: Promise<boolean> | null = null;1530+15191531 /**15201532  * On SW restart, re-derive all in-memory vault state from the session key15211533  * stored in chrome.storage.session (set at unlock time, cleared by lock();@@ -1525,8 +1537,23 @@15251537  */15261538 export async function restoreFromSession(): Promise<boolean> {15271539   if (derivedKey !== null) return true;1540+  if (restoreInFlight) return restoreInFlight;1541+  const task = restoreFromSessionInner();1542+  restoreInFlight = task;1543+  try {1544+    return await task;1545+  } finally {1546+    restoreInFlight = null;1547+  }1548+}1549+1550+async function restoreFromSessionInner(): Promise<boolean> {1551+  const epoch = vaultEpoch;1552+  const stale = () => epoch !== vaultEpoch;1553+  if (derivedKey !== null) return true;15281554   if (!chrome.storage.session) return false;15291555   const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1556+  if (stale()) return false;15301557   const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;15311558   if (!entry?.keyHex) return false;15321559 @@ -1534,38 +1561,50 @@15341561   const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;15351562   const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";15361563   const slot = await loadSlot(slotKey);1564+  if (stale()) { zeroKey(key); return false; }15371565   if (!slot) { await clearSessionKey(); return false; }15381566 15391567   const result = await tryDecryptSlotResult(slot, key, aadTag);1568+  if (stale()) { zeroKey(key); return false; }15401569   if (result.status !== "ok") { await clearSessionKey(); return false; }15411570 1542-  derivedKey = key;1543-  vaultMode = entry.mode;1544-  entries = result.payload.entries;1545-  tombstones = result.payload.tombstones;1546-  duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1547-  buildDomainIndexSync(entries);1571+  // Decrypted payload stays in locals until the final epoch-checked commit1572+  // below; nothing observable is published before then.1573+  const payload = result.payload;15481574   await loadSettings();1549-  touchAutoLock();1575+  if (stale()) { zeroKey(key); return false; }15501576 15511577   // Rehydrate the root key used by sync identity derivation from the password15521578   // escrow stored on the current vault format.1579+  let freshRoot: Uint8Array | null = null;15531580   if (entry.mode === "real") {1554-    rootKey = await deriveRootKeyFromDerivedKey(key, slot);1555-    if (!rootKey) {1556-      lock();1581+    freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1582+    if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1583+    if (!freshRoot) {1584+      await lock();15571585       return false;15581586     }15591587   }15601588 1589+  if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1590+  derivedKey = key;1591+  vaultMode = entry.mode;1592+  entries = payload.entries;1593+  tombstones = payload.tombstones;1594+  duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1595+  if (freshRoot) rootKey = freshRoot;1596+  buildDomainIndexSync(entries);1597+  touchAutoLock();1598+15611599   await lockedSyncUnlockHook?.().catch((error) => {15621600     console.warn("[Peach sync] locked inbox drain failed:", error);15631601   });1602+  if (stale()) return false;15641603 15651604   return true;15661605 }15671606 1568-export function lock(): void {1607+export async function lock(): Promise<void> {15691608   // A user-initiated lock must be durable across MV3 service-worker restarts.15701609   // The SW is idle-killed after ~30s of inactivity, and the popup's next open15711610   // wakes a cold SW whose restoreFromSession() reads chrome.storage.session.@@ -1575,8 +1614,7 @@15751614   // session key once bought (sync runner re-waking to a restored vault) is15761615   // not worth an unlocked vault the user never asked to unlock; when locked,15771616   // sync-runner correctly reports VAULT_LOCKED instead.1578-  void clearSessionKey();1579-  void clearRecoveryConfirmPending();1617+  vaultEpoch++;15801618   if (derivedKey) {15811619     zeroKey(derivedKey);15821620     derivedKey = null;@@ -1592,6 +1630,8 @@15921630   vaultMode = "real";15931631   duressConfigured = false;15941632   clearAutoLock();1633+  await clearSessionKey();1634+  await clearRecoveryConfirmPending();15951635 }15961636 15971637 export function isUnlocked(): boolean {@@ -1622,7 +1662,7 @@16221662 16231663     rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);16241664     if (!rootKey) {1625-      lock();1665+      await lock();16261666       return { ok: false, error: "invalid_key" };16271667     }16281668     return { ok: true, entries };@@ -3086,7 +3126,7 @@30863126   // Lock so the user must prove they can decrypt the restored blob. If they30873127   // restored a pre-password-change snapshot they discover it immediately at30883128   // the lock screen rather than silently holding an inaccessible vault.3089-  lock();3129+  await lock();30903130 30913131   return { ok: true };30923132 }

view file