12345678import { beforeEach, describe, expect, it, vi } from "vitest";910import {11 pairWithCode,12 type CodePairingDeps,13 type CodePairingCallbacks,14} from "./code-pairing";15import { PAIRING_PORT, type PairingSocket } from "../shared/pairing-dial";161718import { derivePairingSAS } from "../shared/chirp-handshake";19import { encodePairingCode } from "../shared/pairing-code";20import type { LocalIdentity } from "../shared/chirp-handshake";21import type { PairedDevice } from "../shared/sync-types";22232425const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o));26const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b));2728const HELPER_EPHEMERAL_HEX = "a".repeat(64);29const HELPER_LONGTERM_HEX = "b".repeat(64);3031const OUR_IDENTITY: LocalIdentity = {32 deviceId: "ext-local-001",33 deviceName: "Firefox on MacBook",34 deviceType: "extension",35 longTermPublicKeyHex: "c".repeat(64),36 signingPublicKeyHex: "d".repeat(64),37 vaultId: "e".repeat(64),38};39404142434445function makeFakeSocket(inbound: Array<Uint8Array | null>) {46 const sent: Uint8Array[] = [];47 let closed = false;48 let lastWasClose = false;49 const queue = [...inbound];50 const sock: PairingSocket = {51 send(bytes) { sent.push(bytes); },52 async receive() {53 const next = queue.shift();54 if (next === undefined || next === null) {55 closed = true;56 lastWasClose = true;57 throw new Error("socket closed");58 }59 lastWasClose = false;60 return next;61 },62 close() { closed = true; },63 closedBeforeFrame() { return lastWasClose; },64 };65 return { sock, sent, isClosed: () => closed };66}67686970717273const FAKE_HANDSHAKE_HASH = Uint8Array.from([74 0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),75]);7677787980vi.mock("../shared/noise-js", async (orig) => {81 const actual = await orig<typeof import("../shared/noise-js")>();82 return {83 ...actual,84 noiseNKInitiator: vi.fn(async (_responderStatic, _send, _receive) => ({85 encrypt: (pt: Uint8Array) => pt,86 decrypt: (ct: Uint8Array) => ct,87 handshakeHash: FAKE_HANDSHAKE_HASH,88 })),89 };90});9192function makeDeps(overrides: Partial<CodePairingDeps> = {}): {93 deps: CodePairingDeps;94 persisted: PairedDevice[];95 sockets: ReturnType<typeof makeFakeSocket>[];96} {97 const persisted: PairedDevice[] = [];98 const sockets: ReturnType<typeof makeFakeSocket>[] = [];99 const deps: CodePairingDeps = {100 findHost: vi.fn(async () => "192.168.1.50"),101 openSocket: vi.fn(async () => {102 103 const s = makeFakeSocket([104 enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),105 enc({106 type: "PAIR_IDENTITY",107 device_id: "desktop-001",108 device_name: "June's iMac",109 device_type: "desktop",110 long_term_public_key_hex: HELPER_LONGTERM_HEX,111 ws_port: 8765,112 }),113 ]);114 sockets.push(s);115 return s.sock;116 }),117 getLocalIdentity: vi.fn(async () => OUR_IDENTITY),118 persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }),119 ...overrides,120 };121 return { deps, persisted, sockets };122}123124function makeCallbacks(): {125 cb: CodePairingCallbacks;126 sas: { value?: string };127 errors: string[];128 completed: PairedDevice[];129} {130 const sas: { value?: string } = {};131 const errors: string[] = [];132 const completed: PairedDevice[] = [];133 const cb: CodePairingCallbacks = {134 onSasReady: (s) => { sas.value = s; },135 onError: (e) => { errors.push(e); },136 onComplete: (d) => { completed.push(d); },137 };138 return { cb, sas, errors, completed };139}140141beforeEach(() => {142 vi.clearAllMocks();143});144145146147describe("pairWithCode", () => {148 it("sends PEACH_HELLO with a 6-digit zero-padded code to port 49142", async () => {149 const { deps, sockets } = makeDeps();150 const { cb } = makeCallbacks();151152 await pairWithCode("123", cb, deps, async () => true);153154 expect(deps.openSocket).toHaveBeenCalledWith("192.168.1.50", PAIRING_PORT);155 const firstFrame = dec(sockets[0].sent[0]);156 expect(firstFrame.type).toBe("PEACH_HELLO");157 158 expect(firstFrame.code).toBe("000123");159 expect(firstFrame.code).toMatch(/^\d{6}$/);160 });161162 it("treats a socket close without PEACH_HELLO_ACK as invalid_code", async () => {163 164 165 166 167 const closeImmediately = makeFakeSocket([null]); 168 const { deps } = makeDeps({169 openSocket: vi.fn(async () => closeImmediately.sock),170 });171 const { cb, errors, completed } = makeCallbacks();172173 await pairWithCode("654321", cb, deps, async () => true);174175 expect(errors).toEqual(["invalid_code"]);176 expect(completed).toHaveLength(0);177 expect(deps.persistDevice).not.toHaveBeenCalled();178 });179180 it("derives the SAS via the real derivePairingSAS — KAT 0fbab8cd… → 895245", () => {181 182 183 184 const hash = Uint8Array.from([185 0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),186 ]);187 expect(derivePairingSAS(hash)).toBe("895245");188 });189190 it("surfaces that same SAS through the pairing flow", async () => {191 192 const { deps } = makeDeps();193 const { cb, sas } = makeCallbacks();194195 await pairWithCode("000123", cb, deps, async () => true);196197 expect(sas.value).toBe("895245");198 });199200 it("persists the device with deviceType 'desktop' on SAS confirm", async () => {201 const { deps, persisted } = makeDeps();202 const { cb, completed, errors } = makeCallbacks();203204 await pairWithCode("000123", cb, deps, async () => true);205206 expect(errors).toHaveLength(0);207 expect(persisted).toHaveLength(1);208 expect(persisted[0].deviceType).toBe("desktop");209 expect(persisted[0].deviceId).toBe("desktop-001");210 expect(persisted[0].longTermPublicKeyHex).toBe(HELPER_LONGTERM_HEX);211 expect(persisted[0].trustSource).toBe("direct");212 expect(persisted[0].wsPort).toBe(8765);213 expect(completed).toHaveLength(1);214 });215216 it("aborts with sas_mismatch and persists nothing when the user rejects", async () => {217 const { deps, persisted } = makeDeps();218 const { cb, errors } = makeCallbacks();219220 await pairWithCode("000123", cb, deps, async () => false);221222 expect(errors).toEqual(["sas_mismatch"]);223 expect(persisted).toHaveLength(0);224 expect(deps.persistDevice).not.toHaveBeenCalled();225 });226227 it("includes vault_id in the outbound PAIR_IDENTITY (Change 1)", async () => {228 const { deps, sockets } = makeDeps();229 const { cb } = makeCallbacks();230231 await pairWithCode("000123", cb, deps, async () => true);232233 234 235 const ourPairIdentity = dec(sockets[0].sent[1]);236 expect(ourPairIdentity.type).toBe("PAIR_IDENTITY");237 expect(ourPairIdentity.vault_id).toBe(OUR_IDENTITY.vaultId);238 });239240 it("reports no_host_found when the probe returns null", async () => {241 const { deps } = makeDeps({ findHost: vi.fn(async () => null) });242 const { cb, errors } = makeCallbacks();243244 await pairWithCode("000123", cb, deps, async () => true);245246 expect(errors).toEqual(["no_host_found"]);247 expect(deps.openSocket).not.toHaveBeenCalled();248 });249250 it("reports vault_locked when local identity is unavailable", async () => {251 const { deps } = makeDeps({252 getLocalIdentity: vi.fn(async () => { throw new Error("vault_locked"); }),253 });254 const { cb, errors } = makeCallbacks();255256 await pairWithCode("000123", cb, deps, async () => true);257258 expect(errors).toEqual(["vault_locked"]);259 expect(deps.findHost).not.toHaveBeenCalled();260 });261});262263264265const PHONE_TOKEN = Uint8Array.of(0xde, 0xad, 0xbe, 0xef);266const PHONE_CODE = encodePairingCode("192.168.4.77", 51555, PHONE_TOKEN);267const PHONE_LONGTERM_HEX = "f".repeat(64);268269270function makePhoneDeps(deviceType: string | undefined = "android") {271 const identity: Record<string, unknown> = {272 type: "PAIR_IDENTITY",273 device_id: "android-001",274 device_name: "Pixel 8",275 long_term_public_key_hex: PHONE_LONGTERM_HEX,276 ws_port: 8765,277 };278 identity.device_type = deviceType;279 return makeDeps({280 openSocket: vi.fn(async () => makeFakeSocket([281 enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),282 enc(identity),283 ]).sock),284 });285}286287describe("pairWithCode — pairing code", () => {288 it("dials the address in the code instead of probing for a host", async () => {289 const { deps } = makePhoneDeps();290 const { cb, errors } = makeCallbacks();291292 await pairWithCode(PHONE_CODE, cb, deps, async () => true);293294 expect(errors).toHaveLength(0);295 expect(deps.findHost).not.toHaveBeenCalled();296 expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);297 });298299 it("presents the token as hex in the plaintext hello, not a 6-digit code", async () => {300 const sockets: ReturnType<typeof makeFakeSocket>[] = [];301 const { deps } = makeDeps({302 openSocket: vi.fn(async () => {303 const s = makeFakeSocket([304 enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),305 enc({306 type: "PAIR_IDENTITY",307 device_id: "android-001",308 device_name: "Pixel 8",309 device_type: "android",310 long_term_public_key_hex: PHONE_LONGTERM_HEX,311 ws_port: 8765,312 }),313 ]);314 sockets.push(s);315 return s.sock;316 }),317 });318 const { cb } = makeCallbacks();319320 await pairWithCode(PHONE_CODE, cb, deps, async () => true);321322 const hello = dec(sockets[0].sent[0]);323 expect(hello.type).toBe("PEACH_HELLO");324 expect(hello.token).toBe("deadbeef");325 expect(hello.code).toBeUndefined();326 });327328 it("persists the phone with the device type it announced", async () => {329 const { deps, persisted } = makePhoneDeps();330 const { cb, completed } = makeCallbacks();331332 await pairWithCode(PHONE_CODE, cb, deps, async () => true);333334 expect(persisted).toHaveLength(1);335 expect(persisted[0].deviceType).toBe("android");336 expect(persisted[0].deviceId).toBe("android-001");337 expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);338 expect(persisted[0].trustSource).toBe("direct");339 expect(persisted[0].wsPort).toBe(8765);340 expect(completed).toHaveLength(1);341 });342343 it("falls back to 'android', never 'desktop', when the peer omits its type", async () => {344 const { deps, persisted } = makePhoneDeps("");345 const { cb } = makeCallbacks();346347 await pairWithCode(PHONE_CODE, cb, deps, async () => true);348349 expect(persisted[0].deviceType).toBe("android");350 });351352 it("caches the coded IP for the next sync round", async () => {353 const cacheLastKnownIp = vi.fn(async () => undefined);354 const { deps } = makePhoneDeps();355 deps.cacheLastKnownIp = cacheLastKnownIp;356 const { cb } = makeCallbacks();357358 await pairWithCode(PHONE_CODE, cb, deps, async () => true);359360 expect(cacheLastKnownIp).toHaveBeenCalledWith(PHONE_LONGTERM_HEX, "192.168.4.77");361 });362363 it("reports no_host_found when the coded endpoint refuses the connection", async () => {364 const { deps } = makeDeps({365 openSocket: vi.fn(async () => { throw new Error("ECONNREFUSED"); }),366 });367 const { cb, errors } = makeCallbacks();368369 await pairWithCode(PHONE_CODE, cb, deps, async () => true);370371 expect(errors).toEqual(["no_host_found"]);372 expect(deps.persistDevice).not.toHaveBeenCalled();373 });374375 it("still aborts on SAS rejection — the token is not the security boundary", async () => {376 const { deps, persisted } = makePhoneDeps();377 const { cb, errors } = makeCallbacks();378379 await pairWithCode(PHONE_CODE, cb, deps, async () => false);380381 expect(errors).toEqual(["sas_mismatch"]);382 expect(persisted).toHaveLength(0);383 });384});