// Code pairing — the PAIR_IDENTITY half over an address-bearing pairing code. // // Driven entirely through the dependency-injection seam (CodePairingDeps and a // fake PairingSocket), so no real WebSocket or Noise crypto runs — except the // SAS KAT, which calls the REAL `derivePairingSAS` to pin cross-implementation // agreement with the peer. import { beforeEach, describe, expect, it, vi } from "vitest"; import { pairWithCode, type CodePairingDeps, type CodePairingCallbacks, } from "./code-pairing"; import type { PairingSocket } from "../shared/pairing-dial"; // REAL SAS implementation — reused, not re-derived (ground truth §5). import { derivePairingSAS } from "../shared/pairing-handshake"; import { encodePairingCode } from "../shared/pairing-code"; import type { LocalIdentity } from "../shared/pairing-handshake"; import type { PairedDevice } from "../shared/sync-types"; // ── helpers ──────────────────────────────────────────────────────────────── const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o)); const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b)); const PEER_EPHEMERAL_HEX = "a".repeat(64); // The ACK key is the Noise NK responder static, so the encrypted identity must // repeat exactly this key after the handshake. const PHONE_LONGTERM_HEX = PEER_EPHEMERAL_HEX; const PHONE_TOKEN = Uint8Array.of(0xde, 0xad, 0xbe, 0xef); const PHONE_CODE = encodePairingCode("192.168.4.77", 51555, PHONE_TOKEN); const OUR_IDENTITY: LocalIdentity = { deviceId: "ext-local-001", deviceName: "Firefox on MacBook", deviceType: "extension", longTermPublicKeyHex: "c".repeat(64), signingPublicKeyHex: "d".repeat(64), vaultId: "e".repeat(64), }; const OUR_PRIVATE_KEY = new Uint8Array(32).fill(0x0c); const OUR_PUBLIC_KEY = new Uint8Array(32).fill(0xcc); /** * Scripted fake socket. Captures everything sent, and replays a queued list * of inbound frames. A frame of `null` means "the socket closes here with no * data" — the wrong-code behaviour (no error frame). */ function makeFakeSocket(inbound: Array) { const sent: Uint8Array[] = []; let closed = false; let lastWasClose = false; const queue = [...inbound]; const sock: PairingSocket = { send(bytes) { sent.push(bytes); }, async receive() { const next = queue.shift(); if (next === undefined || next === null) { closed = true; lastWasClose = true; throw new Error("socket closed"); } lastWasClose = false; return next; }, close() { closed = true; }, closedBeforeFrame() { return lastWasClose; }, }; return { sock, sent, isClosed: () => closed }; } /** * A fake Noise session that no-ops encrypt/decrypt (identity transform) so we * can drive the PAIR_IDENTITY exchange without real crypto. We stub * `noiseNKInitiator` via vi.mock below; this is the shape it returns. */ const FAKE_HANDSHAKE_HASH = Uint8Array.from([ 0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0), ]); // Mock the NK initiator: skip the two handshake messages, hand back a session // whose encrypt/decrypt are identity transforms and whose handshakeHash is the // cross-platform KAT prefix (so the SAS test below is end-to-end through the flow). vi.mock("../shared/noise-js", async (orig) => { const actual = await orig(); return { ...actual, noiseNKInitiator: vi.fn(async (_responderStatic, _send, _receive) => ({ encrypt: (pt: Uint8Array) => pt, decrypt: (ct: Uint8Array) => ct, handshakeHash: FAKE_HANDSHAKE_HASH, close: vi.fn(), })), performInitiatorAuth: vi.fn(async () => undefined), }; }); function makeDeps(overrides: Partial = {}): { deps: CodePairingDeps; persisted: PairedDevice[]; sockets: ReturnType[]; } { const persisted: PairedDevice[] = []; const sockets: ReturnType[] = []; const deps: CodePairingDeps = { openSocket: vi.fn(async () => { // default: ACK then a PAIR_IDENTITY response const s = makeFakeSocket([ enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }), enc({ type: "PAIR_IDENTITY", device_id: "android-001", device_name: "Pixel 8", device_type: "android", long_term_public_key_hex: PHONE_LONGTERM_HEX, ws_port: 49142, relay_url: "wss://relay.peachpasswords.com", }), ]); sockets.push(s); return s.sock; }), getLocalIdentity: vi.fn(async () => ({ identity: OUR_IDENTITY, privateKey: OUR_PRIVATE_KEY, publicKey: OUR_PUBLIC_KEY, })), getRelayUrl: vi.fn(async () => "wss://relay.peachpasswords.com"), persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }), ...overrides, }; return { deps, persisted, sockets }; } function makeCallbacks(): { cb: CodePairingCallbacks; sas: { value?: string }; errors: string[]; completed: PairedDevice[]; } { const sas: { value?: string } = {}; const errors: string[] = []; const completed: PairedDevice[] = []; const cb: CodePairingCallbacks = { onSasReady: (s) => { sas.value = s; }, onError: (e) => { errors.push(e); }, onComplete: (d) => { completed.push(d); }, }; return { cb, sas, errors, completed }; } beforeEach(() => { vi.clearAllMocks(); }); // ── specs ──────────────────────────────────────────────────────────────── describe("pairWithCode", () => { it("dials the coded endpoint and sends its token in PEACH_HELLO", async () => { const { deps, sockets } = makeDeps(); const { cb } = makeCallbacks(); await pairWithCode(PHONE_CODE, cb, deps, async () => true); expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555); const firstFrame = dec(sockets[0].sent[0]); expect(firstFrame).toEqual({ type: "PEACH_HELLO", token: "deadbeef" }); }); it("treats a socket close without PEACH_HELLO_ACK as invalid_code", async () => { // The listener sends NO error frame on a wrong/expired code — it SILENTLY // CLOSES the socket and rotates the code. So the // absence of a PEACH_HELLO_ACK *is* the wrong-code signal. (This replaces // the original brief's bogus PEACH_HELLO_ERROR test — no such frame exists.) const closeImmediately = makeFakeSocket([null]); // close, no data const { deps } = makeDeps({ openSocket: vi.fn(async () => closeImmediately.sock), }); const { cb, errors, completed } = makeCallbacks(); await pairWithCode(PHONE_CODE, cb, deps, async () => true); expect(errors).toEqual(["invalid_code"]); expect(completed).toHaveLength(0); expect(deps.persistDevice).not.toHaveBeenCalled(); }); it("derives the SAS via the real derivePairingSAS — KAT 0fbab8cd… → 44907520", () => { // Cross-impl KAT, matched byte-for-byte by peach-android's SASDerivation: // the first eight bytes 0f ba b8 cd 00 00 00 00 read as an unsigned // big-endian integer are 1133421446844907520; mod 1e8 = 44907520. const hash = Uint8Array.from([ 0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0), ]); expect(derivePairingSAS(hash)).toBe("44907520"); }); it("surfaces that same SAS through the pairing flow", async () => { // FAKE_HANDSHAKE_HASH starts 0fbab8cd, so onSasReady must report 44907520. const { deps } = makeDeps(); const { cb, sas } = makeCallbacks(); await pairWithCode(PHONE_CODE, cb, deps, async () => true); expect(sas.value).toBe("44907520"); }); it("persists the phone identity on SAS confirm", async () => { const { deps, persisted } = makeDeps(); const { cb, completed, errors } = makeCallbacks(); await pairWithCode(PHONE_CODE, cb, deps, async () => true); expect(errors).toHaveLength(0); expect(persisted).toHaveLength(1); expect(persisted[0].deviceType).toBe("android"); expect(persisted[0].deviceId).toBe("android-001"); expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX); expect(persisted[0].trustSource).toBe("direct"); expect(persisted[0].wsPort).toBe(49142); expect(persisted[0].relayUrl).toBe("wss://relay.peachpasswords.com"); expect(persisted[0].rendezvousSecretHex).toMatch(/^[0-9a-f]{64}$/); expect(completed).toHaveLength(1); }); it("proves possession before accepting the peer identity", async () => { const { performInitiatorAuth } = await import("../shared/noise-js"); const { deps } = makeDeps(); const { cb, errors } = makeCallbacks(); await pairWithCode(PHONE_CODE, cb, deps, async () => true); expect(errors).toHaveLength(0); expect(performInitiatorAuth).toHaveBeenCalledTimes(1); expect(performInitiatorAuth).toHaveBeenCalledWith( expect.anything(), OUR_PRIVATE_KEY, OUR_PUBLIC_KEY, expect.any(Uint8Array), expect.any(Function), expect.any(Function), ); }); it("rejects a PAIR_IDENTITY that is not bound to the Noise responder key", async () => { const { deps, persisted } = makeDeps({ openSocket: vi.fn(async () => makeFakeSocket([ enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }), enc({ type: "PAIR_IDENTITY", device_id: "attacker", device_name: "Wrong key", device_type: "android", long_term_public_key_hex: "f".repeat(64), ws_port: 49142, }), ]).sock), }); const { cb, errors } = makeCallbacks(); await pairWithCode(PHONE_CODE, cb, deps, async () => true); expect(errors).toEqual(["handshake_failed"]); expect(persisted).toHaveLength(0); }); it("aborts with sas_mismatch and persists nothing when the user rejects", async () => { const { deps, persisted } = makeDeps(); const { cb, errors } = makeCallbacks(); await pairWithCode(PHONE_CODE, cb, deps, async () => false); expect(errors).toEqual(["sas_mismatch"]); expect(persisted).toHaveLength(0); expect(deps.persistDevice).not.toHaveBeenCalled(); }); it("includes vault_id in the outbound PAIR_IDENTITY (Change 1)", async () => { const { deps, sockets } = makeDeps(); const { cb } = makeCallbacks(); await pairWithCode(PHONE_CODE, cb, deps, async () => true); // sent[0] = PEACH_HELLO (plaintext); sent[1] = our PAIR_IDENTITY // (encrypt is identity in the mock, so it decodes directly). const ourPairIdentity = dec(sockets[0].sent[1]); expect(ourPairIdentity.type).toBe("PAIR_IDENTITY"); expect(ourPairIdentity.vault_id).toBe(OUR_IDENTITY.vaultId); }); it("reports vault_locked when local identity is unavailable", async () => { const { deps } = makeDeps({ getLocalIdentity: vi.fn(async () => { throw new Error("vault_locked"); }), }); const { cb, errors } = makeCallbacks(); await pairWithCode(PHONE_CODE, cb, deps, async () => true); expect(errors).toEqual(["vault_locked"]); expect(deps.openSocket).not.toHaveBeenCalled(); }); }); // ── pairing-code path (the phone is the listener) ────────────────────────── /** Deps whose socket replays an Android peer's ACK + PAIR_IDENTITY. */ function makePhoneDeps(deviceType: string | undefined = "android") { const identity: Record = { type: "PAIR_IDENTITY", device_id: "android-001", device_name: "Pixel 8", long_term_public_key_hex: PHONE_LONGTERM_HEX, ws_port: 49142, relay_url: "wss://relay.peachpasswords.com", }; identity.device_type = deviceType; return makeDeps({ openSocket: vi.fn(async () => makeFakeSocket([ enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }), enc(identity), ]).sock), }); } describe("pairWithCode — pairing code", () => { it("dials the address in the code", async () => { const { deps } = makePhoneDeps(); const { cb, errors } = makeCallbacks(); await pairWithCode(PHONE_CODE, cb, deps, async () => true); expect(errors).toHaveLength(0); expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555); }); it("presents the token as hex in the plaintext hello, not a 6-digit code", async () => { const sockets: ReturnType[] = []; const { deps } = makeDeps({ openSocket: vi.fn(async () => { const s = makeFakeSocket([ enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }), enc({ type: "PAIR_IDENTITY", device_id: "android-001", device_name: "Pixel 8", device_type: "android", long_term_public_key_hex: PHONE_LONGTERM_HEX, ws_port: 49142, relay_url: "wss://relay.peachpasswords.com", }), ]); sockets.push(s); return s.sock; }), }); const { cb } = makeCallbacks(); await pairWithCode(PHONE_CODE, cb, deps, async () => true); const hello = dec(sockets[0].sent[0]); expect(hello.type).toBe("PEACH_HELLO"); expect(hello.token).toBe("deadbeef"); expect(hello.code).toBeUndefined(); }); it("persists the phone with the device type it announced", async () => { const { deps, persisted } = makePhoneDeps(); const { cb, completed } = makeCallbacks(); await pairWithCode(PHONE_CODE, cb, deps, async () => true); expect(persisted).toHaveLength(1); expect(persisted[0].deviceType).toBe("android"); expect(persisted[0].deviceId).toBe("android-001"); expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX); expect(persisted[0].trustSource).toBe("direct"); expect(persisted[0].wsPort).toBe(49142); expect(completed).toHaveLength(1); }); it("rejects a peer that omits its device type", async () => { const { deps, persisted } = makePhoneDeps(""); const { cb, errors } = makeCallbacks(); await pairWithCode(PHONE_CODE, cb, deps, async () => true); expect(errors).toEqual(["handshake_failed"]); expect(persisted).toHaveLength(0); }); it("caches the coded IP for the next sync round", async () => { const cacheLastKnownIp = vi.fn(async () => undefined); const { deps } = makePhoneDeps(); deps.cacheLastKnownIp = cacheLastKnownIp; const { cb } = makeCallbacks(); await pairWithCode(PHONE_CODE, cb, deps, async () => true); expect(cacheLastKnownIp).toHaveBeenCalledWith(PHONE_LONGTERM_HEX, "192.168.4.77"); }); it("reports no_host_found when the coded endpoint refuses the connection", async () => { const { deps } = makeDeps({ openSocket: vi.fn(async () => { throw new Error("ECONNREFUSED"); }), }); const { cb, errors } = makeCallbacks(); await pairWithCode(PHONE_CODE, cb, deps, async () => true); expect(errors).toEqual(["no_host_found"]); expect(deps.persistDevice).not.toHaveBeenCalled(); }); it("still aborts on SAS rejection — the token is not the security boundary", async () => { const { deps, persisted } = makePhoneDeps(); const { cb, errors } = makeCallbacks(); await pairWithCode(PHONE_CODE, cb, deps, async () => false); expect(errors).toEqual(["sas_mismatch"]); expect(persisted).toHaveLength(0); }); });