vault.rotate-sync-identity.test.ts

9.6 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 session-owned recovery-pending state (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 sessionStorage: StorageShape = {};32  const chrome = {33    storage: {34      local: {35        get: vi.fn(async (key?: string | string[]) => {36          if (!key) return { ...storage };37          if (typeof key === "string") return { [key]: storage[key] };38          return (Array.isArray(key) ? key : [key]).reduce<StorageShape>(39            (acc, k) => { acc[k] = storage[k]; return acc; },40            {},41          );42        }),43        set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),44        remove: vi.fn(async (keys: string | string[]) => {45          const ks = Array.isArray(keys) ? keys : [keys];46          for (const k of ks) delete storage[k];47        }),48        clear: vi.fn(async () => {49          for (const k of Object.keys(storage)) delete storage[k];50        }),51      },52      session: {53        get: vi.fn(async (key?: string | string[]) => {54          if (!key) return { ...sessionStorage };55          if (typeof key === "string") return { [key]: sessionStorage[key] };56          return key.reduce<StorageShape>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});57        }),58        set: vi.fn(async (next: StorageShape) => { Object.assign(sessionStorage, next); }),59        remove: vi.fn(async (keys: string | string[]) => {60          for (const key of (Array.isArray(keys) ? keys : [keys])) delete sessionStorage[key];61        }),62      },63    },64    alarms: {65      clear: vi.fn(),66      create: vi.fn(),67      onAlarm: { addListener: vi.fn() },68    },69    idle: {70      setDetectionInterval: vi.fn(),71      onStateChanged: { addListener: vi.fn() },72    },73  } as unknown as typeof globalThis.chrome;74  return { storage, chrome };75}7677// ---------------------------------------------------------------------------78// Tests79// ---------------------------------------------------------------------------8081describe("T-016: rotateSyncIdentity — MV-8 rotation invariants", () => {82  let storage: StorageShape;83  let vault: typeof import("./vault");84  const VAULT_PASSWORD = "rotate-test-pass-007";8586  beforeEach(async () => {87    vi.resetModules();88    const mock = makeChromeStorageMock();89    storage = mock.storage;90    (globalThis as Record<string, unknown>).chrome = mock.chrome;91    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));9293    vault = await import("./vault");94    await vault.createVault(VAULT_PASSWORD);95  });9697  // T-016a: rotateSyncIdentity returns { ok: true } while vault is unlocked.98  // Bug this catches: the function returns early with { ok: false, error: "vault_locked" }99  // if the vault is not in the "real" mode — testing confirms the vault is100  // actually unlocked when we call the function.101  it("T-016a: rotateSyncIdentity returns { ok: true } while vault is unlocked", async () => {102    const result = await vault.rotateSyncIdentity();103    expect(result.ok).toBe(true);104    expect(result.error).toBeUndefined();105  });106107  // T-016b: root key changes after rotation.108  // getRootKeyHex() exposes the in-memory root key. Before and after rotation109  // the hex strings must differ — confirming a genuinely new key was generated110  // (not a no-op that re-uses the same key material).111  //112  // Bug this catches: a rotation that calls generateAndWrapRootKey() but113  // forgets to swap rootKey in memory (step 4 in rotateSyncIdentity: "Swap114  // in memory only after disk committed") would leave getRootKeyHex() unchanged.115  it("T-016b: getRootKeyHex differs before and after rotation (new key generated)", async () => {116    const rootKeyBefore = vault.getRootKeyHex();117    expect(rootKeyBefore).not.toBeNull();118119    await vault.rotateSyncIdentity();120121    const rootKeyAfter = vault.getRootKeyHex();122    expect(rootKeyAfter).not.toBeNull();123    expect(rootKeyAfter).not.toBe(rootKeyBefore);124  });125126  // T-016c: paired devices are cleared after rotation.127  // After rotation, the old paired devices must no longer appear in storage —128  // their Noise static keys are derived from the old root key and cannot129  // be used to authenticate against the new sync identity.130  //131  // Bug this catches: forgetting to call clearPairedDevices() after rotation132  // would leave stale device entries; the sync layer would attempt to use them133  // and fail in a confusing way (wrong MAC on decrypted handshake).134  it("T-016c: paired devices cleared from storage after rotation", async () => {135    // Pre-populate a fake paired device entry136    const PAIRED_KEY = "peach_paired_devices_v3";137    storage[PAIRED_KEY] = [138      {139        deviceId: "test-device-1",140        publicKey: "deadbeef".repeat(8),141        label: "My Laptop",142        pairedAt: 0,143      },144    ];145146    await vault.rotateSyncIdentity();147148    // The paired devices key must be absent (or empty) in storage149    const devicesAfter = storage[PAIRED_KEY];150    const isEmpty =151      devicesAfter === undefined ||152      devicesAfter === null ||153      (Array.isArray(devicesAfter) && (devicesAfter as unknown[]).length === 0);154    expect(isEmpty).toBe(true);155  });156157  // T-016d: the session-owned recovery-pending state is set after rotation.158  // Rotation generates new 12 recovery words (derived from the new root key).159  // The user MUST re-confirm these words before the old ones become the only160  // backup. The recovery_pending flag routes the popup back to the word display.161  //162  // Bug this catches: forgetting setRecoveryConfirmPending() after rotation163  // means the user is never prompted to re-confirm new recovery words — they164  // continue thinking their old words are valid when they are not.165  it("T-016d: session recovery-pending state is set after rotation", async () => {166    await vault.rotateSyncIdentity();167168    const { isRecoveryConfirmPending } = await import("./recovery-pending");169    await expect(isRecoveryConfirmPending()).resolves.toBe(true);170    expect(storage["peach_recovery_pending"]).toBeUndefined();171  });172173  // T-016e: vault is still unlockable with the same password after rotation.174  // The new root key is re-wrapped under the password-derived key — the user's175  // passphrase still works. This is the most critical invariant: a rotation176  // that breaks the password unlock would lock the user out of their vault.177  //178  // Bug this catches: a rotation that fails to persist the new root key wrapped179  // under the current password (step 2 in rotateSyncIdentity: "Re-wrap the new180  // root key under the current password-derived key") would make the vault181  // inaccessible after the next browser restart.182  it("T-016e: vault still unlocks with the same password after rotation", async () => {183    await vault.rotateSyncIdentity();184185    // Lock the vault (clears in-memory derived key and root key)186    vault.lock();187    expect(vault.isUnlocked()).toBe(false);188189    // Unlock with the same password — must succeed190    const unlocked = await vault.unlock(VAULT_PASSWORD);191    expect(unlocked).toBe(true);192    expect(vault.isUnlocked()).toBe(true);193  });194195  // T-016f: entries are preserved after rotation.196  // Entries are encrypted under the password-derived key, NOT the root key.197  // Rotation changes the root key but leaves entry ciphertext untouched.198  // After rotation and re-lock, existing entries must still be readable.199  it("T-016f: entries encrypted before rotation are still accessible after", async () => {200    // Add an entry before rotation201    const added = await vault.addEntry({202      type: "login",203      title: "Pre-Rotation Entry",204      username: "alice",205      password: "s3cret",206      url: "https://example.com",207      tags: [],208    });209    expect(added.id).toBeTruthy();210211    await vault.rotateSyncIdentity();212213    // Lock and re-unlock214    vault.lock();215    await vault.unlock(VAULT_PASSWORD);216217    // Entry must still be present218    const entries = vault.getEntries();219    const found = entries.find((e) => e.id === added.id);220    expect(found).toBeDefined();221    expect(found?.title).toBe("Pre-Rotation Entry");222  });223224  // T-016g: rotateSyncIdentity returns { ok: false, error: "vault_locked" }225  // when called while the vault is locked. No partial state change must occur.226  it("T-016g: rotateSyncIdentity fails gracefully when vault is locked", async () => {227    vault.lock();228    const result = await vault.rotateSyncIdentity();229    expect(result.ok).toBe(false);230    expect(result.error).toBe("vault_locked");231  });232});