pair-secrets.test.ts

10.5 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";23import { isRouteCapableMeshPeer } from "../shared/mesh-peer-role";4import type { PairedDevice } from "../shared/sync-types";56// PB-26-213: the reusable rendezvous secret must never sit in plaintext in7// chrome.storage.local. It lives in an authenticated encrypted record under8// a vault-root-derived wrapping key, with only a trusted-session operational9// copy outside ciphertext.1011type StorageShape = Record<string, unknown>;1213const SECRET_A = "aa".repeat(32);14const SECRET_B = "bb".repeat(32);15const ROOT_KEY = new Uint8Array(32).fill(0x42);16const WRONG_KEY = new Uint8Array(32).fill(0x99);1718function publicRow(deviceId: string): PairedDevice {19  return {20    deviceId,21    deviceName: deviceId,22    deviceType: "android",23    longTermPublicKeyHex: "ab".repeat(32),24    signingPublicKeyHex: "cd".repeat(32),25    trustSource: "direct",26    relayUrl: "wss://relay.example.com",27    pairedAt: 1_700_000_000_000,28  };29}3031function legacyRow(deviceId: string, secret: string): PairedDevice {32  return { ...publicRow(deviceId), rendezvousSecretHex: secret };33}3435function rawLocalContainsSecret(local: StorageShape, secret: string): boolean {36  return JSON.stringify(local).includes(secret);37}3839describe("encrypted pair secrets (PB-26-213)", () => {40  let local: StorageShape;41  let session: StorageShape;4243  beforeEach(() => {44    vi.resetModules();45    local = {};46    session = {};47    const area = (store: StorageShape) => ({48      get: vi.fn(async (key?: string | string[]) => {49        if (!key) return store;50        if (typeof key === "string") return { [key]: store[key] };51        return key.reduce<Record<string, unknown>>((r, k) => { r[k] = store[k]; return r; }, {});52      }),53      set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),54      remove: vi.fn(async (keys: string | string[]) => {55        for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k];56      }),57      clear: vi.fn(async () => {58        for (const k of Object.keys(store)) delete store[k];59      }),60    });61    (globalThis as { chrome?: typeof chrome }).chrome = {62      storage: { local: area(local), session: area(session) },63      alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },64      idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },65    } as unknown as typeof chrome;66    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));67  });6869  it("fresh pairing writes no reusable secret to storage.local outside ciphertext", async () => {70    const { upsertPairedDevice } = await import("./paired-devices");71    const { setPairSecret, getPairSecret } = await import("./pair-secrets");7273    await upsertPairedDevice(legacyRow("pixel-1", SECRET_A));74    await setPairSecret("pixel-1", SECRET_A, ROOT_KEY);7576    expect(rawLocalContainsSecret(local, SECRET_A)).toBe(false);77    expect(local.peach_pair_secrets_v1).toBeDefined();78    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);79  });8081  it("upsertPairedDevice never persists a rendezvous secret into the public registry", async () => {82    const { listPairedDevices, upsertPairedDevice } = await import("./paired-devices");8384    await upsertPairedDevice(legacyRow("pixel-1", SECRET_A));8586    const rows = await listPairedDevices();87    expect(rows).toHaveLength(1);88    expect(rows[0]!.rendezvousSecretHex).toBeUndefined();89    expect(rawLocalContainsSecret(local, SECRET_A)).toBe(false);90  });9192  it("migration preserves every valid secret and scrubs plaintext only after verified persistence", async () => {93    const { listPairedDevices } = await import("./paired-devices");94    const { reconcilePairSecrets, getPairSecret } = await import("./pair-secrets");9596    local.peach_paired_devices_v3 = [legacyRow("pixel-1", SECRET_A), legacyRow("pixel-2", SECRET_B)];9798    const first = await reconcilePairSecrets(ROOT_KEY);99    expect(first.migrated).toBe(2);100    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);101    expect(await getPairSecret("pixel-2", null)).toBe(SECRET_B);102103    // Plaintext source is gone only now that the encrypted write verified.104    for (const row of await listPairedDevices()) {105      expect(row.rendezvousSecretHex).toBeUndefined();106    }107    expect(rawLocalContainsSecret(local, SECRET_A)).toBe(false);108    expect(rawLocalContainsSecret(local, SECRET_B)).toBe(false);109110    // Idempotent across failures between steps: a second run changes nothing.111    const second = await reconcilePairSecrets(ROOT_KEY);112    expect(second.migrated).toBe(0);113    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);114  });115116  it("a wrong wrapping key cannot read the map", async () => {117    const { setPairSecret, getPairSecret } = await import("./pair-secrets");118119    await setPairSecret("pixel-1", SECRET_A, ROOT_KEY);120    // Drop the trusted-session copy so only the crypto boundary remains.121    for (const k of Object.keys(session)) delete session[k];122    const wrongHex = Array.from(WRONG_KEY).map((b) => b.toString(16).padStart(2, "0")).join("");123    expect(await getPairSecret("pixel-1", wrongHex)).toBeNull();124  });125126  it("unlock populates the trusted-session cache; a wiped session requires unlock", async () => {127    const { setPairSecret, getPairSecret } = await import("./pair-secrets");128129    await setPairSecret("pixel-1", SECRET_A, ROOT_KEY);130    // Locked (no root key): the session-scoped operational copy serves sync.131    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);132133    // Full browser restart clears the session: sync stays dormant until unlock.134    for (const k of Object.keys(session)) delete session[k];135    expect(await getPairSecret("pixel-1", null)).toBeNull();136137    // ...but an unlocked caller still reads through the root key.138    const rootHex = Array.from(ROOT_KEY).map((b) => b.toString(16).padStart(2, "0")).join("");139    expect(await getPairSecret("pixel-1", rootHex)).toBe(SECRET_A);140  });141142  it("unpair prunes the secret; re-pair replaces it without orphan records", async () => {143    const { removePairedDeviceById, listPairedDevices } = await import("./paired-devices");144    const { reconcilePairSecrets, setPairSecret, getPairSecret } = await import("./pair-secrets");145146    local.peach_paired_devices_v3 = [publicRow("pixel-1"), publicRow("pixel-2")];147    await setPairSecret("pixel-1", SECRET_A, ROOT_KEY);148    await setPairSecret("pixel-2", SECRET_B, ROOT_KEY);149150    await removePairedDeviceById("pixel-2");151    expect((await listPairedDevices()).map((d) => d.deviceId)).toEqual(["pixel-1"]);152    await reconcilePairSecrets(ROOT_KEY);153    expect(await getPairSecret("pixel-2", null)).toBeNull();154    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);155156    await setPairSecret("pixel-2", SECRET_A, ROOT_KEY);157    local.peach_paired_devices_v3 = [...(local.peach_paired_devices_v3 as PairedDevice[]), publicRow("pixel-2")];158    await reconcilePairSecrets(ROOT_KEY);159    expect(await getPairSecret("pixel-2", null)).toBe(SECRET_A);160  });161162  it("registry rows without secrets stay route-capable; the secret resolves at dial time", () => {163    expect(isRouteCapableMeshPeer(publicRow("pixel-1"))).toBe(true);164  });165});166167describe("pair-secret vault lifecycle (PB-26-213)", () => {168  let local: StorageShape;169  let session: StorageShape;170171  beforeEach(() => {172    vi.resetModules();173    local = {};174    session = {};175    const area = (store: StorageShape) => ({176      get: vi.fn(async (key?: string | string[]) => {177        if (!key) return store;178        if (typeof key === "string") return { [key]: store[key] };179        return key.reduce<Record<string, unknown>>((r, k) => { r[k] = store[k]; return r; }, {});180      }),181      set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),182      remove: vi.fn(async (keys: string | string[]) => {183        for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k];184      }),185      clear: vi.fn(async () => {186        for (const k of Object.keys(store)) delete store[k];187      }),188    });189    (globalThis as { chrome?: typeof chrome }).chrome = {190      storage: { local: area(local), session: area(session) },191      alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },192      idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },193    } as unknown as typeof chrome;194    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));195  });196197  function hexToBytes(value: string): Uint8Array {198    const out = new Uint8Array(value.length / 2);199    for (let i = 0; i < value.length; i += 2) out[i / 2] = parseInt(value.slice(i, i + 2), 16);200    return out;201  }202203  it("password change retains access; rotation clears the map", async () => {204    const vault = await import("./vault");205    const { getPairSecret, setPairSecret } = await import("./pair-secrets");206    await vault.createVault("strong-pass-abc123");207    await vault.unlock("strong-pass-abc123");208209    const root = hexToBytes(vault.getRootKeyHex()!);210    try {211      await setPairSecret("pixel-1", SECRET_A, root);212    } finally {213      root.fill(0);214    }215216    expect(await vault.changePassword("strong-pass-abc123", "new-pass-xyz789")).toBe(true);217    expect(await getPairSecret("pixel-1", vault.getRootKeyHex())).toBe(SECRET_A);218219    expect((await vault.rotateSyncIdentity()).ok).toBe(true);220    expect(await getPairSecret("pixel-1", vault.getRootKeyHex())).toBeNull();221    expect(local.peach_pair_secrets_v1).toBeUndefined();222    expect(session.peach_pair_secrets_session).toBeUndefined();223  });224225  it("decoy unlock drops the session copy; real re-unlock restores service", async () => {226    const vault = await import("./vault");227    const { upsertPairedDevice } = await import("./paired-devices");228    const { getPairSecret, setPairSecret } = await import("./pair-secrets");229    await vault.createVault("strong-pass-abc123");230    await vault.unlock("strong-pass-abc123");231232    await upsertPairedDevice(publicRow("pixel-1"));233    const root = hexToBytes(vault.getRootKeyHex()!);234    try {235      await setPairSecret("pixel-1", SECRET_A, root);236    } finally {237      root.fill(0);238    }239    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);240241    await vault.setupDuress("decoy-password-67890");242    await vault.lock();243    expect(await vault.unlock("decoy-password-67890")).toBe(true);244    expect(await getPairSecret("pixel-1", null)).toBeNull();245    expect(session.peach_pair_secrets_session).toBeUndefined();246247    await vault.lock();248    expect(await vault.unlock("strong-pass-abc123")).toBe(true);249    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);250  });251});