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);293031const PHONE_LONGTERM_HEX = PEER_EPHEMERAL_HEX;32const PHONE_TOKEN = Uint8Array.of(0xde, 0xad, 0xbe, 0xef);33const PHONE_CODE = encodePairingCode("192.168.4.77", 51555, PHONE_TOKEN);3435const OUR_IDENTITY: LocalIdentity = {36 deviceId: "ext-local-001",37 deviceName: "Firefox on MacBook",38 deviceType: "extension",39 longTermPublicKeyHex: "c".repeat(64),40 signingPublicKeyHex: "d".repeat(64),41 vaultId: "e".repeat(64),42};43const OUR_PRIVATE_KEY = new Uint8Array(32).fill(0x0c);44const OUR_PUBLIC_KEY = new Uint8Array(32).fill(0xcc);45464748495051function makeFakeSocket(inbound: Array<Uint8Array | null>) {52 const sent: Uint8Array[] = [];53 let closed = false;54 let lastWasClose = false;55 const queue = [...inbound];56 const sock: PairingSocket = {57 send(bytes) { sent.push(bytes); },58 async receive() {59 const next = queue.shift();60 if (next === undefined || next === null) {61 closed = true;62 lastWasClose = true;63 throw new Error("socket closed");64 }65 lastWasClose = false;66 return next;67 },68 close() { closed = true; },69 closedBeforeFrame() { return lastWasClose; },70 };71 return { sock, sent, isClosed: () => closed };72}73747576777879const FAKE_HANDSHAKE_HASH = Uint8Array.from([80 0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),81]);8283848586vi.mock("../shared/noise-js", async (orig) => {87 const actual = await orig<typeof import("../shared/noise-js")>();88 return {89 ...actual,90 noiseNKInitiator: vi.fn(async (_responderStatic, _send, _receive) => ({91 encrypt: (pt: Uint8Array) => pt,92 decrypt: (ct: Uint8Array) => ct,93 handshakeHash: FAKE_HANDSHAKE_HASH,94 })),95 performInitiatorAuth: vi.fn(async () => undefined),96 };97});9899function makeDeps(overrides: Partial<CodePairingDeps> = {}): {100 deps: CodePairingDeps;101 persisted: PairedDevice[];102 sockets: ReturnType<typeof makeFakeSocket>[];103} {104 const persisted: PairedDevice[] = [];105 const sockets: ReturnType<typeof makeFakeSocket>[] = [];106 const deps: CodePairingDeps = {107 openSocket: vi.fn(async () => {108 109 const s = makeFakeSocket([110 enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),111 enc({112 type: "PAIR_IDENTITY",113 device_id: "android-001",114 device_name: "Pixel 8",115 device_type: "android",116 long_term_public_key_hex: PHONE_LONGTERM_HEX,117 ws_port: 49142,118 relay_url: "wss://relay.peachpasswords.com",119 }),120 ]);121 sockets.push(s);122 return s.sock;123 }),124 getLocalIdentity: vi.fn(async () => ({125 identity: OUR_IDENTITY,126 privateKey: OUR_PRIVATE_KEY,127 publicKey: OUR_PUBLIC_KEY,128 })),129 getRelayUrl: vi.fn(async () => "wss://relay.peachpasswords.com"),130 persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }),131 ...overrides,132 };133 return { deps, persisted, sockets };134}135136function makeCallbacks(): {137 cb: CodePairingCallbacks;138 sas: { value?: string };139 errors: string[];140 completed: PairedDevice[];141} {142 const sas: { value?: string } = {};143 const errors: string[] = [];144 const completed: PairedDevice[] = [];145 const cb: CodePairingCallbacks = {146 onSasReady: (s) => { sas.value = s; },147 onError: (e) => { errors.push(e); },148 onComplete: (d) => { completed.push(d); },149 };150 return { cb, sas, errors, completed };151}152153beforeEach(() => {154 vi.clearAllMocks();155});156157158159describe("pairWithCode", () => {160 it("dials the coded endpoint and sends its token in PEACH_HELLO", async () => {161 const { deps, sockets } = makeDeps();162 const { cb } = makeCallbacks();163164 await pairWithCode(PHONE_CODE, cb, deps, async () => true);165166 expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);167 const firstFrame = dec(sockets[0].sent[0]);168 expect(firstFrame).toEqual({ type: "PEACH_HELLO", token: "deadbeef" });169 });170171 it("treats a socket close without PEACH_HELLO_ACK as invalid_code", async () => {172 173 174 175 176 const closeImmediately = makeFakeSocket([null]); 177 const { deps } = makeDeps({178 openSocket: vi.fn(async () => closeImmediately.sock),179 });180 const { cb, errors, completed } = makeCallbacks();181182 await pairWithCode(PHONE_CODE, cb, deps, async () => true);183184 expect(errors).toEqual(["invalid_code"]);185 expect(completed).toHaveLength(0);186 expect(deps.persistDevice).not.toHaveBeenCalled();187 });188189 it("derives the SAS via the real derivePairingSAS — KAT 0fbab8cd… → 895245", () => {190 191 192 193 const hash = Uint8Array.from([194 0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),195 ]);196 expect(derivePairingSAS(hash)).toBe("895245");197 });198199 it("surfaces that same SAS through the pairing flow", async () => {200 201 const { deps } = makeDeps();202 const { cb, sas } = makeCallbacks();203204 await pairWithCode(PHONE_CODE, cb, deps, async () => true);205206 expect(sas.value).toBe("895245");207 });208209 it("persists the phone identity on SAS confirm", async () => {210 const { deps, persisted } = makeDeps();211 const { cb, completed, errors } = makeCallbacks();212213 await pairWithCode(PHONE_CODE, cb, deps, async () => true);214215 expect(errors).toHaveLength(0);216 expect(persisted).toHaveLength(1);217 expect(persisted[0].deviceType).toBe("android");218 expect(persisted[0].deviceId).toBe("android-001");219 expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);220 expect(persisted[0].trustSource).toBe("direct");221 expect(persisted[0].wsPort).toBe(49142);222 expect(persisted[0].relayUrl).toBe("wss://relay.peachpasswords.com");223 expect(persisted[0].rendezvousSecretHex).toMatch(/^[0-9a-f]{64}$/);224 expect(completed).toHaveLength(1);225 });226227 it("proves possession before accepting the peer identity", async () => {228 const { performInitiatorAuth } = await import("../shared/noise-js");229 const { deps } = makeDeps();230 const { cb, errors } = makeCallbacks();231232 await pairWithCode(PHONE_CODE, cb, deps, async () => true);233234 expect(errors).toHaveLength(0);235 expect(performInitiatorAuth).toHaveBeenCalledTimes(1);236 expect(performInitiatorAuth).toHaveBeenCalledWith(237 expect.anything(),238 OUR_PRIVATE_KEY,239 OUR_PUBLIC_KEY,240 expect.any(Uint8Array),241 expect.any(Function),242 expect.any(Function),243 );244 });245246 it("rejects a PAIR_IDENTITY that is not bound to the Noise responder key", async () => {247 const { deps, persisted } = makeDeps({248 openSocket: vi.fn(async () => makeFakeSocket([249 enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),250 enc({251 type: "PAIR_IDENTITY",252 device_id: "attacker",253 device_name: "Wrong key",254 device_type: "android",255 long_term_public_key_hex: "f".repeat(64),256 ws_port: 49142,257 }),258 ]).sock),259 });260 const { cb, errors } = makeCallbacks();261262 await pairWithCode(PHONE_CODE, cb, deps, async () => true);263264 expect(errors).toEqual(["handshake_failed"]);265 expect(persisted).toHaveLength(0);266 });267268 it("aborts with sas_mismatch and persists nothing when the user rejects", async () => {269 const { deps, persisted } = makeDeps();270 const { cb, errors } = makeCallbacks();271272 await pairWithCode(PHONE_CODE, cb, deps, async () => false);273274 expect(errors).toEqual(["sas_mismatch"]);275 expect(persisted).toHaveLength(0);276 expect(deps.persistDevice).not.toHaveBeenCalled();277 });278279 it("includes vault_id in the outbound PAIR_IDENTITY (Change 1)", async () => {280 const { deps, sockets } = makeDeps();281 const { cb } = makeCallbacks();282283 await pairWithCode(PHONE_CODE, cb, deps, async () => true);284285 286 287 const ourPairIdentity = dec(sockets[0].sent[1]);288 expect(ourPairIdentity.type).toBe("PAIR_IDENTITY");289 expect(ourPairIdentity.vault_id).toBe(OUR_IDENTITY.vaultId);290 });291292 it("reports vault_locked when local identity is unavailable", async () => {293 const { deps } = makeDeps({294 getLocalIdentity: vi.fn(async () => { throw new Error("vault_locked"); }),295 });296 const { cb, errors } = makeCallbacks();297298 await pairWithCode(PHONE_CODE, cb, deps, async () => true);299300 expect(errors).toEqual(["vault_locked"]);301 expect(deps.openSocket).not.toHaveBeenCalled();302 });303});304305306307308function makePhoneDeps(deviceType: string | undefined = "android") {309 const identity: Record<string, unknown> = {310 type: "PAIR_IDENTITY",311 device_id: "android-001",312 device_name: "Pixel 8",313 long_term_public_key_hex: PHONE_LONGTERM_HEX,314 ws_port: 49142,315 relay_url: "wss://relay.peachpasswords.com",316 };317 identity.device_type = deviceType;318 return makeDeps({319 openSocket: vi.fn(async () => makeFakeSocket([320 enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),321 enc(identity),322 ]).sock),323 });324}325326describe("pairWithCode — pairing code", () => {327 it("dials the address in the code", async () => {328 const { deps } = makePhoneDeps();329 const { cb, errors } = makeCallbacks();330331 await pairWithCode(PHONE_CODE, cb, deps, async () => true);332333 expect(errors).toHaveLength(0);334 expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);335 });336337 it("presents the token as hex in the plaintext hello, not a 6-digit code", async () => {338 const sockets: ReturnType<typeof makeFakeSocket>[] = [];339 const { deps } = makeDeps({340 openSocket: vi.fn(async () => {341 const s = makeFakeSocket([342 enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),343 enc({344 type: "PAIR_IDENTITY",345 device_id: "android-001",346 device_name: "Pixel 8",347 device_type: "android",348 long_term_public_key_hex: PHONE_LONGTERM_HEX,349 ws_port: 49142,350 relay_url: "wss://relay.peachpasswords.com",351 }),352 ]);353 sockets.push(s);354 return s.sock;355 }),356 });357 const { cb } = makeCallbacks();358359 await pairWithCode(PHONE_CODE, cb, deps, async () => true);360361 const hello = dec(sockets[0].sent[0]);362 expect(hello.type).toBe("PEACH_HELLO");363 expect(hello.token).toBe("deadbeef");364 expect(hello.code).toBeUndefined();365 });366367 it("persists the phone with the device type it announced", async () => {368 const { deps, persisted } = makePhoneDeps();369 const { cb, completed } = makeCallbacks();370371 await pairWithCode(PHONE_CODE, cb, deps, async () => true);372373 expect(persisted).toHaveLength(1);374 expect(persisted[0].deviceType).toBe("android");375 expect(persisted[0].deviceId).toBe("android-001");376 expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);377 expect(persisted[0].trustSource).toBe("direct");378 expect(persisted[0].wsPort).toBe(49142);379 expect(completed).toHaveLength(1);380 });381382 it("rejects a peer that omits its device type", async () => {383 const { deps, persisted } = makePhoneDeps("");384 const { cb, errors } = makeCallbacks();385386 await pairWithCode(PHONE_CODE, cb, deps, async () => true);387388 expect(errors).toEqual(["handshake_failed"]);389 expect(persisted).toHaveLength(0);390 });391392 it("caches the coded IP for the next sync round", async () => {393 const cacheLastKnownIp = vi.fn(async () => undefined);394 const { deps } = makePhoneDeps();395 deps.cacheLastKnownIp = cacheLastKnownIp;396 const { cb } = makeCallbacks();397398 await pairWithCode(PHONE_CODE, cb, deps, async () => true);399400 expect(cacheLastKnownIp).toHaveBeenCalledWith(PHONE_LONGTERM_HEX, "192.168.4.77");401 });402403 it("reports no_host_found when the coded endpoint refuses the connection", async () => {404 const { deps } = makeDeps({405 openSocket: vi.fn(async () => { throw new Error("ECONNREFUSED"); }),406 });407 const { cb, errors } = makeCallbacks();408409 await pairWithCode(PHONE_CODE, cb, deps, async () => true);410411 expect(errors).toEqual(["no_host_found"]);412 expect(deps.persistDevice).not.toHaveBeenCalled();413 });414415 it("still aborts on SAS rejection — the token is not the security boundary", async () => {416 const { deps, persisted } = makePhoneDeps();417 const { cb, errors } = makeCallbacks();418419 await pairWithCode(PHONE_CODE, cb, deps, async () => false);420421 expect(errors).toEqual(["sas_mismatch"]);422 expect(persisted).toHaveLength(0);423 });424});