12345678import { beforeEach, describe, expect, it, vi } from "vitest";910import {11 pairWithCode,12 type CodePairingDeps,13 type CodePairingCallbacks,14} from "./code-pairing";15import type { PairingSocket } from "../shared/pairing-dial";161718import { derivePairingSAS } from "../shared/pairing-handshake";19import { encodePairingCode } from "../shared/pairing-code";20import type { LocalIdentity } from "../shared/pairing-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 PEER_EPHEMERAL_HEX = "a".repeat(64);29const PHONE_LONGTERM_HEX = "f".repeat(64);30const PHONE_TOKEN = Uint8Array.of(0xde, 0xad, 0xbe, 0xef);31const PHONE_CODE = encodePairingCode("192.168.4.77", 51555, PHONE_TOKEN);3233const OUR_IDENTITY: LocalIdentity = {34 deviceId: "ext-local-001",35 deviceName: "Firefox on MacBook",36 deviceType: "extension",37 longTermPublicKeyHex: "c".repeat(64),38 signingPublicKeyHex: "d".repeat(64),39 vaultId: "e".repeat(64),40};41424344454647function makeFakeSocket(inbound: Array<Uint8Array | null>) {48 const sent: Uint8Array[] = [];49 let closed = false;50 let lastWasClose = false;51 const queue = [...inbound];52 const sock: PairingSocket = {53 send(bytes) { sent.push(bytes); },54 async receive() {55 const next = queue.shift();56 if (next === undefined || next === null) {57 closed = true;58 lastWasClose = true;59 throw new Error("socket closed");60 }61 lastWasClose = false;62 return next;63 },64 close() { closed = true; },65 closedBeforeFrame() { return lastWasClose; },66 };67 return { sock, sent, isClosed: () => closed };68}69707172737475const FAKE_HANDSHAKE_HASH = Uint8Array.from([76 0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),77]);7879808182vi.mock("../shared/noise-js", async (orig) => {83 const actual = await orig<typeof import("../shared/noise-js")>();84 return {85 ...actual,86 noiseNKInitiator: vi.fn(async (_responderStatic, _send, _receive) => ({87 encrypt: (pt: Uint8Array) => pt,88 decrypt: (ct: Uint8Array) => ct,89 handshakeHash: FAKE_HANDSHAKE_HASH,90 })),91 };92});9394function makeDeps(overrides: Partial<CodePairingDeps> = {}): {95 deps: CodePairingDeps;96 persisted: PairedDevice[];97 sockets: ReturnType<typeof makeFakeSocket>[];98} {99 const persisted: PairedDevice[] = [];100 const sockets: ReturnType<typeof makeFakeSocket>[] = [];101 const deps: CodePairingDeps = {102 openSocket: vi.fn(async () => {103 104 const s = makeFakeSocket([105 enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),106 enc({107 type: "PAIR_IDENTITY",108 device_id: "android-001",109 device_name: "Pixel 8",110 device_type: "android",111 long_term_public_key_hex: PHONE_LONGTERM_HEX,112 ws_port: 49142,113 }),114 ]);115 sockets.push(s);116 return s.sock;117 }),118 getLocalIdentity: vi.fn(async () => OUR_IDENTITY),119 persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }),120 ...overrides,121 };122 return { deps, persisted, sockets };123}124125function makeCallbacks(): {126 cb: CodePairingCallbacks;127 sas: { value?: string };128 errors: string[];129 completed: PairedDevice[];130} {131 const sas: { value?: string } = {};132 const errors: string[] = [];133 const completed: PairedDevice[] = [];134 const cb: CodePairingCallbacks = {135 onSasReady: (s) => { sas.value = s; },136 onError: (e) => { errors.push(e); },137 onComplete: (d) => { completed.push(d); },138 };139 return { cb, sas, errors, completed };140}141142beforeEach(() => {143 vi.clearAllMocks();144});145146147148describe("pairWithCode", () => {149 it("dials the coded endpoint and sends its token in PEACH_HELLO", async () => {150 const { deps, sockets } = makeDeps();151 const { cb } = makeCallbacks();152153 await pairWithCode(PHONE_CODE, cb, deps, async () => true);154155 expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);156 const firstFrame = dec(sockets[0].sent[0]);157 expect(firstFrame).toEqual({ type: "PEACH_HELLO", token: "deadbeef" });158 });159160 it("treats a socket close without PEACH_HELLO_ACK as invalid_code", async () => {161 162 163 164 165 const closeImmediately = makeFakeSocket([null]); 166 const { deps } = makeDeps({167 openSocket: vi.fn(async () => closeImmediately.sock),168 });169 const { cb, errors, completed } = makeCallbacks();170171 await pairWithCode(PHONE_CODE, cb, deps, async () => true);172173 expect(errors).toEqual(["invalid_code"]);174 expect(completed).toHaveLength(0);175 expect(deps.persistDevice).not.toHaveBeenCalled();176 });177178 it("derives the SAS via the real derivePairingSAS — KAT 0fbab8cd… → 895245", () => {179 180 181 182 const hash = Uint8Array.from([183 0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),184 ]);185 expect(derivePairingSAS(hash)).toBe("895245");186 });187188 it("surfaces that same SAS through the pairing flow", async () => {189 190 const { deps } = makeDeps();191 const { cb, sas } = makeCallbacks();192193 await pairWithCode(PHONE_CODE, cb, deps, async () => true);194195 expect(sas.value).toBe("895245");196 });197198 it("persists the phone identity on SAS confirm", async () => {199 const { deps, persisted } = makeDeps();200 const { cb, completed, errors } = makeCallbacks();201202 await pairWithCode(PHONE_CODE, cb, deps, async () => true);203204 expect(errors).toHaveLength(0);205 expect(persisted).toHaveLength(1);206 expect(persisted[0].deviceType).toBe("android");207 expect(persisted[0].deviceId).toBe("android-001");208 expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);209 expect(persisted[0].trustSource).toBe("direct");210 expect(persisted[0].wsPort).toBe(49142);211 expect(completed).toHaveLength(1);212 });213214 it("aborts with sas_mismatch and persists nothing when the user rejects", async () => {215 const { deps, persisted } = makeDeps();216 const { cb, errors } = makeCallbacks();217218 await pairWithCode(PHONE_CODE, cb, deps, async () => false);219220 expect(errors).toEqual(["sas_mismatch"]);221 expect(persisted).toHaveLength(0);222 expect(deps.persistDevice).not.toHaveBeenCalled();223 });224225 it("includes vault_id in the outbound PAIR_IDENTITY (Change 1)", async () => {226 const { deps, sockets } = makeDeps();227 const { cb } = makeCallbacks();228229 await pairWithCode(PHONE_CODE, cb, deps, async () => true);230231 232 233 const ourPairIdentity = dec(sockets[0].sent[1]);234 expect(ourPairIdentity.type).toBe("PAIR_IDENTITY");235 expect(ourPairIdentity.vault_id).toBe(OUR_IDENTITY.vaultId);236 });237238 it("reports vault_locked when local identity is unavailable", async () => {239 const { deps } = makeDeps({240 getLocalIdentity: vi.fn(async () => { throw new Error("vault_locked"); }),241 });242 const { cb, errors } = makeCallbacks();243244 await pairWithCode(PHONE_CODE, cb, deps, async () => true);245246 expect(errors).toEqual(["vault_locked"]);247 expect(deps.openSocket).not.toHaveBeenCalled();248 });249});250251252253254function makePhoneDeps(deviceType: string | undefined = "android") {255 const identity: Record<string, unknown> = {256 type: "PAIR_IDENTITY",257 device_id: "android-001",258 device_name: "Pixel 8",259 long_term_public_key_hex: PHONE_LONGTERM_HEX,260 ws_port: 49142,261 };262 identity.device_type = deviceType;263 return makeDeps({264 openSocket: vi.fn(async () => makeFakeSocket([265 enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),266 enc(identity),267 ]).sock),268 });269}270271describe("pairWithCode — pairing code", () => {272 it("dials the address in the code", async () => {273 const { deps } = makePhoneDeps();274 const { cb, errors } = makeCallbacks();275276 await pairWithCode(PHONE_CODE, cb, deps, async () => true);277278 expect(errors).toHaveLength(0);279 expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);280 });281282 it("presents the token as hex in the plaintext hello, not a 6-digit code", async () => {283 const sockets: ReturnType<typeof makeFakeSocket>[] = [];284 const { deps } = makeDeps({285 openSocket: vi.fn(async () => {286 const s = makeFakeSocket([287 enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),288 enc({289 type: "PAIR_IDENTITY",290 device_id: "android-001",291 device_name: "Pixel 8",292 device_type: "android",293 long_term_public_key_hex: PHONE_LONGTERM_HEX,294 ws_port: 49142,295 }),296 ]);297 sockets.push(s);298 return s.sock;299 }),300 });301 const { cb } = makeCallbacks();302303 await pairWithCode(PHONE_CODE, cb, deps, async () => true);304305 const hello = dec(sockets[0].sent[0]);306 expect(hello.type).toBe("PEACH_HELLO");307 expect(hello.token).toBe("deadbeef");308 expect(hello.code).toBeUndefined();309 });310311 it("persists the phone with the device type it announced", async () => {312 const { deps, persisted } = makePhoneDeps();313 const { cb, completed } = makeCallbacks();314315 await pairWithCode(PHONE_CODE, cb, deps, async () => true);316317 expect(persisted).toHaveLength(1);318 expect(persisted[0].deviceType).toBe("android");319 expect(persisted[0].deviceId).toBe("android-001");320 expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);321 expect(persisted[0].trustSource).toBe("direct");322 expect(persisted[0].wsPort).toBe(49142);323 expect(completed).toHaveLength(1);324 });325326 it("rejects a peer that omits its device type", async () => {327 const { deps, persisted } = makePhoneDeps("");328 const { cb, errors } = makeCallbacks();329330 await pairWithCode(PHONE_CODE, cb, deps, async () => true);331332 expect(errors).toEqual(["handshake_failed"]);333 expect(persisted).toHaveLength(0);334 });335336 it("caches the coded IP for the next sync round", async () => {337 const cacheLastKnownIp = vi.fn(async () => undefined);338 const { deps } = makePhoneDeps();339 deps.cacheLastKnownIp = cacheLastKnownIp;340 const { cb } = makeCallbacks();341342 await pairWithCode(PHONE_CODE, cb, deps, async () => true);343344 expect(cacheLastKnownIp).toHaveBeenCalledWith(PHONE_LONGTERM_HEX, "192.168.4.77");345 });346347 it("reports no_host_found when the coded endpoint refuses the connection", async () => {348 const { deps } = makeDeps({349 openSocket: vi.fn(async () => { throw new Error("ECONNREFUSED"); }),350 });351 const { cb, errors } = makeCallbacks();352353 await pairWithCode(PHONE_CODE, cb, deps, async () => true);354355 expect(errors).toEqual(["no_host_found"]);356 expect(deps.persistDevice).not.toHaveBeenCalled();357 });358359 it("still aborts on SAS rejection — the token is not the security boundary", async () => {360 const { deps, persisted } = makePhoneDeps();361 const { cb, errors } = makeCallbacks();362363 await pairWithCode(PHONE_CODE, cb, deps, async () => false);364365 expect(errors).toEqual(["sas_mismatch"]);366 expect(persisted).toHaveLength(0);367 });368});