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("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 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 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 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 145 expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);146147 148 for (const k of Object.keys(session)) delete session[k];149 expect(await getPairSecret("pixel-1", null)).toBeNull();150151 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});