// Tests for Change 3 — desktop helper code pairing. // // DRAFT / apply-ready. These specs import from the proposed module // `src/background/desktop-pairing.ts` (Change 3). Path assumes this file is // relocated to `src/background/desktop-pairing.test.ts` on apply; from the // draft location the imports below would resolve as written if vitest's // rootDir maps `src/` — adjust the two `../../` prefixes when you move it. // // They use the dependency-injection seam (DesktopPairingDeps / a fake // DesktopPairingSocket) so no real WebSocket or Noise crypto runs — except // the SAS KAT, which calls the REAL `derivePairingSAS` from the shipped // chirp-handshake module to pin cross-impl agreement with peach-sync. import { beforeEach, describe, expect, it, vi } from "vitest"; // Change 3 module (proposed): import { pairWithDesktopHelper, DESKTOP_PAIRING_PORT, type DesktopPairingDeps, type DesktopPairingSocket, type PairWithDesktopCallbacks, } from "./desktop-pairing"; // REAL SAS implementation — reused, not re-derived (ground truth §5). import { derivePairingSAS } from "../shared/chirp-handshake"; import { encodePairingCode } from "../shared/pairing-code"; import type { LocalIdentity } from "../shared/chirp-handshake"; import type { PairedDevice } from "../shared/sync-types"; // ── helpers ──────────────────────────────────────────────────────────────── const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o)); const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b)); const HELPER_EPHEMERAL_HEX = "a".repeat(64); const HELPER_LONGTERM_HEX = "b".repeat(64); const OUR_IDENTITY: LocalIdentity = { deviceId: "ext-local-001", deviceName: "Firefox on MacBook", deviceType: "extension", longTermPublicKeyHex: "c".repeat(64), signingPublicKeyHex: "d".repeat(64), vaultId: "e".repeat(64), }; /** * Scripted fake socket. Captures everything sent, and replays a queued list * of inbound frames. A frame of `null` means "the socket closes here with no * data" — the peach-sync wrong-code behaviour (no error frame). */ function makeFakeSocket(inbound: Array) { const sent: Uint8Array[] = []; let closed = false; let lastWasClose = false; const queue = [...inbound]; const sock: DesktopPairingSocket = { send(bytes) { sent.push(bytes); }, async receive() { const next = queue.shift(); if (next === undefined || next === null) { closed = true; lastWasClose = true; throw new Error("socket closed"); } lastWasClose = false; return next; }, close() { closed = true; }, closedBeforeFrame() { return lastWasClose; }, }; return { sock, sent, isClosed: () => closed }; } /** * A fake Noise session that no-ops encrypt/decrypt (identity transform) so we * can drive the PAIR_IDENTITY exchange without real crypto. We stub * `noiseNKInitiator` via vi.mock below; this is the shape it returns. */ const FAKE_HANDSHAKE_HASH = Uint8Array.from([ 0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0), ]); // Mock the NK initiator: skip the two handshake messages, hand back a session // whose encrypt/decrypt are identity transforms and whose handshakeHash is the // peach-sync KAT prefix (so the SAS test below is end-to-end through the flow). vi.mock("../shared/noise-js", async (orig) => { const actual = await orig(); return { ...actual, noiseNKInitiator: vi.fn(async (_responderStatic, _send, _receive) => ({ encrypt: (pt: Uint8Array) => pt, decrypt: (ct: Uint8Array) => ct, handshakeHash: FAKE_HANDSHAKE_HASH, })), }; }); function makeDeps(overrides: Partial = {}): { deps: DesktopPairingDeps; persisted: PairedDevice[]; sockets: ReturnType[]; } { const persisted: PairedDevice[] = []; const sockets: ReturnType[] = []; const deps: DesktopPairingDeps = { findHost: vi.fn(async () => "192.168.1.50"), openSocket: vi.fn(async () => { // default: ACK then a PAIR_IDENTITY response const s = makeFakeSocket([ enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }), enc({ type: "PAIR_IDENTITY", device_id: "desktop-001", device_name: "June's iMac", device_type: "desktop", long_term_public_key_hex: HELPER_LONGTERM_HEX, ws_port: 8765, }), ]); sockets.push(s); return s.sock; }), getLocalIdentity: vi.fn(async () => OUR_IDENTITY), persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }), ...overrides, }; return { deps, persisted, sockets }; } function makeCallbacks(): { cb: PairWithDesktopCallbacks; sas: { value?: string }; errors: string[]; completed: PairedDevice[]; } { const sas: { value?: string } = {}; const errors: string[] = []; const completed: PairedDevice[] = []; const cb: PairWithDesktopCallbacks = { onSasReady: (s) => { sas.value = s; }, onError: (e) => { errors.push(e); }, onComplete: (d) => { completed.push(d); }, }; return { cb, sas, errors, completed }; } beforeEach(() => { vi.clearAllMocks(); }); // ── specs ──────────────────────────────────────────────────────────────── describe("pairWithDesktopHelper", () => { it("sends PEACH_HELLO with a 6-digit zero-padded code to port 49142", async () => { const { deps, sockets } = makeDeps(); const { cb } = makeCallbacks(); await pairWithDesktopHelper("123", cb, deps, async () => true); expect(deps.openSocket).toHaveBeenCalledWith("192.168.1.50", DESKTOP_PAIRING_PORT); const firstFrame = dec(sockets[0].sent[0]); expect(firstFrame.type).toBe("PEACH_HELLO"); // "123" must be normalised to exactly 6 ASCII digits, zero-padded. expect(firstFrame.code).toBe("000123"); expect(firstFrame.code).toMatch(/^\d{6}$/); }); it("treats a socket close without PEACH_HELLO_ACK as invalid_code", async () => { // peach-sync sends NO error frame on a wrong/expired code — it SILENTLY // CLOSES the socket and rotates the code (pairing.zig:117,119-125). So the // absence of a PEACH_HELLO_ACK *is* the wrong-code signal. (This replaces // the original brief's bogus PEACH_HELLO_ERROR test — no such frame exists.) const closeImmediately = makeFakeSocket([null]); // close, no data const { deps } = makeDeps({ openSocket: vi.fn(async () => closeImmediately.sock), }); const { cb, errors, completed } = makeCallbacks(); await pairWithDesktopHelper("654321", cb, deps, async () => true); expect(errors).toEqual(["invalid_code"]); expect(completed).toHaveLength(0); expect(deps.persistDevice).not.toHaveBeenCalled(); }); it("derives the SAS via the real derivePairingSAS — KAT 0fbab8cd… → 895245", () => { // Cross-impl KAT pinned in peach-sync (pairing.zig:275-279): the handshake // hash beginning 0f ba b8 cd yields SAS "895245". 0x0fbab8cd = 263895245; // 263895245 mod 1_000_000 = 895245. const hash = Uint8Array.from([ 0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0), ]); expect(derivePairingSAS(hash)).toBe("895245"); }); it("surfaces that same SAS through the pairing flow", async () => { // FAKE_HANDSHAKE_HASH starts 0fbab8cd, so onSasReady must report 895245. const { deps } = makeDeps(); const { cb, sas } = makeCallbacks(); await pairWithDesktopHelper("000123", cb, deps, async () => true); expect(sas.value).toBe("895245"); }); it("persists the device with deviceType 'desktop' on SAS confirm", async () => { const { deps, persisted } = makeDeps(); const { cb, completed, errors } = makeCallbacks(); await pairWithDesktopHelper("000123", cb, deps, async () => true); expect(errors).toHaveLength(0); expect(persisted).toHaveLength(1); expect(persisted[0].deviceType).toBe("desktop"); expect(persisted[0].deviceId).toBe("desktop-001"); expect(persisted[0].longTermPublicKeyHex).toBe(HELPER_LONGTERM_HEX); expect(persisted[0].trustSource).toBe("direct"); expect(persisted[0].wsPort).toBe(8765); expect(completed).toHaveLength(1); }); it("aborts with sas_mismatch and persists nothing when the user rejects", async () => { const { deps, persisted } = makeDeps(); const { cb, errors } = makeCallbacks(); await pairWithDesktopHelper("000123", cb, deps, async () => false); expect(errors).toEqual(["sas_mismatch"]); expect(persisted).toHaveLength(0); expect(deps.persistDevice).not.toHaveBeenCalled(); }); it("includes vault_id in the outbound PAIR_IDENTITY (Change 1)", async () => { const { deps, sockets } = makeDeps(); const { cb } = makeCallbacks(); await pairWithDesktopHelper("000123", cb, deps, async () => true); // sent[0] = PEACH_HELLO (plaintext); sent[1] = our PAIR_IDENTITY // (encrypt is identity in the mock, so it decodes directly). const ourPairIdentity = dec(sockets[0].sent[1]); expect(ourPairIdentity.type).toBe("PAIR_IDENTITY"); expect(ourPairIdentity.vault_id).toBe(OUR_IDENTITY.vaultId); }); it("reports no_host_found when the probe returns null", async () => { const { deps } = makeDeps({ findHost: vi.fn(async () => null) }); const { cb, errors } = makeCallbacks(); await pairWithDesktopHelper("000123", cb, deps, async () => true); expect(errors).toEqual(["no_host_found"]); expect(deps.openSocket).not.toHaveBeenCalled(); }); it("reports vault_locked when local identity is unavailable", async () => { const { deps } = makeDeps({ getLocalIdentity: vi.fn(async () => { throw new Error("vault_locked"); }), }); const { cb, errors } = makeCallbacks(); await pairWithDesktopHelper("000123", cb, deps, async () => true); expect(errors).toEqual(["vault_locked"]); expect(deps.findHost).not.toHaveBeenCalled(); }); }); // ── pairing-code path (the phone is the listener) ────────────────────────── const PHONE_TOKEN = Uint8Array.of(0xde, 0xad, 0xbe, 0xef); const PHONE_CODE = encodePairingCode("192.168.4.77", 51555, PHONE_TOKEN); const PHONE_LONGTERM_HEX = "f".repeat(64); /** Deps whose socket replays an Android peer's ACK + PAIR_IDENTITY. */ function makePhoneDeps(deviceType: string | undefined = "android") { const identity: Record = { type: "PAIR_IDENTITY", device_id: "android-001", device_name: "Pixel 8", long_term_public_key_hex: PHONE_LONGTERM_HEX, ws_port: 8765, }; identity.device_type = deviceType; return makeDeps({ openSocket: vi.fn(async () => makeFakeSocket([ enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }), enc(identity), ]).sock), }); } describe("pairWithDesktopHelper — pairing code", () => { it("dials the address in the code instead of probing for a host", async () => { const { deps } = makePhoneDeps(); const { cb, errors } = makeCallbacks(); await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true); expect(errors).toHaveLength(0); expect(deps.findHost).not.toHaveBeenCalled(); expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555); }); it("presents the token as hex in the plaintext hello, not a 6-digit code", async () => { const sockets: ReturnType[] = []; const { deps } = makeDeps({ openSocket: vi.fn(async () => { const s = makeFakeSocket([ enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }), enc({ type: "PAIR_IDENTITY", device_id: "android-001", device_name: "Pixel 8", device_type: "android", long_term_public_key_hex: PHONE_LONGTERM_HEX, ws_port: 8765, }), ]); sockets.push(s); return s.sock; }), }); const { cb } = makeCallbacks(); await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true); const hello = dec(sockets[0].sent[0]); expect(hello.type).toBe("PEACH_HELLO"); expect(hello.token).toBe("deadbeef"); expect(hello.code).toBeUndefined(); }); it("persists the phone with the device type it announced", async () => { const { deps, persisted } = makePhoneDeps(); const { cb, completed } = makeCallbacks(); await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true); expect(persisted).toHaveLength(1); expect(persisted[0].deviceType).toBe("android"); expect(persisted[0].deviceId).toBe("android-001"); expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX); expect(persisted[0].trustSource).toBe("direct"); expect(persisted[0].wsPort).toBe(8765); expect(completed).toHaveLength(1); }); it("falls back to 'android', never 'desktop', when the peer omits its type", async () => { const { deps, persisted } = makePhoneDeps(""); const { cb } = makeCallbacks(); await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true); expect(persisted[0].deviceType).toBe("android"); }); it("caches the coded IP for the next sync round", async () => { const cacheLastKnownIp = vi.fn(async () => undefined); const { deps } = makePhoneDeps(); deps.cacheLastKnownIp = cacheLastKnownIp; const { cb } = makeCallbacks(); await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true); expect(cacheLastKnownIp).toHaveBeenCalledWith(PHONE_LONGTERM_HEX, "192.168.4.77"); }); it("reports no_host_found when the coded endpoint refuses the connection", async () => { const { deps } = makeDeps({ openSocket: vi.fn(async () => { throw new Error("ECONNREFUSED"); }), }); const { cb, errors } = makeCallbacks(); await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true); expect(errors).toEqual(["no_host_found"]); expect(deps.persistDevice).not.toHaveBeenCalled(); }); it("still aborts on SAS rejection — the token is not the security boundary", async () => { const { deps, persisted } = makePhoneDeps(); const { cb, errors } = makeCallbacks(); await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => false); expect(errors).toEqual(["sas_mismatch"]); expect(persisted).toHaveLength(0); }); });