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.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}1920describe("vault session escrow refresh (PCH-01-008)", () => {21  let local: StorageShape;22  let session: StorageShape;2324  beforeEach(() => {25    vi.resetModules();26    local = {};27    session = {};28    const area = (store: StorageShape) => ({29      get: vi.fn(async (key?: string | string[]) => {30        if (!key) return store;31        if (typeof key === "string") return { [key]: store[key] };32        return key.reduce<Record<string, unknown>>((r, k) => { r[k] = store[k]; return r; }, {});33      }),34      set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),35      remove: vi.fn(async (keys: string | string[]) => {36        for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k];37      }),38    });39    (globalThis as { chrome?: typeof chrome }).chrome = {40      storage: { local: area(local), session: area(session) },41      alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },42      idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },43    } as unknown as typeof chrome;44    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));45  });4647  it("change_password_updates_session_escrow", async () => {48    const v1 = await import("./vault");49    await v1.createVault("old-pass-abc123");50    await v1.addEntry({ type: "login", title: "Acct", password: "pw" });51    expect(await v1.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);5253    // Flush the fire-and-forget saveSessionKey microtask, then simulate a SW54    // restart: a fresh module has derivedKey=null but session/local persist.55    await Promise.resolve();56    vi.resetModules();57    const v2 = await import("./vault");58    expect(await v2.restoreFromSession()).toBe(true);59    expect(v2.getEntries().some((e) => e.title === "Acct")).toBe(true);60  });6162  it("change_password_with_new_key_updates_session_escrow", async () => {63    const v1 = await import("./vault");64    await v1.createVault("orig-pass-abc123");65    await v1.addEntry({ type: "login", title: "Recovered", password: "pw" });6667    const newSalt = crypto.getRandomValues(new Uint8Array(16));68    const newKey = await deriveKey("reset-pass-xyz789", newSalt);69    await v1.changePasswordWithNewKey(newSalt, newKey);7071    await Promise.resolve();72    vi.resetModules();73    const v2 = await import("./vault");74    expect(await v2.restoreFromSession()).toBe(true);75    expect(v2.getEntries().some((e) => e.title === "Recovered")).toBe(true);76  });7778  it("restoreFromSession rehydrates rootKey so background sync survives a SW restart", async () => {79    // Background sync bug: entry.ts calls restoreFromSession() on every SW wake-up,80    // then triggerSyncIfReachable. The runner needs getRootKeyHex() to be non-null81    // — sync identity derives from rootKey, not derivedKey. The previous82    // restoreFromSession only set rootKey when the slot had recovery_salt +83    // recovery_wrapped_root_key; for any vault that hadn't been through84    // the recovery bootstrap, rootKey was silently null and the runner returned85    // VAULT_LOCKED. The fix is to always derive rootKey from pw_root_* (which86    // are present on every vault since createVault).87    const v1 = await import("./vault");88    await v1.createVault("strong-pass-abc123");89    // unlock() is the real-world path that saves the MV3 session key — the90    // user's popup opens, types the password, and the session key is parked91    // in chrome.storage.session for the next SW wake.92    await v1.unlock("strong-pass-abc123");93    const rootBeforeDead = v1.getRootKeyHex();94    expect(rootBeforeDead).not.toBeNull();9596    // Simulate a SW kill: flush the fire-and-forget saveSessionKey promise,97    // then reset modules so a fresh vault.ts reads the persisted session key.98    await Promise.resolve();99    vi.resetModules();100    const v2 = await import("./vault");101    expect(await v2.restoreFromSession()).toBe(true);102    // The bug shape: rootKey was null after restore, so the runner short-circuited103    // to VAULT_LOCKED even though entries were present.104    expect(v2.getRootKeyHex()).not.toBeNull();105    expect(v2.getRootKeyHex()).toBe(rootBeforeDead);106  });107108  it("lock() clears the MV3 session key so a manual lock survives a SW restart", async () => {109    // Bug shape: lock() used to keep the session key in chrome.storage.session110    // so background sync could re-wake a restored vault. MV3 idle-kills the SW111    // after ~30s, so a popup reopened a minute after locking woke a cold SW,112    // restoreFromSession() found the surviving key, and the vault silently113    // re-unlocked without the password. Every lock path (manual lock icon,114    // popup close with lockOnClose=true, auto-lock alarm, idle) flows through115    // lock(), and all of them are deliberate locks — none should be undone by116    // the next SW restart. clearSessionKey() mirrors the fire-and-forget117    // saveSessionKey() pattern used by unlock().118    const v1 = await import("./vault");119    await v1.createVault("strong-pass-abc123");120    await v1.unlock("strong-pass-abc123");121    const sessionKeyBeforeLock = await chrome.storage.session.get("peach_session_dk");122    expect(sessionKeyBeforeLock.peach_session_dk).toBeDefined();123124    // The lock-on-close path: popup port disconnect, alarm tick, etc.125    v1.lock();126    expect(v1.isUnlocked()).toBe(false);127    // Flush the fire-and-forget clearSessionKey microtask, then confirm the128    // key was actually removed from chrome.storage.session.129    await Promise.resolve();130    const sessionKeyAfterLock = await chrome.storage.session.get("peach_session_dk");131    expect(sessionKeyAfterLock.peach_session_dk).toBeUndefined();132133    // The next SW wake must NOT restore the vault — restoreFromSession() finds134    // no session key and the vault stays locked until the password is typed.135    vi.resetModules();136    const v2 = await import("./vault");137    expect(await v2.restoreFromSession()).toBe(false);138    expect(v2.isUnlocked()).toBe(false);139    expect(v2.getRootKeyHex()).toBeNull();140  });141142  it("a lock invalidates a restore paused while reading the session key", async () => {143    const v1 = await import("./vault");144    await v1.createVault("strong-pass-abc123");145    await v1.unlock("strong-pass-abc123");146    await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());147148    const staleSessionKey = session.peach_session_dk;149    const readStarted = deferred();150    const releaseRead = deferred();151    const sessionGet = chrome.storage.session.get as unknown as ReturnType<typeof vi.fn>;152    sessionGet.mockImplementation(async (key?: string | string[]) => {153      if (key === "peach_session_dk") {154        readStarted.resolve();155        await releaseRead.promise;156        return { peach_session_dk: staleSessionKey };157      }158      if (!key) return session;159      const keys = Array.isArray(key) ? key : [key];160      return keys.reduce<StorageShape>((result, entry) => {161        result[entry] = session[entry];162        return result;163      }, {});164    });165166    vi.resetModules();167    const v2 = await import("./vault");168    const restoring = v2.restoreFromSession();169    await readStarted.promise;170171    await v2.lock();172    releaseRead.resolve();173174    await expect(restoring).resolves.toBe(false);175    expect(v2.isUnlocked()).toBe(false);176    expect(v2.getRootKeyHex()).toBeNull();177  });178179  it("keeps decrypted restore state private until the final epoch-checked commit", async () => {180    const v1 = await import("./vault");181    await v1.createVault("strong-pass-abc123");182    await v1.unlock("strong-pass-abc123");183    await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());184185    const settingsReadStarted = deferred();186    const releaseSettingsRead = deferred();187    const localGet = chrome.storage.local.get as unknown as ReturnType<typeof vi.fn>;188    localGet.mockImplementation(async (key?: string | string[]) => {189      if (key === "peach_settings") {190        settingsReadStarted.resolve();191        await releaseSettingsRead.promise;192      }193      if (!key) return local;194      const keys = Array.isArray(key) ? key : [key];195      return keys.reduce<StorageShape>((result, entry) => {196        result[entry] = local[entry];197        return result;198      }, {});199    });200201    vi.resetModules();202    const v2 = await import("./vault");203    const restoring = v2.restoreFromSession();204    await settingsReadStarted.promise;205206    try {207      expect(v2.isUnlocked()).toBe(false);208      const locking = Promise.resolve(v2.lock());209      expect(v2.isUnlocked()).toBe(false);210      releaseSettingsRead.resolve();211      await locking;212213      await expect(restoring).resolves.toBe(false);214      expect(v2.isUnlocked()).toBe(false);215      expect(v2.getRootKeyHex()).toBeNull();216    } finally {217      releaseSettingsRead.resolve();218      await Promise.allSettled([restoring]);219    }220  });221222  it("does not resolve lock until session escrow and recovery confirmation are absent", async () => {223    const vault = await import("./vault");224    const pending = await import("./recovery-pending");225    await vault.createVault("strong-pass-abc123");226    await vault.unlock("strong-pass-abc123");227    await pending.setRecoveryConfirmPending();228    await vi.waitFor(() => {229      expect(session.peach_session_dk).toBeDefined();230      expect(session.peach_recovery_pending).toBeDefined();231    });232233    const removalStarted = deferred();234    const releaseRemoval = deferred();235    const sessionRemove = chrome.storage.session.remove as unknown as ReturnType<typeof vi.fn>;236    sessionRemove.mockImplementation(async (keys: string | string[]) => {237      removalStarted.resolve();238      await releaseRemoval.promise;239      for (const key of Array.isArray(keys) ? keys : [keys]) delete session[key];240    });241242    let settled = false;243    const locking = Promise.resolve(vault.lock()).then(() => {244      settled = true;245    });246    expect(vault.isUnlocked()).toBe(false);247    await removalStarted.promise;248249    try {250      await Promise.resolve();251      expect(settled).toBe(false);252    } finally {253      releaseRemoval.resolve();254      await locking;255    }256257    expect(session.peach_session_dk).toBeUndefined();258    expect(session.peach_recovery_pending).toBeUndefined();259  });260261  it("disabled escrow clears the stored key and blocks save and restore", async () => {262    const v1 = await import("./vault");263    await v1.createVault("strong-pass-abc123");264    await v1.unlock("strong-pass-abc123");265    await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());266    const parked = session.peach_session_dk;267268    await v1.applyStorageHardening(false);269    expect(session.peach_session_dk).toBeUndefined();270271    // A later unlock must not re-park the key while escrow is off.272    await v1.unlock("strong-pass-abc123");273    await Promise.resolve();274    expect(session.peach_session_dk).toBeUndefined();275276    // A restarted worker with the same stored key restores only when the277    // hardening result allows escrow; a disabled escrow fails closed.278    session.peach_session_dk = parked;279    vi.resetModules();280    const v2 = await import("./vault");281    expect(await v2.restoreFromSession()).toBe(true);282283    vi.resetModules();284    const v3 = await import("./vault");285    await v3.applyStorageHardening(false);286    expect(session.peach_session_dk).toBeUndefined();287    expect(await v3.restoreFromSession()).toBe(false);288    expect(v3.isUnlocked()).toBe(false);289  });290291  it("deduplicates simultaneous restores and runs the unlock hook once", async () => {292    const v1 = await import("./vault");293    await v1.createVault("strong-pass-abc123");294    await v1.unlock("strong-pass-abc123");295    await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());296297    vi.resetModules();298    const v2 = await import("./vault");299    const afterUnlock = vi.fn(async () => undefined);300    v2.setLockedSyncHooks({301      refresh: vi.fn(async () => undefined),302      afterUnlock,303    });304    const sessionGet = chrome.storage.session.get as unknown as ReturnType<typeof vi.fn>;305    sessionGet.mockClear();306307    await expect(Promise.all([308      v2.restoreFromSession(),309      v2.restoreFromSession(),310    ])).resolves.toEqual([true, true]);311312    expect(sessionGet).toHaveBeenCalledTimes(1);313    expect(afterUnlock).toHaveBeenCalledTimes(1);314  });315});