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
↓ peach-browser-4da393b6.tar.gzatlas at this save
files changed9 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 −17manifest.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": {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",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",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 }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 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 () => {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> = {};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 });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 }