vault.session-escrow.test.ts

12.7 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>;1011function 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}1819describe("vault session escrow refresh (PCH-01-008)", () => {20  let local: StorageShape;21  let session: StorageShape;2223  beforeEach(() => {24    vi.resetModules();25    local = {};26    session = {};27    const area = (store: StorageShape) => ({28      get: vi.fn(async (key?: string | string[]) => {29        if (!key) return store;30        if (typeof key === "string") return { [key]: store[key] };31        return key.reduce<Record<string, unknown>>((r, k) => { r[k] = store[k]; return r; }, {});32      }),33      set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),34      remove: vi.fn(async (keys: string | string[]) => {35        for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k];36      }),37    });38    (globalThis as { chrome?: typeof chrome }).chrome = {39      storage: { local: area(local), session: area(session) },40      alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },41      idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },42    } as unknown as typeof chrome;43    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));44  });4546  it("change_password_updates_session_escrow", async () => {47    const v1 = await import("./vault");48    await v1.createVault("old-pass-abc123");49    await v1.addEntry({ type: "login", title: "Acct", password: "pw" });50    expect(await v1.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);5152    // Flush the fire-and-forget saveSessionKey microtask, then simulate a SW53    // restart: a fresh module has derivedKey=null but session/local persist.54    await Promise.resolve();55    vi.resetModules();56    const v2 = await import("./vault");57    expect(await v2.restoreFromSession()).toBe(true);58    expect(v2.getEntries().some((e) => e.title === "Acct")).toBe(true);59  });6061  it("change_password_with_new_key_updates_session_escrow", async () => {62    const v1 = await import("./vault");63    await v1.createVault("orig-pass-abc123");64    await v1.addEntry({ type: "login", title: "Recovered", password: "pw" });6566    const newSalt = crypto.getRandomValues(new Uint8Array(16));67    const newKey = await deriveKey("reset-pass-xyz789", newSalt);68    await v1.changePasswordWithNewKey(newSalt, newKey);6970    await Promise.resolve();71    vi.resetModules();72    const v2 = await import("./vault");73    expect(await v2.restoreFromSession()).toBe(true);74    expect(v2.getEntries().some((e) => e.title === "Recovered")).toBe(true);75  });7677  it("restoreFromSession rehydrates rootKey so background sync survives a SW restart", async () => {78    // Background sync bug: entry.ts calls restoreFromSession() on every SW wake-up,79    // then triggerSyncIfReachable. The runner needs getRootKeyHex() to be non-null80    // — sync identity derives from rootKey, not derivedKey. The previous81    // restoreFromSession only set rootKey when the slot had recovery_salt +82    // recovery_wrapped_root_key; for any vault that hadn't been through83    // the recovery bootstrap, rootKey was silently null and the runner returned84    // VAULT_LOCKED. The fix is to always derive rootKey from pw_root_* (which85    // are present on every vault since createVault).86    const v1 = await import("./vault");87    await v1.createVault("strong-pass-abc123");88    // unlock() is the real-world path that saves the MV3 session key — the89    // user's popup opens, types the password, and the session key is parked90    // in chrome.storage.session for the next SW wake.91    await v1.unlock("strong-pass-abc123");92    const rootBeforeDead = v1.getRootKeyHex();93    expect(rootBeforeDead).not.toBeNull();9495    // Simulate a SW kill: flush the fire-and-forget saveSessionKey promise,96    // then reset modules so a fresh vault.ts reads the persisted session key.97    await Promise.resolve();98    vi.resetModules();99    const v2 = await import("./vault");100    expect(await v2.restoreFromSession()).toBe(true);101    // The bug shape: rootKey was null after restore, so the runner short-circuited102    // to VAULT_LOCKED even though entries were present.103    expect(v2.getRootKeyHex()).not.toBeNull();104    expect(v2.getRootKeyHex()).toBe(rootBeforeDead);105  });106107  it("lock() clears the MV3 session key so a manual lock survives a SW restart", async () => {108    // Bug shape: lock() used to keep the session key in chrome.storage.session109    // so background sync could re-wake a restored vault. MV3 idle-kills the SW110    // after ~30s, so a popup reopened a minute after locking woke a cold SW,111    // restoreFromSession() found the surviving key, and the vault silently112    // re-unlocked without the password. Every lock path (manual lock icon,113    // popup close with lockOnClose=true, auto-lock alarm, idle) flows through114    // lock(), and all of them are deliberate locks — none should be undone by115    // the next SW restart. clearSessionKey() mirrors the fire-and-forget116    // saveSessionKey() pattern used by unlock().117    const v1 = await import("./vault");118    await v1.createVault("strong-pass-abc123");119    await v1.unlock("strong-pass-abc123");120    const sessionKeyBeforeLock = await chrome.storage.session.get("peach_session_dk");121    expect(sessionKeyBeforeLock.peach_session_dk).toBeDefined();122123    // The lock-on-close path: popup port disconnect, alarm tick, etc.124    v1.lock();125    expect(v1.isUnlocked()).toBe(false);126    // Flush the fire-and-forget clearSessionKey microtask, then confirm the127    // key was actually removed from chrome.storage.session.128    await Promise.resolve();129    const sessionKeyAfterLock = await chrome.storage.session.get("peach_session_dk");130    expect(sessionKeyAfterLock.peach_session_dk).toBeUndefined();131132    // The next SW wake must NOT restore the vault — restoreFromSession() finds133    // no session key and the vault stays locked until the password is typed.134    vi.resetModules();135    const v2 = await import("./vault");136    expect(await v2.restoreFromSession()).toBe(false);137    expect(v2.isUnlocked()).toBe(false);138    expect(v2.getRootKeyHex()).toBeNull();139  });140141  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());146147    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    });164165    vi.resetModules();166    const v2 = await import("./vault");167    const restoring = v2.restoreFromSession();168    await readStarted.promise;169170    await v2.lock();171    releaseRead.resolve();172173    await expect(restoring).resolves.toBe(false);174    expect(v2.isUnlocked()).toBe(false);175    expect(v2.getRootKeyHex()).toBeNull();176  });177178  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());183184    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    });199200    vi.resetModules();201    const v2 = await import("./vault");202    const restoring = v2.restoreFromSession();203    await settingsReadStarted.promise;204205    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;211212      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  });220221  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    });231232    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    });240241    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;247248    try {249      await Promise.resolve();250      expect(settled).toBe(false);251    } finally {252      releaseRemoval.resolve();253      await locking;254    }255256    expect(session.peach_session_dk).toBeUndefined();257    expect(session.peach_recovery_pending).toBeUndefined();258  });259260  it("disabled escrow clears the stored key and blocks save and restore", 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    const parked = session.peach_session_dk;266267    await v1.applyStorageHardening(false);268    expect(session.peach_session_dk).toBeUndefined();269270    // A later unlock must not re-park the key while escrow is off.271    await v1.unlock("strong-pass-abc123");272    await Promise.resolve();273    expect(session.peach_session_dk).toBeUndefined();274275    // A restarted worker with the same stored key restores only when the276    // hardening result allows escrow; a disabled escrow fails closed.277    session.peach_session_dk = parked;278    vi.resetModules();279    const v2 = await import("./vault");280    expect(await v2.restoreFromSession()).toBe(true);281282    vi.resetModules();283    const v3 = await import("./vault");284    await v3.applyStorageHardening(false);285    expect(session.peach_session_dk).toBeUndefined();286    expect(await v3.restoreFromSession()).toBe(false);287    expect(v3.isUnlocked()).toBe(false);288  });289290  it("deduplicates simultaneous restores and runs the unlock hook once", async () => {291    const v1 = await import("./vault");292    await v1.createVault("strong-pass-abc123");293    await v1.unlock("strong-pass-abc123");294    await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());295296    vi.resetModules();297    const v2 = await import("./vault");298    const afterUnlock = vi.fn(async () => undefined);299    v2.setLockedSyncHooks({300      refresh: vi.fn(async () => undefined),301      afterUnlock,302    });303    const sessionGet = chrome.storage.session.get as unknown as ReturnType<typeof vi.fn>;304    sessionGet.mockClear();305306    await expect(Promise.all([307      v2.restoreFromSession(),308      v2.restoreFromSession(),309    ])).resolves.toEqual([true, true]);310311    expect(sessionGet).toHaveBeenCalledTimes(1);312    expect(afterUnlock).toHaveBeenCalledTimes(1);313  });314});