vault.session-escrow.test.ts

6.4 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";2import { deriveKey } from "../crypto/argon2";34// PCH-01-008: a password change must refresh the MV3 session-key escrow so the5// next service-worker restore decrypts the new-key Slot A. The backing stores6// are closed over by the chrome mock on globalThis, so they survive the7// vi.resetModules() we use to simulate a service-worker restart mid-test.89type StorageShape = Record<string, unknown>;1011describe("vault session escrow refresh (PCH-01-008)", () => {12  let local: StorageShape;13  let session: StorageShape;1415  beforeEach(() => {16    vi.resetModules();17    local = {};18    session = {};19    const area = (store: StorageShape) => ({20      get: vi.fn(async (key?: string | string[]) => {21        if (!key) return store;22        if (typeof key === "string") return { [key]: store[key] };23        return key.reduce<Record<string, unknown>>((r, k) => { r[k] = store[k]; return r; }, {});24      }),25      set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),26      remove: vi.fn(async (keys: string | string[]) => {27        for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k];28      }),29    });30    (globalThis as { chrome?: typeof chrome }).chrome = {31      storage: { local: area(local), session: area(session) },32      alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },33      idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },34    } as unknown as typeof chrome;35    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));36  });3738  it("change_password_updates_session_escrow", async () => {39    const v1 = await import("./vault");40    await v1.createVault("old-pass-abc123");41    await v1.addEntry({ type: "login", title: "Acct", password: "pw" });42    expect(await v1.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);4344    // Flush the fire-and-forget saveSessionKey microtask, then simulate a SW45    // restart: a fresh module has derivedKey=null but session/local persist.46    await Promise.resolve();47    vi.resetModules();48    const v2 = await import("./vault");49    expect(await v2.restoreFromSession()).toBe(true);50    expect(v2.getEntries().some((e) => e.title === "Acct")).toBe(true);51  });5253  it("change_password_with_new_key_updates_session_escrow", async () => {54    const v1 = await import("./vault");55    await v1.createVault("orig-pass-abc123");56    await v1.addEntry({ type: "login", title: "Recovered", password: "pw" });5758    const newSalt = crypto.getRandomValues(new Uint8Array(16));59    const newKey = await deriveKey("reset-pass-xyz789", newSalt);60    await v1.changePasswordWithNewKey(newSalt, newKey);6162    await Promise.resolve();63    vi.resetModules();64    const v2 = await import("./vault");65    expect(await v2.restoreFromSession()).toBe(true);66    expect(v2.getEntries().some((e) => e.title === "Recovered")).toBe(true);67  });6869  it("restoreFromSession rehydrates rootKey so background sync survives a SW restart", async () => {70    // Background sync bug: entry.ts calls restoreFromSession() on every SW wake-up,71    // then triggerSyncIfReachable. The runner needs getRootKeyHex() to be non-null72    // — sync identity derives from rootKey, not derivedKey. The previous73    // restoreFromSession only set rootKey when the slot had recovery_salt +74    // recovery_wrapped_root_key; for any vault that hadn't been through75    // the recovery bootstrap, rootKey was silently null and the runner returned76    // VAULT_LOCKED. The fix is to always derive rootKey from pw_root_* (which77    // are present on every vault since createVault).78    const v1 = await import("./vault");79    await v1.createVault("strong-pass-abc123");80    // unlock() is the real-world path that saves the MV3 session key — the81    // user's popup opens, types the password, and the session key is parked82    // in chrome.storage.session for the next SW wake.83    await v1.unlock("strong-pass-abc123");84    const rootBeforeDead = v1.getRootKeyHex();85    expect(rootBeforeDead).not.toBeNull();8687    // Simulate a SW kill: flush the fire-and-forget saveSessionKey promise,88    // then reset modules so a fresh vault.ts reads the persisted session key.89    await Promise.resolve();90    vi.resetModules();91    const v2 = await import("./vault");92    expect(await v2.restoreFromSession()).toBe(true);93    // The bug shape: rootKey was null after restore, so the runner short-circuited94    // to VAULT_LOCKED even though entries were present.95    expect(v2.getRootKeyHex()).not.toBeNull();96    expect(v2.getRootKeyHex()).toBe(rootBeforeDead);97  });9899  it("lock() clears the MV3 session key so a manual lock survives a SW restart", async () => {100    // Bug shape: lock() used to keep the session key in chrome.storage.session101    // so background sync could re-wake a restored vault. MV3 idle-kills the SW102    // after ~30s, so a popup reopened a minute after locking woke a cold SW,103    // restoreFromSession() found the surviving key, and the vault silently104    // re-unlocked without the password. Every lock path (manual lock icon,105    // popup close with lockOnClose=true, auto-lock alarm, idle) flows through106    // lock(), and all of them are deliberate locks — none should be undone by107    // the next SW restart. clearSessionKey() mirrors the fire-and-forget108    // saveSessionKey() pattern used by unlock().109    const v1 = await import("./vault");110    await v1.createVault("strong-pass-abc123");111    await v1.unlock("strong-pass-abc123");112    const sessionKeyBeforeLock = await chrome.storage.session.get("peach_session_dk");113    expect(sessionKeyBeforeLock.peach_session_dk).toBeDefined();114115    // The lock-on-close path: popup port disconnect, alarm tick, etc.116    v1.lock();117    expect(v1.isUnlocked()).toBe(false);118    // Flush the fire-and-forget clearSessionKey microtask, then confirm the119    // key was actually removed from chrome.storage.session.120    await Promise.resolve();121    const sessionKeyAfterLock = await chrome.storage.session.get("peach_session_dk");122    expect(sessionKeyAfterLock.peach_session_dk).toBeUndefined();123124    // The next SW wake must NOT restore the vault — restoreFromSession() finds125    // no session key and the vault stays locked until the password is typed.126    vi.resetModules();127    const v2 = await import("./vault");128    expect(await v2.restoreFromSession()).toBe(false);129    expect(v2.isUnlocked()).toBe(false);130    expect(v2.getRootKeyHex()).toBeNull();131  });132});