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 close: vi.fn(),95 })),96 performInitiatorAuth: vi.fn(async () => undefined),97 };98});99100function makeDeps(overrides: Partial<CodePairingDeps> = {}): {101 deps: CodePairingDeps;102 persisted: PairedDevice[];103 sockets: ReturnType<typeof makeFakeSocket>[];104} {105 const persisted: PairedDevice[] = [];106 const sockets: ReturnType<typeof makeFakeSocket>[] = [];107 const deps: CodePairingDeps = {108 openSocket: vi.fn(async () => {109 110 const s = makeFakeSocket([111 enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),112 enc({113 type: "PAIR_IDENTITY",114 device_id: "android-001",115 device_name: "Pixel 8",116 device_type: "android",117 long_term_public_key_hex: PHONE_LONGTERM_HEX,118 ws_port: 49142,119 relay_url: "wss://relay.peachpasswords.com",120 }),121 ]);122 sockets.push(s);123 return s.sock;124 }),125 getLocalIdentity: vi.fn(async () => ({126 identity: OUR_IDENTITY,127 privateKey: OUR_PRIVATE_KEY,128 publicKey: OUR_PUBLIC_KEY,129 })),130 getRelayUrl: vi.fn(async () => "wss://relay.peachpasswords.com"),131 persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }),132 ...overrides,133 };134 return { deps, persisted, sockets };135}136137function makeCallbacks(): {138 cb: CodePairingCallbacks;139 sas: { value?: string };140 errors: string[];141 completed: PairedDevice[];142} {143 const sas: { value?: string } = {};144 const errors: string[] = [];145 const completed: PairedDevice[] = [];146 const cb: CodePairingCallbacks = {147 onSasReady: (s) => { sas.value = s; },148 onError: (e) => { errors.push(e); },149 onComplete: (d) => { completed.push(d); },150 };151 return { cb, sas, errors, completed };152}153154beforeEach(() => {155 vi.clearAllMocks();156});157158159160describe("pairWithCode", () => {161 it("dials the coded endpoint and sends its token in PEACH_HELLO", async () => {162 const { deps, sockets } = makeDeps();163 const { cb } = makeCallbacks();164165 await pairWithCode(PHONE_CODE, cb, deps, async () => true);166167 expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);168 const firstFrame = dec(sockets[0].sent[0]);169 expect(firstFrame).toEqual({ type: "PEACH_HELLO", token: "deadbeef" });170 });171172 it("treats a socket close without PEACH_HELLO_ACK as invalid_code", async () => {173 174 175 176 177 const closeImmediately = makeFakeSocket([null]); 178 const { deps } = makeDeps({179 openSocket: vi.fn(async () => closeImmediately.sock),180 });181 const { cb, errors, completed } = makeCallbacks();182183 await pairWithCode(PHONE_CODE, cb, deps, async () => true);184185 expect(errors).toEqual(["invalid_code"]);186 expect(completed).toHaveLength(0);187 expect(deps.persistDevice).not.toHaveBeenCalled();188 });189190 it("derives the SAS via the real derivePairingSAS — KAT 0fbab8cd… → 895245", () => {191 192 193 194 const hash = Uint8Array.from([195 0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),196 ]);197 expect(derivePairingSAS(hash)).toBe("895245");198 });199200 it("surfaces that same SAS through the pairing flow", async () => {201 202 const { deps } = makeDeps();203 const { cb, sas } = makeCallbacks();204205 await pairWithCode(PHONE_CODE, cb, deps, async () => true);206207 expect(sas.value).toBe("895245");208 });209210 it("persists the phone identity on SAS confirm", async () => {211 const { deps, persisted } = makeDeps();212 const { cb, completed, errors } = makeCallbacks();213214 await pairWithCode(PHONE_CODE, cb, deps, async () => true);215216 expect(errors).toHaveLength(0);217 expect(persisted).toHaveLength(1);218 expect(persisted[0].deviceType).toBe("android");219 expect(persisted[0].deviceId).toBe("android-001");220 expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);221 expect(persisted[0].trustSource).toBe("direct");222 expect(persisted[0].wsPort).toBe(49142);223 expect(persisted[0].relayUrl).toBe("wss://relay.peachpasswords.com");224 expect(persisted[0].rendezvousSecretHex).toMatch(/^[0-9a-f]{64}$/);225 expect(completed).toHaveLength(1);226 });227228 it("proves possession before accepting the peer identity", async () => {229 const { performInitiatorAuth } = await import("../shared/noise-js");230 const { deps } = makeDeps();231 const { cb, errors } = makeCallbacks();232233 await pairWithCode(PHONE_CODE, cb, deps, async () => true);234235 expect(errors).toHaveLength(0);236 expect(performInitiatorAuth).toHaveBeenCalledTimes(1);237 expect(performInitiatorAuth).toHaveBeenCalledWith(238 expect.anything(),239 OUR_PRIVATE_KEY,240 OUR_PUBLIC_KEY,241 expect.any(Uint8Array),242 expect.any(Function),243 expect.any(Function),244 );245 });246247 it("rejects a PAIR_IDENTITY that is not bound to the Noise responder key", async () => {248 const { deps, persisted } = makeDeps({249 openSocket: vi.fn(async () => makeFakeSocket([250 enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),251 enc({252 type: "PAIR_IDENTITY",253 device_id: "attacker",254 device_name: "Wrong key",255 device_type: "android",256 long_term_public_key_hex: "f".repeat(64),257 ws_port: 49142,258 }),259 ]).sock),260 });261 const { cb, errors } = makeCallbacks();262263 await pairWithCode(PHONE_CODE, cb, deps, async () => true);264265 expect(errors).toEqual(["handshake_failed"]);266 expect(persisted).toHaveLength(0);267 });268269 it("aborts with sas_mismatch and persists nothing when the user rejects", async () => {270 const { deps, persisted } = makeDeps();271 const { cb, errors } = makeCallbacks();272273 await pairWithCode(PHONE_CODE, cb, deps, async () => false);274275 expect(errors).toEqual(["sas_mismatch"]);276 expect(persisted).toHaveLength(0);277 expect(deps.persistDevice).not.toHaveBeenCalled();278 });279280 it("includes vault_id in the outbound PAIR_IDENTITY (Change 1)", async () => {281 const { deps, sockets } = makeDeps();282 const { cb } = makeCallbacks();283284 await pairWithCode(PHONE_CODE, cb, deps, async () => true);285286 287 288 const ourPairIdentity = dec(sockets[0].sent[1]);289 expect(ourPairIdentity.type).toBe("PAIR_IDENTITY");290 expect(ourPairIdentity.vault_id).toBe(OUR_IDENTITY.vaultId);291 });292293 it("reports vault_locked when local identity is unavailable", async () => {294 const { deps } = makeDeps({295 getLocalIdentity: vi.fn(async () => { throw new Error("vault_locked"); }),296 });297 const { cb, errors } = makeCallbacks();298299 await pairWithCode(PHONE_CODE, cb, deps, async () => true);300301 expect(errors).toEqual(["vault_locked"]);302 expect(deps.openSocket).not.toHaveBeenCalled();303 });304});305306307308309function makePhoneDeps(deviceType: string | undefined = "android") {310 const identity: Record<string, unknown> = {311 type: "PAIR_IDENTITY",312 device_id: "android-001",313 device_name: "Pixel 8",314 long_term_public_key_hex: PHONE_LONGTERM_HEX,315 ws_port: 49142,316 relay_url: "wss://relay.peachpasswords.com",317 };318 identity.device_type = deviceType;319 return makeDeps({320 openSocket: vi.fn(async () => makeFakeSocket([321 enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),322 enc(identity),323 ]).sock),324 });325}326327describe("pairWithCode — pairing code", () => {328 it("dials the address in the code", async () => {329 const { deps } = makePhoneDeps();330 const { cb, errors } = makeCallbacks();331332 await pairWithCode(PHONE_CODE, cb, deps, async () => true);333334 expect(errors).toHaveLength(0);335 expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);336 });337338 it("presents the token as hex in the plaintext hello, not a 6-digit code", async () => {339 const sockets: ReturnType<typeof makeFakeSocket>[] = [];340 const { deps } = makeDeps({341 openSocket: vi.fn(async () => {342 const s = makeFakeSocket([343 enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),344 enc({345 type: "PAIR_IDENTITY",346 device_id: "android-001",347 device_name: "Pixel 8",348 device_type: "android",349 long_term_public_key_hex: PHONE_LONGTERM_HEX,350 ws_port: 49142,351 relay_url: "wss://relay.peachpasswords.com",352 }),353 ]);354 sockets.push(s);355 return s.sock;356 }),357 });358 const { cb } = makeCallbacks();359360 await pairWithCode(PHONE_CODE, cb, deps, async () => true);361362 const hello = dec(sockets[0].sent[0]);363 expect(hello.type).toBe("PEACH_HELLO");364 expect(hello.token).toBe("deadbeef");365 expect(hello.code).toBeUndefined();366 });367368 it("persists the phone with the device type it announced", async () => {369 const { deps, persisted } = makePhoneDeps();370 const { cb, completed } = makeCallbacks();371372 await pairWithCode(PHONE_CODE, cb, deps, async () => true);373374 expect(persisted).toHaveLength(1);375 expect(persisted[0].deviceType).toBe("android");376 expect(persisted[0].deviceId).toBe("android-001");377 expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);378 expect(persisted[0].trustSource).toBe("direct");379 expect(persisted[0].wsPort).toBe(49142);380 expect(completed).toHaveLength(1);381 });382383 it("rejects a peer that omits its device type", async () => {384 const { deps, persisted } = makePhoneDeps("");385 const { cb, errors } = makeCallbacks();386387 await pairWithCode(PHONE_CODE, cb, deps, async () => true);388389 expect(errors).toEqual(["handshake_failed"]);390 expect(persisted).toHaveLength(0);391 });392393 it("caches the coded IP for the next sync round", async () => {394 const cacheLastKnownIp = vi.fn(async () => undefined);395 const { deps } = makePhoneDeps();396 deps.cacheLastKnownIp = cacheLastKnownIp;397 const { cb } = makeCallbacks();398399 await pairWithCode(PHONE_CODE, cb, deps, async () => true);400401 expect(cacheLastKnownIp).toHaveBeenCalledWith(PHONE_LONGTERM_HEX, "192.168.4.77");402 });403404 it("reports no_host_found when the coded endpoint refuses the connection", async () => {405 const { deps } = makeDeps({406 openSocket: vi.fn(async () => { throw new Error("ECONNREFUSED"); }),407 });408 const { cb, errors } = makeCallbacks();409410 await pairWithCode(PHONE_CODE, cb, deps, async () => true);411412 expect(errors).toEqual(["no_host_found"]);413 expect(deps.persistDevice).not.toHaveBeenCalled();414 });415416 it("still aborts on SAS rejection — the token is not the security boundary", async () => {417 const { deps, persisted } = makePhoneDeps();418 const { cb, errors } = makeCallbacks();419420 await pairWithCode(PHONE_CODE, cb, deps, async () => false);421422 expect(errors).toEqual(["sas_mismatch"]);423 expect(persisted).toHaveLength(0);424 });425});