import { beforeEach, describe, expect, it, vi } from "vitest"; import { deriveKey } from "../crypto/argon2"; // PCH-01-008: a password change must refresh the MV3 session-key escrow so the // next service-worker restore decrypts the new-key Slot A. The backing stores // are closed over by the chrome mock on globalThis, so they survive the // vi.resetModules() we use to simulate a service-worker restart mid-test. type StorageShape = Record; describe("vault session escrow refresh (PCH-01-008)", () => { let local: StorageShape; let session: StorageShape; beforeEach(() => { vi.resetModules(); local = {}; session = {}; const area = (store: StorageShape) => ({ get: vi.fn(async (key?: string | string[]) => { if (!key) return store; if (typeof key === "string") return { [key]: store[key] }; return key.reduce>((r, k) => { r[k] = store[k]; return r; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(store, next); }), remove: vi.fn(async (keys: string | string[]) => { for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k]; }), }); (globalThis as { chrome?: typeof chrome }).chrome = { storage: { local: area(local), session: area(session) }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } }, } as unknown as typeof chrome; vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); })); }); it("change_password_updates_session_escrow", async () => { const v1 = await import("./vault"); await v1.createVault("old-pass-abc123"); await v1.addEntry({ type: "login", title: "Acct", password: "pw" }); expect(await v1.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true); // Flush the fire-and-forget saveSessionKey microtask, then simulate a SW // restart: a fresh module has derivedKey=null but session/local persist. await Promise.resolve(); vi.resetModules(); const v2 = await import("./vault"); expect(await v2.restoreFromSession()).toBe(true); expect(v2.getEntries().some((e) => e.title === "Acct")).toBe(true); }); it("change_password_with_new_key_updates_session_escrow", async () => { const v1 = await import("./vault"); await v1.createVault("orig-pass-abc123"); await v1.addEntry({ type: "login", title: "Recovered", password: "pw" }); const newSalt = crypto.getRandomValues(new Uint8Array(16)); const newKey = await deriveKey("reset-pass-xyz789", newSalt); await v1.changePasswordWithNewKey(newSalt, newKey); await Promise.resolve(); vi.resetModules(); const v2 = await import("./vault"); expect(await v2.restoreFromSession()).toBe(true); expect(v2.getEntries().some((e) => e.title === "Recovered")).toBe(true); }); it("restoreFromSession rehydrates rootKey so background sync survives a SW restart", async () => { // Background sync bug: entry.ts calls restoreFromSession() on every SW wake-up, // then triggerSyncIfReachable. The runner needs getRootKeyHex() to be non-null // — sync identity derives from rootKey, not derivedKey. The previous // restoreFromSession only set rootKey when the slot had recovery_salt + // recovery_wrapped_root_key; for any vault that hadn't been through // migrateToRecovery, rootKey was silently null and the runner returned // VAULT_LOCKED. The fix is to always derive rootKey from pw_root_* (which // are present on every vault since createVault). const v1 = await import("./vault"); await v1.createVault("strong-pass-abc123"); // unlock() is the real-world path that saves the MV3 session key — the // user's popup opens, types the password, and the session key is parked // in chrome.storage.session for the next SW wake. await v1.unlock("strong-pass-abc123"); const rootBeforeDead = v1.getRootKeyHex(); expect(rootBeforeDead).not.toBeNull(); // Simulate a SW kill: flush the fire-and-forget saveSessionKey promise, // then reset modules so a fresh vault.ts reads the persisted session key. await Promise.resolve(); vi.resetModules(); const v2 = await import("./vault"); expect(await v2.restoreFromSession()).toBe(true); // The bug shape: rootKey was null after restore, so the runner short-circuited // to VAULT_LOCKED even though entries were present. expect(v2.getRootKeyHex()).not.toBeNull(); expect(v2.getRootKeyHex()).toBe(rootBeforeDead); }); it("lock() does NOT clear the MV3 session key — background sync survives popup close", async () => { // Bug shape: any lock() caller (popup close with lockOnClose=true, auto-lock, // explicit user lock) used to clear the session key. The next SW wake // (10-minute sync alarm, post-adoption retry, post-update alarm) found no // session key, restoreFromSession() returned false, the runner returned // VAULT_LOCKED, and the indicator went yellow every 10 minutes. The fix // is to keep the session key across lock() — chrome.storage.session is // cleared on browser close anyway, so the natural lifetime is the // browser session, which is what we want. const v1 = await import("./vault"); await v1.createVault("strong-pass-abc123"); await v1.unlock("strong-pass-abc123"); const sessionKeyBeforeLock = await chrome.storage.session.get("peach_session_dk"); expect(sessionKeyBeforeLock.peach_session_dk).toBeDefined(); // The lock-on-close path: popup port disconnect, alarm tick, etc. v1.lock(); expect(v1.isUnlocked()).toBe(false); const sessionKeyAfterLock = await chrome.storage.session.get("peach_session_dk"); expect(sessionKeyAfterLock.peach_session_dk).toBeDefined(); // Same key bytes — clearSessionKey would have removed the entry. expect(sessionKeyAfterLock.peach_session_dk).toEqual(sessionKeyBeforeLock.peach_session_dk); // The next SW wake restores the vault from the surviving session key. await Promise.resolve(); vi.resetModules(); const v2 = await import("./vault"); expect(await v2.restoreFromSession()).toBe(true); expect(v2.isUnlocked()).toBe(true); expect(v2.getRootKeyHex()).not.toBeNull(); }); });