f0f75ccf82d8

fix in-flight unlock and password-change escrow races with epoch and ordered session mutations (PB-26-306, v5.0.89)

time
2026-09-04 01:30 UTC · 1 month ago
machine
koh2
agent
claude-sonnet-5
session
PB-26-306 residual lock escrow race fix
parent
7b6b2aaa225e
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.session-escrow.test.ts+138 −4src/background/vault.ts+49 −11
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.88",4+  "version": "5.0.89",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.88",5+  "version": "5.0.89",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.88",3+  "version": "5.0.89",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/vault.session-escrow.test.ts+138 −4
@@ -17,6 +17,23 @@1717   return { promise, resolve };1818 }1919 20+function pauseSessionKeyWrite(session: StorageShape): {21+  started: Promise<void>;22+  release: () => void;23+} {24+  const started = deferred();25+  const release = deferred();26+  const sessionSet = chrome.storage.session.set as unknown as ReturnType<typeof vi.fn>;27+  sessionSet.mockImplementation(async (next: Record<string, unknown>) => {28+    if ("peach_session_dk" in next) {29+      started.resolve();30+      await release.promise;31+    }32+    Object.assign(session, next);33+  });34+  return { started: started.promise, release: release.resolve };35+}36+2037 describe("vault session escrow refresh (PCH-01-008)", () => {2138   let local: StorageShape;2239   let session: StorageShape;@@ -50,9 +67,8 @@5067     await v1.addEntry({ type: "login", title: "Acct", password: "pw" });5168     expect(await v1.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);5269 53-    // Flush the fire-and-forget saveSessionKey microtask, then simulate a SW54-    // restart: a fresh module has derivedKey=null but session/local persist.55-    await Promise.resolve();70+    // Simulate a SW restart after the awaited escrow refresh: a fresh module71+    // has derivedKey=null but session/local persist.5672     vi.resetModules();5773     const v2 = await import("./vault");5874     expect(await v2.restoreFromSession()).toBe(true);@@ -68,7 +84,6 @@6884     const newKey = await deriveKey("reset-pass-xyz789", newSalt);6985     await v1.changePasswordWithNewKey(newSalt, newKey);7086 71-    await Promise.resolve();7287     vi.resetModules();7388     const v2 = await import("./vault");7489     expect(await v2.restoreFromSession()).toBe(true);@@ -137,6 +152,125 @@137152     expect(await v2.restoreFromSession()).toBe(false);138153     expect(v2.isUnlocked()).toBe(false);139154     expect(v2.getRootKeyHex()).toBeNull();155+  });156+157+  it("a lock invalidates a real unlock paused while writing session escrow", async () => {158+    const vault = await import("./vault");159+    await vault.createVault("strong-pass-abc123");160+    await vault.lock();161+162+    const write = pauseSessionKeyWrite(session);163+    const unlocking = vault.unlock("strong-pass-abc123");164+    await write.started;165+166+    let lockSettled = false;167+    const locking = vault.lock().then(() => {168+      lockSettled = true;169+    });170+    await new Promise((resolve) => setTimeout(resolve, 0));171+    const lockSettledBeforeWrite = lockSettled;172+    write.release();173+174+    expect(lockSettledBeforeWrite).toBe(false);175+    await expect(unlocking).resolves.toBe(false);176+    await locking;177+    expect(vault.isUnlocked()).toBe(false);178+    expect(session.peach_session_dk).toBeUndefined();179+180+    vi.resetModules();181+    const restarted = await import("./vault");182+    expect(await restarted.restoreFromSession()).toBe(false);183+  });184+185+  it("a lock invalidates a duress unlock paused while writing session escrow", async () => {186+    const vault = await import("./vault");187+    await vault.createVault("real-pass-abc123");188+    await vault.setupDuress("duress-pass-xyz789");189+    await vault.lock();190+191+    const write = pauseSessionKeyWrite(session);192+    const unlocking = vault.unlock("duress-pass-xyz789");193+    await write.started;194+195+    const locking = vault.lock();196+    await Promise.resolve();197+    write.release();198+199+    await expect(unlocking).resolves.toBe(false);200+    await locking;201+    expect(vault.isUnlocked()).toBe(false);202+    expect(session.peach_session_dk).toBeUndefined();203+  });204+205+  it("a lock invalidates a key unlock paused while writing session escrow", async () => {206+    const vault = await import("./vault");207+    await vault.createVault("strong-pass-abc123");208+    const keyHex = vault.getDerivedKeyHex();209+    await vault.lock();210+211+    const write = pauseSessionKeyWrite(session);212+    const unlocking = vault.unlockWithKey(keyHex);213+    await write.started;214+215+    const locking = vault.lock();216+    await Promise.resolve();217+    write.release();218+219+    await expect(unlocking).resolves.toEqual({ ok: false, error: "invalid_key" });220+    await locking;221+    expect(vault.isUnlocked()).toBe(false);222+    expect(session.peach_session_dk).toBeUndefined();223+  });224+225+  it("a lock invalidates a password change paused while writing session escrow", async () => {226+    const vault = await import("./vault");227+    await vault.createVault("old-pass-abc123");228+229+    const write = pauseSessionKeyWrite(session);230+    const changing = vault.changePassword("old-pass-abc123", "new-pass-xyz789");231+    await write.started;232+233+    const locking = vault.lock();234+    await Promise.resolve();235+    write.release();236+237+    await expect(changing).resolves.toBe(false);238+    await locking;239+    expect(vault.isUnlocked()).toBe(false);240+    expect(session.peach_session_dk).toBeUndefined();241+242+    vi.resetModules();243+    const restarted = await import("./vault");244+    expect(await restarted.restoreFromSession()).toBe(false);245+  });246+247+  it("a lock clears a recovery re-key paused while writing session escrow", async () => {248+    const vault = await import("./vault");249+    await vault.createVault("old-pass-abc123");250+    const newSalt = crypto.getRandomValues(new Uint8Array(16));251+    const newKey = await deriveKey("new-pass-xyz789", newSalt);252+253+    const write = pauseSessionKeyWrite(session);254+    const changing = vault.changePasswordWithNewKey(newSalt, newKey);255+    await write.started;256+257+    let lockSettled = false;258+    const locking = vault.lock().then(() => {259+      lockSettled = true;260+    });261+    await new Promise((resolve) => setTimeout(resolve, 0));262+    const lockSettledBeforeWrite = lockSettled;263+    write.release();264+265+    expect(lockSettledBeforeWrite).toBe(false);266+    await expect(changing).rejects.toThrow("vault_locked");267+    await locking;268+    expect(vault.isUnlocked()).toBe(false);269+    expect(session.peach_session_dk).toBeUndefined();270+271+    vi.resetModules();272+    const restarted = await import("./vault");273+    expect(await restarted.restoreFromSession()).toBe(false);140274   });141275 142276   it("a lock invalidates a restore paused while reading the session key", async () => {

view file

src/background/vault.ts+49 −11
@@ -283,7 +283,6 @@283283     unwrappedKey = unhex(masterKeyHex);284284     const result = await unlockWithKey(masterKeyHex);285285     if (!result.ok) return false;286-    await saveSessionKey(unwrappedKey, "real");287286     await resetPinFailedAttempts();288287     return true;289288   } catch {@@ -714,33 +713,48 @@714713   newSalt: Uint8Array,715714   newKey: Uint8Array716715 ): Promise<void> {716+  const epoch = vaultEpoch;717+  const throwIfStale = () => {718+    if (epoch === vaultEpoch) return;719+    zeroKey(newKey);720+    throw new Error("vault_locked");721+  };717722   const slotA = await loadSlot(SLOT_A);723+  throwIfStale();718724   if (!slotA) throw new Error("missing slot A");719725   // reencryptVaultForNewKey must receive the updated salt so that subsequent720726   // UNLOCK calls with the new password can re-derive the correct key.721727   await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });728+  throwIfStale();722729   if (derivedKey) zeroKey(derivedKey);723730   derivedKey = newKey;724731   // PCH-01-008: re-escrow the new key (see changePassword). The recovery flow725732   // reaches this path, so a post-recovery SW restart would otherwise lock out726733   // a user who just reset their password.727-  void saveSessionKey(newKey, "real");734+  await saveSessionKey(newKey, "real", epoch);735+  throwIfStale();728736   // Refresh pw_root_* under the new key so deriveRootKeyFromDerivedKey works729737   // on the next unlock (H2/COR-006). If rootKey is missing — recovery flow730738   // should have populated it — fall back silently rather than blowing up the731739   // recovery path; the escrow is already present on every supported vault.732740   if (rootKey) {733741     const slotAfter = await loadSlot(SLOT_A);742+    throwIfStale();734743     if (slotAfter) {735744       const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);745+      throwIfStale();736746       await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });747+      throwIfStale();737748     }738749   }739750   if (!duressConfigured) {740751     await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));752+    throwIfStale();741753   }742754   await clearPinUnlockStorage();755+  throwIfStale();743756   await stampLocalVaultMutation();757+  throwIfStale();744758 }745759 746760 // ---------------------------------------------------------------------------@@ -1506,7 +1520,8 @@15061520       await lock();15071521       return false;15081522     }1509-    await saveSessionKey(keyA, "real");1523+    await saveSessionKey(keyA, "real", epoch);1524+    if (stale()) return false;15101525     return true;15111526   }15121527 @@ -1532,7 +1547,8 @@15321547       await lock();15331548       return false;15341549     }1535-    await saveSessionKey(keyB, "decoy");1550+    await saveSessionKey(keyB, "decoy", epoch);1551+    if (stale()) return false;15361552     return true;15371553   }15381554 @@ -1556,6 +1572,16 @@15561572 // Firefox does not implement it (persistent background script, no restart risk).15571573 const SESSION_KEY_STORAGE = "peach_session_dk";15581574 1575+// Order writes and removals so lock's awaited removal is a barrier after1576+// every escrow write that started before it.1577+let sessionKeyMutation = Promise.resolve();1578+1579+function mutateSessionKey(mutation: () => Promise<void>): Promise<void> {1580+  const task = sessionKeyMutation.then(mutation, mutation);1581+  sessionKeyMutation = task.catch(() => undefined);1582+  return task;1583+}1584+15591585 // PB-26-203: the session-key escrow may only operate where startup positively15601586 // established trusted-only session storage. Defaults to available so direct15611587 // vault imports (tests, non-entry bootstraps) keep working; entry.ts applies@@ -1575,14 +1601,24 @@15751601   await clearSessionKey();15761602 }15771603 1578-async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1579-  if (!chrome.storage.session || !sessionEscrowAvailable) return;1580-  await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1604+async function saveSessionKey(1605+  key: Uint8Array,1606+  mode: "real" | "decoy",1607+  expectedEpoch = vaultEpoch,1608+): Promise<void> {1609+  const session = chrome.storage.session;1610+  if (!session || !sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1611+  const stored = { keyHex: hex(key), mode };1612+  await mutateSessionKey(async () => {1613+    if (!sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1614+    await session.set({ [SESSION_KEY_STORAGE]: stored });1615+  });15811616 }15821617 15831618 async function clearSessionKey(): Promise<void> {1584-  if (!chrome.storage.session) return;1585-  await chrome.storage.session.remove(SESSION_KEY_STORAGE);1619+  const session = chrome.storage.session;1620+  if (!session) return;1621+  await mutateSessionKey(() => session.remove(SESSION_KEY_STORAGE));15861622 }15871623 15881624 // A lock that only holds while the service worker stays alive is no lock at@@ -1755,7 +1791,8 @@17551791       await lock();17561792       return { ok: false, error: "invalid_key" };17571793     }1758-    await saveSessionKey(masterKeyBytes, "real");1794+    await saveSessionKey(masterKeyBytes, "real", epoch);1795+    if (stale()) return { ok: false, error: "invalid_key" };17591796     return { ok: true, entries };17601797   } catch {17611798     if (masterKeyBytes && derivedKey !== masterKeyBytes) zeroKey(masterKeyBytes);@@ -3360,7 +3397,8 @@33603397     await lock();33613398     return false;33623399   }3363-  await saveSessionKey(newKey, "real");3400+  await saveSessionKey(newKey, "real", epoch);3401+  if (stale()) return false;33643402 33653403   // Regenerate noise for slot B when duress is not in use. Runs after the33663404   // key swap so a failure here is a disposable noise regeneration, not a

view file