pair-secrets.test.ts

11.1 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("concurrent pair-secret writes preserve both devices", async () => {82    const { setPairSecret, getPairSecret } = await import("./pair-secrets");8384    await Promise.all([85      setPairSecret("pixel-1", SECRET_A, ROOT_KEY),86      setPairSecret("pixel-2", SECRET_B, ROOT_KEY),87    ]);8889    for (const key of Object.keys(session)) delete session[key];90    const rootHex = Array.from(ROOT_KEY, (byte) => byte.toString(16).padStart(2, "0")).join("");91    expect(await getPairSecret("pixel-1", rootHex)).toBe(SECRET_A);92    expect(await getPairSecret("pixel-2", rootHex)).toBe(SECRET_B);93  });9495  it("upsertPairedDevice never persists a rendezvous secret into the public registry", async () => {96    const { listPairedDevices, upsertPairedDevice } = await import("./paired-devices");9798    await upsertPairedDevice(legacyRow("pixel-1", SECRET_A));99100    const rows = await listPairedDevices();101    expect(rows).toHaveLength(1);102    expect(rows[0]!.rendezvousSecretHex).toBeUndefined();103    expect(rawLocalContainsSecret(local, SECRET_A)).toBe(false);104  });105106  it("migration preserves every valid secret and scrubs plaintext only after verified persistence", async () => {107    const { listPairedDevices } = await import("./paired-devices");108    const { reconcilePairSecrets, getPairSecret } = await import("./pair-secrets");109110    local.peach_paired_devices_v3 = [legacyRow("pixel-1", SECRET_A), legacyRow("pixel-2", SECRET_B)];111112    const first = await reconcilePairSecrets(ROOT_KEY);113    expect(first.migrated).toBe(2);114    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);115    expect(await getPairSecret("pixel-2", null)).toBe(SECRET_B);116117    // Plaintext source is gone only now that the encrypted write verified.118    for (const row of await listPairedDevices()) {119      expect(row.rendezvousSecretHex).toBeUndefined();120    }121    expect(rawLocalContainsSecret(local, SECRET_A)).toBe(false);122    expect(rawLocalContainsSecret(local, SECRET_B)).toBe(false);123124    // Idempotent across failures between steps: a second run changes nothing.125    const second = await reconcilePairSecrets(ROOT_KEY);126    expect(second.migrated).toBe(0);127    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);128  });129130  it("a wrong wrapping key cannot read the map", async () => {131    const { setPairSecret, getPairSecret } = await import("./pair-secrets");132133    await setPairSecret("pixel-1", SECRET_A, ROOT_KEY);134    // Drop the trusted-session copy so only the crypto boundary remains.135    for (const k of Object.keys(session)) delete session[k];136    const wrongHex = Array.from(WRONG_KEY).map((b) => b.toString(16).padStart(2, "0")).join("");137    expect(await getPairSecret("pixel-1", wrongHex)).toBeNull();138  });139140  it("unlock populates the trusted-session cache; a wiped session requires unlock", async () => {141    const { setPairSecret, getPairSecret } = await import("./pair-secrets");142143    await setPairSecret("pixel-1", SECRET_A, ROOT_KEY);144    // Locked (no root key): the session-scoped operational copy serves sync.145    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);146147    // Full browser restart clears the session: sync stays dormant until unlock.148    for (const k of Object.keys(session)) delete session[k];149    expect(await getPairSecret("pixel-1", null)).toBeNull();150151    // ...but an unlocked caller still reads through the root key.152    const rootHex = Array.from(ROOT_KEY).map((b) => b.toString(16).padStart(2, "0")).join("");153    expect(await getPairSecret("pixel-1", rootHex)).toBe(SECRET_A);154  });155156  it("unpair prunes the secret; re-pair replaces it without orphan records", async () => {157    const { removePairedDeviceById, listPairedDevices } = await import("./paired-devices");158    const { reconcilePairSecrets, setPairSecret, getPairSecret } = await import("./pair-secrets");159160    local.peach_paired_devices_v3 = [publicRow("pixel-1"), publicRow("pixel-2")];161    await setPairSecret("pixel-1", SECRET_A, ROOT_KEY);162    await setPairSecret("pixel-2", SECRET_B, ROOT_KEY);163164    await removePairedDeviceById("pixel-2");165    expect((await listPairedDevices()).map((d) => d.deviceId)).toEqual(["pixel-1"]);166    await reconcilePairSecrets(ROOT_KEY);167    expect(await getPairSecret("pixel-2", null)).toBeNull();168    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);169170    await setPairSecret("pixel-2", SECRET_A, ROOT_KEY);171    local.peach_paired_devices_v3 = [...(local.peach_paired_devices_v3 as PairedDevice[]), publicRow("pixel-2")];172    await reconcilePairSecrets(ROOT_KEY);173    expect(await getPairSecret("pixel-2", null)).toBe(SECRET_A);174  });175176  it("registry rows without secrets stay route-capable; the secret resolves at dial time", () => {177    expect(isRouteCapableMeshPeer(publicRow("pixel-1"))).toBe(true);178  });179});180181describe("pair-secret vault lifecycle (PB-26-213)", () => {182  let local: StorageShape;183  let session: StorageShape;184185  beforeEach(() => {186    vi.resetModules();187    local = {};188    session = {};189    const area = (store: StorageShape) => ({190      get: vi.fn(async (key?: string | string[]) => {191        if (!key) return store;192        if (typeof key === "string") return { [key]: store[key] };193        return key.reduce<Record<string, unknown>>((r, k) => { r[k] = store[k]; return r; }, {});194      }),195      set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),196      remove: vi.fn(async (keys: string | string[]) => {197        for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k];198      }),199      clear: vi.fn(async () => {200        for (const k of Object.keys(store)) delete store[k];201      }),202    });203    (globalThis as { chrome?: typeof chrome }).chrome = {204      storage: { local: area(local), session: area(session) },205      alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },206      idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },207    } as unknown as typeof chrome;208    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));209  });210211  function hexToBytes(value: string): Uint8Array {212    const out = new Uint8Array(value.length / 2);213    for (let i = 0; i < value.length; i += 2) out[i / 2] = parseInt(value.slice(i, i + 2), 16);214    return out;215  }216217  it("password change retains access; rotation clears the map", async () => {218    const vault = await import("./vault");219    const { getPairSecret, setPairSecret } = await import("./pair-secrets");220    await vault.createVault("strong-pass-abc123");221    await vault.unlock("strong-pass-abc123");222223    const root = hexToBytes(vault.getRootKeyHex()!);224    try {225      await setPairSecret("pixel-1", SECRET_A, root);226    } finally {227      root.fill(0);228    }229230    expect(await vault.changePassword("strong-pass-abc123", "new-pass-xyz789")).toBe(true);231    expect(await getPairSecret("pixel-1", vault.getRootKeyHex())).toBe(SECRET_A);232233    expect((await vault.rotateSyncIdentity()).ok).toBe(true);234    expect(await getPairSecret("pixel-1", vault.getRootKeyHex())).toBeNull();235    expect(local.peach_pair_secrets_v1).toBeUndefined();236    expect(session.peach_pair_secrets_session).toBeUndefined();237  });238239  it("decoy unlock drops the session copy; real re-unlock restores service", async () => {240    const vault = await import("./vault");241    const { upsertPairedDevice } = await import("./paired-devices");242    const { getPairSecret, setPairSecret } = await import("./pair-secrets");243    await vault.createVault("strong-pass-abc123");244    await vault.unlock("strong-pass-abc123");245246    await upsertPairedDevice(publicRow("pixel-1"));247    const root = hexToBytes(vault.getRootKeyHex()!);248    try {249      await setPairSecret("pixel-1", SECRET_A, root);250    } finally {251      root.fill(0);252    }253    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);254255    await vault.setupDuress("decoy-password-67890");256    await vault.lock();257    expect(await vault.unlock("decoy-password-67890")).toBe(true);258    expect(await getPairSecret("pixel-1", null)).toBeNull();259    expect(session.peach_pair_secrets_session).toBeUndefined();260261    await vault.lock();262    expect(await vault.unlock("strong-pass-abc123")).toBe(true);263    expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);264  });265});