import { beforeEach, describe, expect, it, vi } from "vitest"; import { isRouteCapableMeshPeer } from "../shared/mesh-peer-role"; import type { PairedDevice } from "../shared/sync-types"; // PB-26-213: the reusable rendezvous secret must never sit in plaintext in // chrome.storage.local. It lives in an authenticated encrypted record under // a vault-root-derived wrapping key, with only a trusted-session operational // copy outside ciphertext. type StorageShape = Record; const SECRET_A = "aa".repeat(32); const SECRET_B = "bb".repeat(32); const ROOT_KEY = new Uint8Array(32).fill(0x42); const WRONG_KEY = new Uint8Array(32).fill(0x99); function publicRow(deviceId: string): PairedDevice { return { deviceId, deviceName: deviceId, deviceType: "android", longTermPublicKeyHex: "ab".repeat(32), signingPublicKeyHex: "cd".repeat(32), trustSource: "direct", relayUrl: "wss://relay.example.com", pairedAt: 1_700_000_000_000, }; } function legacyRow(deviceId: string, secret: string): PairedDevice { return { ...publicRow(deviceId), rendezvousSecretHex: secret }; } function rawLocalContainsSecret(local: StorageShape, secret: string): boolean { return JSON.stringify(local).includes(secret); } describe("encrypted pair secrets (PB-26-213)", () => { let local: StorageShape; let session: StorageShape; beforeEach(() => { vi.resetModules(); local = {}; session = {}; const area = (store: StorageShape) => ({ get: vi.fn(async (key?: string | string[]) => { if (!key) return store; if (typeof key === "string") return { [key]: store[key] }; return key.reduce>((r, k) => { r[k] = store[k]; return r; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(store, next); }), remove: vi.fn(async (keys: string | string[]) => { for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k]; }), clear: vi.fn(async () => { for (const k of Object.keys(store)) delete store[k]; }), }); (globalThis as { chrome?: typeof chrome }).chrome = { storage: { local: area(local), session: area(session) }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } }, } as unknown as typeof chrome; vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); })); }); it("fresh pairing writes no reusable secret to storage.local outside ciphertext", async () => { const { upsertPairedDevice } = await import("./paired-devices"); const { setPairSecret, getPairSecret } = await import("./pair-secrets"); await upsertPairedDevice(legacyRow("pixel-1", SECRET_A)); await setPairSecret("pixel-1", SECRET_A, ROOT_KEY); expect(rawLocalContainsSecret(local, SECRET_A)).toBe(false); expect(local.peach_pair_secrets_v1).toBeDefined(); expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A); }); it("concurrent pair-secret writes preserve both devices", async () => { const { setPairSecret, getPairSecret } = await import("./pair-secrets"); await Promise.all([ setPairSecret("pixel-1", SECRET_A, ROOT_KEY), setPairSecret("pixel-2", SECRET_B, ROOT_KEY), ]); for (const key of Object.keys(session)) delete session[key]; const rootHex = Array.from(ROOT_KEY, (byte) => byte.toString(16).padStart(2, "0")).join(""); expect(await getPairSecret("pixel-1", rootHex)).toBe(SECRET_A); expect(await getPairSecret("pixel-2", rootHex)).toBe(SECRET_B); }); it("upsertPairedDevice never persists a rendezvous secret into the public registry", async () => { const { listPairedDevices, upsertPairedDevice } = await import("./paired-devices"); await upsertPairedDevice(legacyRow("pixel-1", SECRET_A)); const rows = await listPairedDevices(); expect(rows).toHaveLength(1); expect(rows[0]!.rendezvousSecretHex).toBeUndefined(); expect(rawLocalContainsSecret(local, SECRET_A)).toBe(false); }); it("migration preserves every valid secret and scrubs plaintext only after verified persistence", async () => { const { listPairedDevices } = await import("./paired-devices"); const { reconcilePairSecrets, getPairSecret } = await import("./pair-secrets"); local.peach_paired_devices_v3 = [legacyRow("pixel-1", SECRET_A), legacyRow("pixel-2", SECRET_B)]; const first = await reconcilePairSecrets(ROOT_KEY); expect(first.migrated).toBe(2); expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A); expect(await getPairSecret("pixel-2", null)).toBe(SECRET_B); // Plaintext source is gone only now that the encrypted write verified. for (const row of await listPairedDevices()) { expect(row.rendezvousSecretHex).toBeUndefined(); } expect(rawLocalContainsSecret(local, SECRET_A)).toBe(false); expect(rawLocalContainsSecret(local, SECRET_B)).toBe(false); // Idempotent across failures between steps: a second run changes nothing. const second = await reconcilePairSecrets(ROOT_KEY); expect(second.migrated).toBe(0); expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A); }); it("a wrong wrapping key cannot read the map", async () => { const { setPairSecret, getPairSecret } = await import("./pair-secrets"); await setPairSecret("pixel-1", SECRET_A, ROOT_KEY); // Drop the trusted-session copy so only the crypto boundary remains. for (const k of Object.keys(session)) delete session[k]; const wrongHex = Array.from(WRONG_KEY).map((b) => b.toString(16).padStart(2, "0")).join(""); expect(await getPairSecret("pixel-1", wrongHex)).toBeNull(); }); it("unlock populates the trusted-session cache; a wiped session requires unlock", async () => { const { setPairSecret, getPairSecret } = await import("./pair-secrets"); await setPairSecret("pixel-1", SECRET_A, ROOT_KEY); // Locked (no root key): the session-scoped operational copy serves sync. expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A); // Full browser restart clears the session: sync stays dormant until unlock. for (const k of Object.keys(session)) delete session[k]; expect(await getPairSecret("pixel-1", null)).toBeNull(); // ...but an unlocked caller still reads through the root key. const rootHex = Array.from(ROOT_KEY).map((b) => b.toString(16).padStart(2, "0")).join(""); expect(await getPairSecret("pixel-1", rootHex)).toBe(SECRET_A); }); it("unpair prunes the secret; re-pair replaces it without orphan records", async () => { const { removePairedDeviceById, listPairedDevices } = await import("./paired-devices"); const { reconcilePairSecrets, setPairSecret, getPairSecret } = await import("./pair-secrets"); local.peach_paired_devices_v3 = [publicRow("pixel-1"), publicRow("pixel-2")]; await setPairSecret("pixel-1", SECRET_A, ROOT_KEY); await setPairSecret("pixel-2", SECRET_B, ROOT_KEY); await removePairedDeviceById("pixel-2"); expect((await listPairedDevices()).map((d) => d.deviceId)).toEqual(["pixel-1"]); await reconcilePairSecrets(ROOT_KEY); expect(await getPairSecret("pixel-2", null)).toBeNull(); expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A); await setPairSecret("pixel-2", SECRET_A, ROOT_KEY); local.peach_paired_devices_v3 = [...(local.peach_paired_devices_v3 as PairedDevice[]), publicRow("pixel-2")]; await reconcilePairSecrets(ROOT_KEY); expect(await getPairSecret("pixel-2", null)).toBe(SECRET_A); }); it("registry rows without secrets stay route-capable; the secret resolves at dial time", () => { expect(isRouteCapableMeshPeer(publicRow("pixel-1"))).toBe(true); }); }); describe("pair-secret vault lifecycle (PB-26-213)", () => { let local: StorageShape; let session: StorageShape; beforeEach(() => { vi.resetModules(); local = {}; session = {}; const area = (store: StorageShape) => ({ get: vi.fn(async (key?: string | string[]) => { if (!key) return store; if (typeof key === "string") return { [key]: store[key] }; return key.reduce>((r, k) => { r[k] = store[k]; return r; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(store, next); }), remove: vi.fn(async (keys: string | string[]) => { for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k]; }), clear: vi.fn(async () => { for (const k of Object.keys(store)) delete store[k]; }), }); (globalThis as { chrome?: typeof chrome }).chrome = { storage: { local: area(local), session: area(session) }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } }, } as unknown as typeof chrome; vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); })); }); function hexToBytes(value: string): Uint8Array { const out = new Uint8Array(value.length / 2); for (let i = 0; i < value.length; i += 2) out[i / 2] = parseInt(value.slice(i, i + 2), 16); return out; } it("password change retains access; rotation clears the map", async () => { const vault = await import("./vault"); const { getPairSecret, setPairSecret } = await import("./pair-secrets"); await vault.createVault("strong-pass-abc123"); await vault.unlock("strong-pass-abc123"); const root = hexToBytes(vault.getRootKeyHex()!); try { await setPairSecret("pixel-1", SECRET_A, root); } finally { root.fill(0); } expect(await vault.changePassword("strong-pass-abc123", "new-pass-xyz789")).toBe(true); expect(await getPairSecret("pixel-1", vault.getRootKeyHex())).toBe(SECRET_A); expect((await vault.rotateSyncIdentity()).ok).toBe(true); expect(await getPairSecret("pixel-1", vault.getRootKeyHex())).toBeNull(); expect(local.peach_pair_secrets_v1).toBeUndefined(); expect(session.peach_pair_secrets_session).toBeUndefined(); }); it("decoy unlock drops the session copy; real re-unlock restores service", async () => { const vault = await import("./vault"); const { upsertPairedDevice } = await import("./paired-devices"); const { getPairSecret, setPairSecret } = await import("./pair-secrets"); await vault.createVault("strong-pass-abc123"); await vault.unlock("strong-pass-abc123"); await upsertPairedDevice(publicRow("pixel-1")); const root = hexToBytes(vault.getRootKeyHex()!); try { await setPairSecret("pixel-1", SECRET_A, root); } finally { root.fill(0); } expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A); await vault.setupDuress("decoy-password-67890"); await vault.lock(); expect(await vault.unlock("decoy-password-67890")).toBe(true); expect(await getPairSecret("pixel-1", null)).toBeNull(); expect(session.peach_pair_secrets_session).toBeUndefined(); await vault.lock(); expect(await vault.unlock("strong-pass-abc123")).toBe(true); expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A); }); });