vault.session-escrow.test.ts

17.1 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.8910type StorageShape = Record<string, unknown>;1112function deferred(): { promise: Promise<void>; resolve: () => void } {13  let resolve!: () => void;14  const promise = new Promise<void>((done) => {15    resolve = done;16  });17  return { promise, resolve };18}1920function pauseSessionKeyWrite(session: StorageShape): {21  started: Promise<void>;22  release: () => void;23} {24  const started = deferred();25  const release = deferred();26  const sessionSet = chrome.storage.session.set as unknown as ReturnType<typeof vi.fn>;27  sessionSet.mockImplementation(async (next: Record<string, unknown>) => {28    if ("peach_session_dk" in next) {29      started.resolve();30      await release.promise;31    }32    Object.assign(session, next);33  });34  return { started: started.promise, release: release.resolve };35}3637describe("vault session escrow refresh (PCH-01-008)", () => {38  let local: StorageShape;39  let session: StorageShape;4041  beforeEach(() => {42    vi.resetModules();43    local = {};44    session = {};45    const area = (store: StorageShape) => ({46      get: vi.fn(async (key?: string | string[]) => {47        if (!key) return store;48        if (typeof key === "string") return { [key]: store[key] };49        return key.reduce<Record<string, unknown>>((r, k) => { r[k] = store[k]; return r; }, {});50      }),51      set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),52      remove: vi.fn(async (keys: string | string[]) => {53        for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k];54      }),55    });56    (globalThis as { chrome?: typeof chrome }).chrome = {57      storage: { local: area(local), session: area(session) },58      alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },59      idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },60    } as unknown as typeof chrome;61    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));62  });6364  it("change_password_updates_session_escrow", async () => {65    const v1 = await import("./vault");66    await v1.createVault("old-pass-abc123");67    await v1.addEntry({ type: "login", title: "Acct", password: "pw" });68    expect(await v1.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);6970    // Simulate a SW restart after the awaited escrow refresh: a fresh module71    // has derivedKey=null but session/local persist.72    vi.resetModules();73    const v2 = await import("./vault");74    expect(await v2.restoreFromSession()).toBe(true);75    expect(v2.getEntries().some((e) => e.title === "Acct")).toBe(true);76  });7778  it("change_password_with_new_key_updates_session_escrow", async () => {79    const v1 = await import("./vault");80    await v1.createVault("orig-pass-abc123");81    await v1.addEntry({ type: "login", title: "Recovered", password: "pw" });8283    const newSalt = crypto.getRandomValues(new Uint8Array(16));84    const newKey = await deriveKey("reset-pass-xyz789", newSalt);85    await v1.changePasswordWithNewKey(newSalt, newKey);8687    vi.resetModules();88    const v2 = await import("./vault");89    expect(await v2.restoreFromSession()).toBe(true);90    expect(v2.getEntries().some((e) => e.title === "Recovered")).toBe(true);91  });9293  it("restoreFromSession rehydrates rootKey so background sync survives a SW restart", async () => {94    // Background sync bug: entry.ts calls restoreFromSession() on every SW wake-up,95    // then triggerSyncIfReachable. The runner needs getRootKeyHex() to be non-null96    // — sync identity derives from rootKey, not derivedKey. The previous97    // restoreFromSession only set rootKey when the slot had recovery_salt +98    // recovery_wrapped_root_key; for any vault that hadn't been through99    // the recovery bootstrap, rootKey was silently null and the runner returned100    // VAULT_LOCKED. The fix is to always derive rootKey from pw_root_* (which101    // are present on every vault since createVault).102    const v1 = await import("./vault");103    await v1.createVault("strong-pass-abc123");104    // unlock() is the real-world path that saves the MV3 session key — the105    // user's popup opens, types the password, and the session key is parked106    // in chrome.storage.session for the next SW wake.107    await v1.unlock("strong-pass-abc123");108    const rootBeforeDead = v1.getRootKeyHex();109    expect(rootBeforeDead).not.toBeNull();110111    // Simulate a SW kill: flush the fire-and-forget saveSessionKey promise,112    // then reset modules so a fresh vault.ts reads the persisted session key.113    await Promise.resolve();114    vi.resetModules();115    const v2 = await import("./vault");116    expect(await v2.restoreFromSession()).toBe(true);117    // The bug shape: rootKey was null after restore, so the runner short-circuited118    // to VAULT_LOCKED even though entries were present.119    expect(v2.getRootKeyHex()).not.toBeNull();120    expect(v2.getRootKeyHex()).toBe(rootBeforeDead);121  });122123  it("lock() clears the MV3 session key so a manual lock survives a SW restart", async () => {124    // Bug shape: lock() used to keep the session key in chrome.storage.session125    // so background sync could re-wake a restored vault. MV3 idle-kills the SW126    // after ~30s, so a popup reopened a minute after locking woke a cold SW,127    // restoreFromSession() found the surviving key, and the vault silently128    // re-unlocked without the password. Every lock path (manual lock icon,129    // popup close with lockOnClose=true, auto-lock alarm, idle) flows through130    // lock(), and all of them are deliberate locks — none should be undone by131    // the next SW restart. clearSessionKey() mirrors the fire-and-forget132    // saveSessionKey() pattern used by unlock().133    const v1 = await import("./vault");134    await v1.createVault("strong-pass-abc123");135    await v1.unlock("strong-pass-abc123");136    const sessionKeyBeforeLock = await chrome.storage.session.get("peach_session_dk");137    expect(sessionKeyBeforeLock.peach_session_dk).toBeDefined();138139    // The lock-on-close path: popup port disconnect, alarm tick, etc.140    v1.lock();141    expect(v1.isUnlocked()).toBe(false);142    // Flush the fire-and-forget clearSessionKey microtask, then confirm the143    // key was actually removed from chrome.storage.session.144    await Promise.resolve();145    const sessionKeyAfterLock = await chrome.storage.session.get("peach_session_dk");146    expect(sessionKeyAfterLock.peach_session_dk).toBeUndefined();147148    // The next SW wake must NOT restore the vault — restoreFromSession() finds149    // no session key and the vault stays locked until the password is typed.150    vi.resetModules();151    const v2 = await import("./vault");152    expect(await v2.restoreFromSession()).toBe(false);153    expect(v2.isUnlocked()).toBe(false);154    expect(v2.getRootKeyHex()).toBeNull();155  });156157  it("a lock invalidates a real unlock paused while writing session escrow", async () => {158    const vault = await import("./vault");159    await vault.createVault("strong-pass-abc123");160    await vault.lock();161162    const write = pauseSessionKeyWrite(session);163    const unlocking = vault.unlock("strong-pass-abc123");164    await write.started;165166    let lockSettled = false;167    const locking = vault.lock().then(() => {168      lockSettled = true;169    });170    await new Promise((resolve) => setTimeout(resolve, 0));171    const lockSettledBeforeWrite = lockSettled;172    write.release();173174    expect(lockSettledBeforeWrite).toBe(false);175    await expect(unlocking).resolves.toBe(false);176    await locking;177    expect(vault.isUnlocked()).toBe(false);178    expect(session.peach_session_dk).toBeUndefined();179180    vi.resetModules();181    const restarted = await import("./vault");182    expect(await restarted.restoreFromSession()).toBe(false);183  });184185  it("a lock invalidates a duress unlock paused while writing session escrow", async () => {186    const vault = await import("./vault");187    await vault.createVault("real-pass-abc123");188    await vault.setupDuress("duress-pass-xyz789");189    await vault.lock();190191    const write = pauseSessionKeyWrite(session);192    const unlocking = vault.unlock("duress-pass-xyz789");193    await write.started;194195    const locking = vault.lock();196    await Promise.resolve();197    write.release();198199    await expect(unlocking).resolves.toBe(false);200    await locking;201    expect(vault.isUnlocked()).toBe(false);202    expect(session.peach_session_dk).toBeUndefined();203  });204205  it("a lock invalidates a key unlock paused while writing session escrow", async () => {206    const vault = await import("./vault");207    await vault.createVault("strong-pass-abc123");208    const keyHex = vault.getDerivedKeyHex();209    await vault.lock();210211    const write = pauseSessionKeyWrite(session);212    const unlocking = vault.unlockWithKey(keyHex);213    await write.started;214215    const locking = vault.lock();216    await Promise.resolve();217    write.release();218219    await expect(unlocking).resolves.toEqual({ ok: false, error: "invalid_key" });220    await locking;221    expect(vault.isUnlocked()).toBe(false);222    expect(session.peach_session_dk).toBeUndefined();223  });224225  it("a lock invalidates a password change paused while writing session escrow", async () => {226    const vault = await import("./vault");227    await vault.createVault("old-pass-abc123");228229    const write = pauseSessionKeyWrite(session);230    const changing = vault.changePassword("old-pass-abc123", "new-pass-xyz789");231    await write.started;232233    const locking = vault.lock();234    await Promise.resolve();235    write.release();236237    await expect(changing).resolves.toBe(false);238    await locking;239    expect(vault.isUnlocked()).toBe(false);240    expect(session.peach_session_dk).toBeUndefined();241242    vi.resetModules();243    const restarted = await import("./vault");244    expect(await restarted.restoreFromSession()).toBe(false);245  });246247  it("a lock clears a recovery re-key paused while writing session escrow", async () => {248    const vault = await import("./vault");249    await vault.createVault("old-pass-abc123");250    const newSalt = crypto.getRandomValues(new Uint8Array(16));251    const newKey = await deriveKey("new-pass-xyz789", newSalt);252253    const write = pauseSessionKeyWrite(session);254    const changing = vault.changePasswordWithNewKey(newSalt, newKey);255    await write.started;256257    let lockSettled = false;258    const locking = vault.lock().then(() => {259      lockSettled = true;260    });261    await new Promise((resolve) => setTimeout(resolve, 0));262    const lockSettledBeforeWrite = lockSettled;263    write.release();264265    expect(lockSettledBeforeWrite).toBe(false);266    await expect(changing).rejects.toThrow("vault_locked");267    await locking;268    expect(vault.isUnlocked()).toBe(false);269    expect(session.peach_session_dk).toBeUndefined();270271    vi.resetModules();272    const restarted = await import("./vault");273    expect(await restarted.restoreFromSession()).toBe(false);274  });275276  it("a lock invalidates a restore paused while reading the session key", async () => {277    const v1 = await import("./vault");278    await v1.createVault("strong-pass-abc123");279    await v1.unlock("strong-pass-abc123");280    await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());281282    const staleSessionKey = session.peach_session_dk;283    const readStarted = deferred();284    const releaseRead = deferred();285    const sessionGet = chrome.storage.session.get as unknown as ReturnType<typeof vi.fn>;286    sessionGet.mockImplementation(async (key?: string | string[]) => {287      if (key === "peach_session_dk") {288        readStarted.resolve();289        await releaseRead.promise;290        return { peach_session_dk: staleSessionKey };291      }292      if (!key) return session;293      const keys = Array.isArray(key) ? key : [key];294      return keys.reduce<StorageShape>((result, entry) => {295        result[entry] = session[entry];296        return result;297      }, {});298    });299300    vi.resetModules();301    const v2 = await import("./vault");302    const restoring = v2.restoreFromSession();303    await readStarted.promise;304305    await v2.lock();306    releaseRead.resolve();307308    await expect(restoring).resolves.toBe(false);309    expect(v2.isUnlocked()).toBe(false);310    expect(v2.getRootKeyHex()).toBeNull();311  });312313  it("keeps decrypted restore state private until the final epoch-checked commit", async () => {314    const v1 = await import("./vault");315    await v1.createVault("strong-pass-abc123");316    await v1.unlock("strong-pass-abc123");317    await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());318319    const settingsReadStarted = deferred();320    const releaseSettingsRead = deferred();321    const localGet = chrome.storage.local.get as unknown as ReturnType<typeof vi.fn>;322    localGet.mockImplementation(async (key?: string | string[]) => {323      if (key === "peach_settings") {324        settingsReadStarted.resolve();325        await releaseSettingsRead.promise;326      }327      if (!key) return local;328      const keys = Array.isArray(key) ? key : [key];329      return keys.reduce<StorageShape>((result, entry) => {330        result[entry] = local[entry];331        return result;332      }, {});333    });334335    vi.resetModules();336    const v2 = await import("./vault");337    const restoring = v2.restoreFromSession();338    await settingsReadStarted.promise;339340    try {341      expect(v2.isUnlocked()).toBe(false);342      const locking = Promise.resolve(v2.lock());343      expect(v2.isUnlocked()).toBe(false);344      releaseSettingsRead.resolve();345      await locking;346347      await expect(restoring).resolves.toBe(false);348      expect(v2.isUnlocked()).toBe(false);349      expect(v2.getRootKeyHex()).toBeNull();350    } finally {351      releaseSettingsRead.resolve();352      await Promise.allSettled([restoring]);353    }354  });355356  it("does not resolve lock until session escrow is absent", async () => {357    const vault = await import("./vault");358    const pending = await import("./recovery-pending");359    await vault.createVault("strong-pass-abc123");360    await vault.unlock("strong-pass-abc123");361    await pending.setRecoveryConfirmPending();362    await vi.waitFor(() => {363      expect(session.peach_session_dk).toBeDefined();364      expect(session.peach_recovery_pending).toBeDefined();365    });366367    const removalStarted = deferred();368    const releaseRemoval = deferred();369    const sessionRemove = chrome.storage.session.remove as unknown as ReturnType<typeof vi.fn>;370    sessionRemove.mockImplementation(async (keys: string | string[]) => {371      removalStarted.resolve();372      await releaseRemoval.promise;373      for (const key of Array.isArray(keys) ? keys : [keys]) delete session[key];374    });375376    let settled = false;377    const locking = Promise.resolve(vault.lock()).then(() => {378      settled = true;379    });380    expect(vault.isUnlocked()).toBe(false);381    await removalStarted.promise;382383    try {384      await Promise.resolve();385      expect(settled).toBe(false);386    } finally {387      releaseRemoval.resolve();388      await locking;389    }390391    expect(session.peach_session_dk).toBeUndefined();392    expect(session.peach_recovery_pending).toBeDefined();393  });394395  it("disabled escrow clears the stored key and blocks save and restore", async () => {396    const v1 = await import("./vault");397    await v1.createVault("strong-pass-abc123");398    await v1.unlock("strong-pass-abc123");399    await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());400    const parked = session.peach_session_dk;401402    await v1.applyStorageHardening(false);403    expect(session.peach_session_dk).toBeUndefined();404405    // A later unlock must not re-park the key while escrow is off.406    await v1.unlock("strong-pass-abc123");407    await Promise.resolve();408    expect(session.peach_session_dk).toBeUndefined();409410    // A restarted worker with the same stored key restores only when the411    // hardening result allows escrow; a disabled escrow fails closed.412    session.peach_session_dk = parked;413    vi.resetModules();414    const v2 = await import("./vault");415    expect(await v2.restoreFromSession()).toBe(true);416417    vi.resetModules();418    const v3 = await import("./vault");419    await v3.applyStorageHardening(false);420    expect(session.peach_session_dk).toBeUndefined();421    expect(await v3.restoreFromSession()).toBe(false);422    expect(v3.isUnlocked()).toBe(false);423  });424425  it("deduplicates simultaneous restores and runs the unlock hook once", async () => {426    const v1 = await import("./vault");427    await v1.createVault("strong-pass-abc123");428    await v1.unlock("strong-pass-abc123");429    await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());430431    vi.resetModules();432    const v2 = await import("./vault");433    const afterUnlock = vi.fn(async () => undefined);434    v2.setLockedSyncHooks({435      refresh: vi.fn(async () => undefined),436      afterUnlock,437    });438    const sessionGet = chrome.storage.session.get as unknown as ReturnType<typeof vi.fn>;439    sessionGet.mockClear();440441    await expect(Promise.all([442      v2.restoreFromSession(),443      v2.restoreFromSession(),444    ])).resolves.toEqual([true, true]);445446    expect(sessionGet).toHaveBeenCalledTimes(1);447    expect(afterUnlock).toHaveBeenCalledTimes(1);448  });449});