1234567891011121314import { beforeEach, describe, expect, it, vi } from "vitest";151617import {18 pairWithDesktopHelper,19 DESKTOP_PAIRING_PORT,20 type DesktopPairingDeps,21 type DesktopPairingSocket,22 type PairWithDesktopCallbacks,23} from "./desktop-pairing";242526import { derivePairingSAS } from "../shared/chirp-handshake";27import { encodePairingCode } from "../shared/pairing-code";28import type { LocalIdentity } from "../shared/chirp-handshake";29import type { PairedDevice } from "../shared/sync-types";30313233const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o));34const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b));3536const HELPER_EPHEMERAL_HEX = "a".repeat(64);37const HELPER_LONGTERM_HEX = "b".repeat(64);3839const OUR_IDENTITY: LocalIdentity = {40 deviceId: "ext-local-001",41 deviceName: "Firefox on MacBook",42 deviceType: "extension",43 longTermPublicKeyHex: "c".repeat(64),44 signingPublicKeyHex: "d".repeat(64),45 vaultId: "e".repeat(64),46};47484950515253function makeFakeSocket(inbound: Array<Uint8Array | null>) {54 const sent: Uint8Array[] = [];55 let closed = false;56 let lastWasClose = false;57 const queue = [...inbound];58 const sock: DesktopPairingSocket = {59 send(bytes) { sent.push(bytes); },60 async receive() {61 const next = queue.shift();62 if (next === undefined || next === null) {63 closed = true;64 lastWasClose = true;65 throw new Error("socket closed");66 }67 lastWasClose = false;68 return next;69 },70 close() { closed = true; },71 closedBeforeFrame() { return lastWasClose; },72 };73 return { sock, sent, isClosed: () => closed };74}75767778798081const FAKE_HANDSHAKE_HASH = Uint8Array.from([82 0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),83]);8485868788vi.mock("../shared/noise-js", async (orig) => {89 const actual = await orig<typeof import("../shared/noise-js")>();90 return {91 ...actual,92 noiseNKInitiator: vi.fn(async (_responderStatic, _send, _receive) => ({93 encrypt: (pt: Uint8Array) => pt,94 decrypt: (ct: Uint8Array) => ct,95 handshakeHash: FAKE_HANDSHAKE_HASH,96 })),97 };98});99100function makeDeps(overrides: Partial<DesktopPairingDeps> = {}): {101 deps: DesktopPairingDeps;102 persisted: PairedDevice[];103 sockets: ReturnType<typeof makeFakeSocket>[];104} {105 const persisted: PairedDevice[] = [];106 const sockets: ReturnType<typeof makeFakeSocket>[] = [];107 const deps: DesktopPairingDeps = {108 findHost: vi.fn(async () => "192.168.1.50"),109 openSocket: vi.fn(async () => {110 111 const s = makeFakeSocket([112 enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),113 enc({114 type: "PAIR_IDENTITY",115 device_id: "desktop-001",116 device_name: "June's iMac",117 device_type: "desktop",118 long_term_public_key_hex: HELPER_LONGTERM_HEX,119 ws_port: 8765,120 }),121 ]);122 sockets.push(s);123 return s.sock;124 }),125 getLocalIdentity: vi.fn(async () => OUR_IDENTITY),126 persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }),127 ...overrides,128 };129 return { deps, persisted, sockets };130}131132function makeCallbacks(): {133 cb: PairWithDesktopCallbacks;134 sas: { value?: string };135 errors: string[];136 completed: PairedDevice[];137} {138 const sas: { value?: string } = {};139 const errors: string[] = [];140 const completed: PairedDevice[] = [];141 const cb: PairWithDesktopCallbacks = {142 onSasReady: (s) => { sas.value = s; },143 onError: (e) => { errors.push(e); },144 onComplete: (d) => { completed.push(d); },145 };146 return { cb, sas, errors, completed };147}148149beforeEach(() => {150 vi.clearAllMocks();151});152153154155describe("pairWithDesktopHelper", () => {156 it("sends PEACH_HELLO with a 6-digit zero-padded code to port 49142", async () => {157 const { deps, sockets } = makeDeps();158 const { cb } = makeCallbacks();159160 await pairWithDesktopHelper("123", cb, deps, async () => true);161162 expect(deps.openSocket).toHaveBeenCalledWith("192.168.1.50", DESKTOP_PAIRING_PORT);163 const firstFrame = dec(sockets[0].sent[0]);164 expect(firstFrame.type).toBe("PEACH_HELLO");165 166 expect(firstFrame.code).toBe("000123");167 expect(firstFrame.code).toMatch(/^\d{6}$/);168 });169170 it("treats a socket close without PEACH_HELLO_ACK as invalid_code", async () => {171 172 173 174 175 const closeImmediately = makeFakeSocket([null]); 176 const { deps } = makeDeps({177 openSocket: vi.fn(async () => closeImmediately.sock),178 });179 const { cb, errors, completed } = makeCallbacks();180181 await pairWithDesktopHelper("654321", cb, deps, async () => true);182183 expect(errors).toEqual(["invalid_code"]);184 expect(completed).toHaveLength(0);185 expect(deps.persistDevice).not.toHaveBeenCalled();186 });187188 it("derives the SAS via the real derivePairingSAS — KAT 0fbab8cd… → 895245", () => {189 190 191 192 const hash = Uint8Array.from([193 0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),194 ]);195 expect(derivePairingSAS(hash)).toBe("895245");196 });197198 it("surfaces that same SAS through the pairing flow", async () => {199 200 const { deps } = makeDeps();201 const { cb, sas } = makeCallbacks();202203 await pairWithDesktopHelper("000123", cb, deps, async () => true);204205 expect(sas.value).toBe("895245");206 });207208 it("persists the device with deviceType 'desktop' on SAS confirm", async () => {209 const { deps, persisted } = makeDeps();210 const { cb, completed, errors } = makeCallbacks();211212 await pairWithDesktopHelper("000123", cb, deps, async () => true);213214 expect(errors).toHaveLength(0);215 expect(persisted).toHaveLength(1);216 expect(persisted[0].deviceType).toBe("desktop");217 expect(persisted[0].deviceId).toBe("desktop-001");218 expect(persisted[0].longTermPublicKeyHex).toBe(HELPER_LONGTERM_HEX);219 expect(persisted[0].trustSource).toBe("direct");220 expect(persisted[0].wsPort).toBe(8765);221 expect(completed).toHaveLength(1);222 });223224 it("aborts with sas_mismatch and persists nothing when the user rejects", async () => {225 const { deps, persisted } = makeDeps();226 const { cb, errors } = makeCallbacks();227228 await pairWithDesktopHelper("000123", cb, deps, async () => false);229230 expect(errors).toEqual(["sas_mismatch"]);231 expect(persisted).toHaveLength(0);232 expect(deps.persistDevice).not.toHaveBeenCalled();233 });234235 it("includes vault_id in the outbound PAIR_IDENTITY (Change 1)", async () => {236 const { deps, sockets } = makeDeps();237 const { cb } = makeCallbacks();238239 await pairWithDesktopHelper("000123", cb, deps, async () => true);240241 242 243 const ourPairIdentity = dec(sockets[0].sent[1]);244 expect(ourPairIdentity.type).toBe("PAIR_IDENTITY");245 expect(ourPairIdentity.vault_id).toBe(OUR_IDENTITY.vaultId);246 });247248 it("reports no_host_found when the probe returns null", async () => {249 const { deps } = makeDeps({ findHost: vi.fn(async () => null) });250 const { cb, errors } = makeCallbacks();251252 await pairWithDesktopHelper("000123", cb, deps, async () => true);253254 expect(errors).toEqual(["no_host_found"]);255 expect(deps.openSocket).not.toHaveBeenCalled();256 });257258 it("reports vault_locked when local identity is unavailable", async () => {259 const { deps } = makeDeps({260 getLocalIdentity: vi.fn(async () => { throw new Error("vault_locked"); }),261 });262 const { cb, errors } = makeCallbacks();263264 await pairWithDesktopHelper("000123", cb, deps, async () => true);265266 expect(errors).toEqual(["vault_locked"]);267 expect(deps.findHost).not.toHaveBeenCalled();268 });269});270271272273const PHONE_TOKEN = Uint8Array.of(0xde, 0xad, 0xbe, 0xef);274const PHONE_CODE = encodePairingCode("192.168.4.77", 51555, PHONE_TOKEN);275const PHONE_LONGTERM_HEX = "f".repeat(64);276277278function makePhoneDeps(deviceType: string | undefined = "android") {279 const identity: Record<string, unknown> = {280 type: "PAIR_IDENTITY",281 device_id: "android-001",282 device_name: "Pixel 8",283 long_term_public_key_hex: PHONE_LONGTERM_HEX,284 ws_port: 8765,285 };286 identity.device_type = deviceType;287 return makeDeps({288 openSocket: vi.fn(async () => makeFakeSocket([289 enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),290 enc(identity),291 ]).sock),292 });293}294295describe("pairWithDesktopHelper — pairing code", () => {296 it("dials the address in the code instead of probing for a host", async () => {297 const { deps } = makePhoneDeps();298 const { cb, errors } = makeCallbacks();299300 await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);301302 expect(errors).toHaveLength(0);303 expect(deps.findHost).not.toHaveBeenCalled();304 expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);305 });306307 it("presents the token as hex in the plaintext hello, not a 6-digit code", async () => {308 const sockets: ReturnType<typeof makeFakeSocket>[] = [];309 const { deps } = makeDeps({310 openSocket: vi.fn(async () => {311 const s = makeFakeSocket([312 enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),313 enc({314 type: "PAIR_IDENTITY",315 device_id: "android-001",316 device_name: "Pixel 8",317 device_type: "android",318 long_term_public_key_hex: PHONE_LONGTERM_HEX,319 ws_port: 8765,320 }),321 ]);322 sockets.push(s);323 return s.sock;324 }),325 });326 const { cb } = makeCallbacks();327328 await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);329330 const hello = dec(sockets[0].sent[0]);331 expect(hello.type).toBe("PEACH_HELLO");332 expect(hello.token).toBe("deadbeef");333 expect(hello.code).toBeUndefined();334 });335336 it("persists the phone with the device type it announced", async () => {337 const { deps, persisted } = makePhoneDeps();338 const { cb, completed } = makeCallbacks();339340 await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);341342 expect(persisted).toHaveLength(1);343 expect(persisted[0].deviceType).toBe("android");344 expect(persisted[0].deviceId).toBe("android-001");345 expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);346 expect(persisted[0].trustSource).toBe("direct");347 expect(persisted[0].wsPort).toBe(8765);348 expect(completed).toHaveLength(1);349 });350351 it("falls back to 'android', never 'desktop', when the peer omits its type", async () => {352 const { deps, persisted } = makePhoneDeps("");353 const { cb } = makeCallbacks();354355 await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);356357 expect(persisted[0].deviceType).toBe("android");358 });359360 it("caches the coded IP for the next sync round", async () => {361 const cacheLastKnownIp = vi.fn(async () => undefined);362 const { deps } = makePhoneDeps();363 deps.cacheLastKnownIp = cacheLastKnownIp;364 const { cb } = makeCallbacks();365366 await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);367368 expect(cacheLastKnownIp).toHaveBeenCalledWith(PHONE_LONGTERM_HEX, "192.168.4.77");369 });370371 it("reports no_host_found when the coded endpoint refuses the connection", async () => {372 const { deps } = makeDeps({373 openSocket: vi.fn(async () => { throw new Error("ECONNREFUSED"); }),374 });375 const { cb, errors } = makeCallbacks();376377 await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);378379 expect(errors).toEqual(["no_host_found"]);380 expect(deps.persistDevice).not.toHaveBeenCalled();381 });382383 it("still aborts on SAS rejection — the token is not the security boundary", async () => {384 const { deps, persisted } = makePhoneDeps();385 const { cb, errors } = makeCallbacks();386387 await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => false);388389 expect(errors).toEqual(["sas_mismatch"]);390 expect(persisted).toHaveLength(0);391 });392});