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 relay_url: "wss://relay.peachpasswords.com",114 }),115 ]);116 sockets.push(s);117 return s.sock;118 }),119 getLocalIdentity: vi.fn(async () => OUR_IDENTITY),120 getRelayUrl: vi.fn(async () => "wss://relay.peachpasswords.com"),121 persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }),122 ...overrides,123 };124 return { deps, persisted, sockets };125}126127function makeCallbacks(): {128 cb: CodePairingCallbacks;129 sas: { value?: string };130 errors: string[];131 completed: PairedDevice[];132} {133 const sas: { value?: string } = {};134 const errors: string[] = [];135 const completed: PairedDevice[] = [];136 const cb: CodePairingCallbacks = {137 onSasReady: (s) => { sas.value = s; },138 onError: (e) => { errors.push(e); },139 onComplete: (d) => { completed.push(d); },140 };141 return { cb, sas, errors, completed };142}143144beforeEach(() => {145 vi.clearAllMocks();146});147148149150describe("pairWithCode", () => {151 it("dials the coded endpoint and sends its token in PEACH_HELLO", async () => {152 const { deps, sockets } = makeDeps();153 const { cb } = makeCallbacks();154155 await pairWithCode(PHONE_CODE, cb, deps, async () => true);156157 expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);158 const firstFrame = dec(sockets[0].sent[0]);159 expect(firstFrame).toEqual({ type: "PEACH_HELLO", token: "deadbeef" });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(PHONE_CODE, 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(PHONE_CODE, cb, deps, async () => true);196197 expect(sas.value).toBe("895245");198 });199200 it("persists the phone identity on SAS confirm", async () => {201 const { deps, persisted } = makeDeps();202 const { cb, completed, errors } = makeCallbacks();203204 await pairWithCode(PHONE_CODE, cb, deps, async () => true);205206 expect(errors).toHaveLength(0);207 expect(persisted).toHaveLength(1);208 expect(persisted[0].deviceType).toBe("android");209 expect(persisted[0].deviceId).toBe("android-001");210 expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);211 expect(persisted[0].trustSource).toBe("direct");212 expect(persisted[0].wsPort).toBe(49142);213 expect(persisted[0].relayUrl).toBe("wss://relay.peachpasswords.com");214 expect(persisted[0].rendezvousSecretHex).toMatch(/^[0-9a-f]{64}$/);215 expect(completed).toHaveLength(1);216 });217218 it("aborts with sas_mismatch and persists nothing when the user rejects", async () => {219 const { deps, persisted } = makeDeps();220 const { cb, errors } = makeCallbacks();221222 await pairWithCode(PHONE_CODE, cb, deps, async () => false);223224 expect(errors).toEqual(["sas_mismatch"]);225 expect(persisted).toHaveLength(0);226 expect(deps.persistDevice).not.toHaveBeenCalled();227 });228229 it("includes vault_id in the outbound PAIR_IDENTITY (Change 1)", async () => {230 const { deps, sockets } = makeDeps();231 const { cb } = makeCallbacks();232233 await pairWithCode(PHONE_CODE, cb, deps, async () => true);234235 236 237 const ourPairIdentity = dec(sockets[0].sent[1]);238 expect(ourPairIdentity.type).toBe("PAIR_IDENTITY");239 expect(ourPairIdentity.vault_id).toBe(OUR_IDENTITY.vaultId);240 });241242 it("reports vault_locked when local identity is unavailable", async () => {243 const { deps } = makeDeps({244 getLocalIdentity: vi.fn(async () => { throw new Error("vault_locked"); }),245 });246 const { cb, errors } = makeCallbacks();247248 await pairWithCode(PHONE_CODE, cb, deps, async () => true);249250 expect(errors).toEqual(["vault_locked"]);251 expect(deps.openSocket).not.toHaveBeenCalled();252 });253});254255256257258function makePhoneDeps(deviceType: string | undefined = "android") {259 const identity: Record<string, unknown> = {260 type: "PAIR_IDENTITY",261 device_id: "android-001",262 device_name: "Pixel 8",263 long_term_public_key_hex: PHONE_LONGTERM_HEX,264 ws_port: 49142,265 relay_url: "wss://relay.peachpasswords.com",266 };267 identity.device_type = deviceType;268 return makeDeps({269 openSocket: vi.fn(async () => makeFakeSocket([270 enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),271 enc(identity),272 ]).sock),273 });274}275276describe("pairWithCode — pairing code", () => {277 it("dials the address in the code", async () => {278 const { deps } = makePhoneDeps();279 const { cb, errors } = makeCallbacks();280281 await pairWithCode(PHONE_CODE, cb, deps, async () => true);282283 expect(errors).toHaveLength(0);284 expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);285 });286287 it("presents the token as hex in the plaintext hello, not a 6-digit code", async () => {288 const sockets: ReturnType<typeof makeFakeSocket>[] = [];289 const { deps } = makeDeps({290 openSocket: vi.fn(async () => {291 const s = makeFakeSocket([292 enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),293 enc({294 type: "PAIR_IDENTITY",295 device_id: "android-001",296 device_name: "Pixel 8",297 device_type: "android",298 long_term_public_key_hex: PHONE_LONGTERM_HEX,299 ws_port: 49142,300 relay_url: "wss://relay.peachpasswords.com",301 }),302 ]);303 sockets.push(s);304 return s.sock;305 }),306 });307 const { cb } = makeCallbacks();308309 await pairWithCode(PHONE_CODE, cb, deps, async () => true);310311 const hello = dec(sockets[0].sent[0]);312 expect(hello.type).toBe("PEACH_HELLO");313 expect(hello.token).toBe("deadbeef");314 expect(hello.code).toBeUndefined();315 });316317 it("persists the phone with the device type it announced", async () => {318 const { deps, persisted } = makePhoneDeps();319 const { cb, completed } = makeCallbacks();320321 await pairWithCode(PHONE_CODE, cb, deps, async () => true);322323 expect(persisted).toHaveLength(1);324 expect(persisted[0].deviceType).toBe("android");325 expect(persisted[0].deviceId).toBe("android-001");326 expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);327 expect(persisted[0].trustSource).toBe("direct");328 expect(persisted[0].wsPort).toBe(49142);329 expect(completed).toHaveLength(1);330 });331332 it("rejects a peer that omits its device type", async () => {333 const { deps, persisted } = makePhoneDeps("");334 const { cb, errors } = makeCallbacks();335336 await pairWithCode(PHONE_CODE, cb, deps, async () => true);337338 expect(errors).toEqual(["handshake_failed"]);339 expect(persisted).toHaveLength(0);340 });341342 it("caches the coded IP for the next sync round", async () => {343 const cacheLastKnownIp = vi.fn(async () => undefined);344 const { deps } = makePhoneDeps();345 deps.cacheLastKnownIp = cacheLastKnownIp;346 const { cb } = makeCallbacks();347348 await pairWithCode(PHONE_CODE, cb, deps, async () => true);349350 expect(cacheLastKnownIp).toHaveBeenCalledWith(PHONE_LONGTERM_HEX, "192.168.4.77");351 });352353 it("reports no_host_found when the coded endpoint refuses the connection", async () => {354 const { deps } = makeDeps({355 openSocket: vi.fn(async () => { throw new Error("ECONNREFUSED"); }),356 });357 const { cb, errors } = makeCallbacks();358359 await pairWithCode(PHONE_CODE, cb, deps, async () => true);360361 expect(errors).toEqual(["no_host_found"]);362 expect(deps.persistDevice).not.toHaveBeenCalled();363 });364365 it("still aborts on SAS rejection — the token is not the security boundary", async () => {366 const { deps, persisted } = makePhoneDeps();367 const { cb, errors } = makeCallbacks();368369 await pairWithCode(PHONE_CODE, cb, deps, async () => false);370371 expect(errors).toEqual(["sas_mismatch"]);372 expect(persisted).toHaveLength(0);373 });374});