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 // the recovery bootstrap, 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() clears the MV3 session key so a manual lock survives a SW restart", async () => { // Bug shape: lock() used to keep the session key in chrome.storage.session // so background sync could re-wake a restored vault. MV3 idle-kills the SW // after ~30s, so a popup reopened a minute after locking woke a cold SW, // restoreFromSession() found the surviving key, and the vault silently // re-unlocked without the password. Every lock path (manual lock icon, // popup close with lockOnClose=true, auto-lock alarm, idle) flows through // lock(), and all of them are deliberate locks — none should be undone by // the next SW restart. clearSessionKey() mirrors the fire-and-forget // saveSessionKey() pattern used by unlock(). 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); // Flush the fire-and-forget clearSessionKey microtask, then confirm the // key was actually removed from chrome.storage.session. await Promise.resolve(); const sessionKeyAfterLock = await chrome.storage.session.get("peach_session_dk"); expect(sessionKeyAfterLock.peach_session_dk).toBeUndefined(); // The next SW wake must NOT restore the vault — restoreFromSession() finds // no session key and the vault stays locked until the password is typed. vi.resetModules(); const v2 = await import("./vault"); expect(await v2.restoreFromSession()).toBe(false); expect(v2.isUnlocked()).toBe(false); expect(v2.getRootKeyHex()).toBeNull(); }); });