vault.rotate-sync-identity.test.ts

8.9 KB · raw

1// T-016: rotateSyncIdentity — new root key, cleared peers, recovery pending2//3// Why this test exists: rotateSyncIdentity (MV-8) is the "nuclear option" for4// a lost/stolen device — it swaps in a new root key so the compromised device's5// retained key material becomes useless. It also:6//   - Clears all paired devices (they must re-pair)7//   - Sets peach_recovery_pending (new recovery words must be acknowledged)8//   - Rewraps the new root key under the SAME password-derived key (so the9//     vault stays unlockable with the current password)10//   - Does NOT re-encrypt existing entries (they are password-sealed, not11//     root-key-sealed)12//13// This function was added in MV-8 and has ZERO test coverage in the existing14// suite. A regression here would silently fail to rotate the root key or15// silently fail to clear paired devices — leaving the user's vault unprotected16// while the UI presents the rotation as successful.1718import { beforeEach, describe, expect, it, vi } from "vitest";1920type StorageShape = Record<string, unknown>;2122// ---------------------------------------------------------------------------23// Chrome storage mock (same pattern as messages.batch-sanitize.test.ts)24// ---------------------------------------------------------------------------2526function makeChromeStorageMock(): {27  storage: StorageShape;28  chrome: typeof globalThis.chrome;29} {30  const storage: StorageShape = {};31  const chrome = {32    storage: {33      local: {34        get: vi.fn(async (key?: string | string[]) => {35          if (!key) return { ...storage };36          if (typeof key === "string") return { [key]: storage[key] };37          return (Array.isArray(key) ? key : [key]).reduce<StorageShape>(38            (acc, k) => { acc[k] = storage[k]; return acc; },39            {},40          );41        }),42        set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),43        remove: vi.fn(async (keys: string | string[]) => {44          const ks = Array.isArray(keys) ? keys : [keys];45          for (const k of ks) delete storage[k];46        }),47        clear: vi.fn(async () => {48          for (const k of Object.keys(storage)) delete storage[k];49        }),50      },51      session: {52        get: vi.fn(async () => ({})),53        set: vi.fn(async () => {}),54        remove: vi.fn(async () => {}),55      },56    },57    alarms: {58      clear: vi.fn(),59      create: vi.fn(),60      onAlarm: { addListener: vi.fn() },61    },62    idle: {63      setDetectionInterval: vi.fn(),64      onStateChanged: { addListener: vi.fn() },65    },66  } as unknown as typeof globalThis.chrome;67  return { storage, chrome };68}6970// ---------------------------------------------------------------------------71// Tests72// ---------------------------------------------------------------------------7374describe("T-016: rotateSyncIdentity — MV-8 rotation invariants", () => {75  let storage: StorageShape;76  let vault: typeof import("./vault");77  const VAULT_PASSWORD = "rotate-test-pass-007";7879  beforeEach(async () => {80    vi.resetModules();81    const mock = makeChromeStorageMock();82    storage = mock.storage;83    (globalThis as Record<string, unknown>).chrome = mock.chrome;84    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));8586    vault = await import("./vault");87    await vault.createVault(VAULT_PASSWORD);88  });8990  // T-016a: rotateSyncIdentity returns { ok: true } while vault is unlocked.91  // Bug this catches: the function returns early with { ok: false, error: "vault_locked" }92  // if the vault is not in the "real" mode — testing confirms the vault is93  // actually unlocked when we call the function.94  it("T-016a: rotateSyncIdentity returns { ok: true } while vault is unlocked", async () => {95    const result = await vault.rotateSyncIdentity();96    expect(result.ok).toBe(true);97    expect(result.error).toBeUndefined();98  });99100  // T-016b: root key changes after rotation.101  // getRootKeyHex() exposes the in-memory root key. Before and after rotation102  // the hex strings must differ — confirming a genuinely new key was generated103  // (not a no-op that re-uses the same key material).104  //105  // Bug this catches: a rotation that calls generateAndWrapRootKey() but106  // forgets to swap rootKey in memory (step 4 in rotateSyncIdentity: "Swap107  // in memory only after disk committed") would leave getRootKeyHex() unchanged.108  it("T-016b: getRootKeyHex differs before and after rotation (new key generated)", async () => {109    const rootKeyBefore = vault.getRootKeyHex();110    expect(rootKeyBefore).not.toBeNull();111112    await vault.rotateSyncIdentity();113114    const rootKeyAfter = vault.getRootKeyHex();115    expect(rootKeyAfter).not.toBeNull();116    expect(rootKeyAfter).not.toBe(rootKeyBefore);117  });118119  // T-016c: paired devices are cleared after rotation.120  // After rotation, the old paired devices must no longer appear in storage —121  // their Noise static keys are derived from the old root key and cannot122  // be used to authenticate against the new sync identity.123  //124  // Bug this catches: forgetting to call clearPairedDevices() after rotation125  // would leave stale device entries; the sync layer would attempt to use them126  // and fail in a confusing way (wrong MAC on decrypted handshake).127  it("T-016c: paired devices cleared from storage after rotation", async () => {128    // Pre-populate a fake paired device entry129    const PAIRED_KEY = "peach_paired_devices_v3";130    storage[PAIRED_KEY] = [131      {132        deviceId: "test-device-1",133        publicKey: "deadbeef".repeat(8),134        label: "My Laptop",135        pairedAt: 0,136      },137    ];138139    await vault.rotateSyncIdentity();140141    // The paired devices key must be absent (or empty) in storage142    const devicesAfter = storage[PAIRED_KEY];143    const isEmpty =144      devicesAfter === undefined ||145      devicesAfter === null ||146      (Array.isArray(devicesAfter) && (devicesAfter as unknown[]).length === 0);147    expect(isEmpty).toBe(true);148  });149150  // T-016d: peach_recovery_pending is set to "1" after rotation.151  // Rotation generates new 12 recovery words (derived from the new root key).152  // The user MUST re-confirm these words before the old ones become the only153  // backup. The recovery_pending flag routes the popup back to the word display.154  //155  // Bug this catches: forgetting setRecoveryConfirmPending() after rotation156  // means the user is never prompted to re-confirm new recovery words — they157  // continue thinking their old words are valid when they are not.158  it("T-016d: peach_recovery_pending set to '1' after rotation", async () => {159    await vault.rotateSyncIdentity();160161    expect(storage["peach_recovery_pending"]).toBe("1");162  });163164  // T-016e: vault is still unlockable with the same password after rotation.165  // The new root key is re-wrapped under the password-derived key — the user's166  // passphrase still works. This is the most critical invariant: a rotation167  // that breaks the password unlock would lock the user out of their vault.168  //169  // Bug this catches: a rotation that fails to persist the new root key wrapped170  // under the current password (step 2 in rotateSyncIdentity: "Re-wrap the new171  // root key under the current password-derived key") would make the vault172  // inaccessible after the next browser restart.173  it("T-016e: vault still unlocks with the same password after rotation", async () => {174    await vault.rotateSyncIdentity();175176    // Lock the vault (clears in-memory derived key and root key)177    vault.lock();178    expect(vault.isUnlocked()).toBe(false);179180    // Unlock with the same password — must succeed181    const unlocked = await vault.unlock(VAULT_PASSWORD);182    expect(unlocked).toBe(true);183    expect(vault.isUnlocked()).toBe(true);184  });185186  // T-016f: entries are preserved after rotation.187  // Entries are encrypted under the password-derived key, NOT the root key.188  // Rotation changes the root key but leaves entry ciphertext untouched.189  // After rotation and re-lock, existing entries must still be readable.190  it("T-016f: entries encrypted before rotation are still accessible after", async () => {191    // Add an entry before rotation192    const added = await vault.addEntry({193      type: "login",194      title: "Pre-Rotation Entry",195      username: "alice",196      password: "s3cret",197      url: "https://example.com",198      tags: [],199    });200    expect(added.id).toBeTruthy();201202    await vault.rotateSyncIdentity();203204    // Lock and re-unlock205    vault.lock();206    await vault.unlock(VAULT_PASSWORD);207208    // Entry must still be present209    const entries = vault.getEntries();210    const found = entries.find((e) => e.id === added.id);211    expect(found).toBeDefined();212    expect(found?.title).toBe("Pre-Rotation Entry");213  });214215  // T-016g: rotateSyncIdentity returns { ok: false, error: "vault_locked" }216  // when called while the vault is locked. No partial state change must occur.217  it("T-016g: rotateSyncIdentity fails gracefully when vault is locked", async () => {218    vault.lock();219    const result = await vault.rotateSyncIdentity();220    expect(result.ok).toBe(false);221    expect(result.error).toBe("vault_locked");222  });223});