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
↓ peach-browser-f0f75ccf.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.session-escrow.test.ts+138 −4src/background/vault.ts+49 −11manifest.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": {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",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",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 () => {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