1import { beforeEach, describe, expect, it, vi } from "vitest";23import { isRouteCapableMeshPeer } from "../shared/mesh-peer-role";4import type { PairedDevice } from "../shared/sync-types";567891011type 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 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 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 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 131 expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);132133 134 for (const k of Object.keys(session)) delete session[k];135 expect(await getPairSecret("pixel-1", null)).toBeNull();136137 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});