code-pairing.test.ts

13.8 KB · raw

1// Code pairing — the PAIR_IDENTITY half, over both code forms.2//3// Driven entirely through the dependency-injection seam (CodePairingDeps and a4// fake PairingSocket), so no real WebSocket or Noise crypto runs — except the5// SAS KAT, which calls the REAL `derivePairingSAS` to pin cross-implementation6// agreement with the peer.78import { beforeEach, describe, expect, it, vi } from "vitest";910import {11  pairWithCode,12  type CodePairingDeps,13  type CodePairingCallbacks,14} from "./code-pairing";15import { PAIRING_PORT, type PairingSocket } from "../shared/pairing-dial";1617// REAL SAS implementation — reused, not re-derived (ground truth §5).18import { derivePairingSAS } from "../shared/chirp-handshake";19import { encodePairingCode } from "../shared/pairing-code";20import type { LocalIdentity } from "../shared/chirp-handshake";21import type { PairedDevice } from "../shared/sync-types";2223// ── helpers ────────────────────────────────────────────────────────────────2425const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o));26const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b));2728const HELPER_EPHEMERAL_HEX = "a".repeat(64);29const HELPER_LONGTERM_HEX = "b".repeat(64);3031const OUR_IDENTITY: LocalIdentity = {32  deviceId: "ext-local-001",33  deviceName: "Firefox on MacBook",34  deviceType: "extension",35  longTermPublicKeyHex: "c".repeat(64),36  signingPublicKeyHex: "d".repeat(64),37  vaultId: "e".repeat(64),38};3940/**41 * Scripted fake socket. Captures everything sent, and replays a queued list42 * of inbound frames. A frame of `null` means "the socket closes here with no43 * data" — the peach-sync wrong-code behaviour (no error frame).44 */45function makeFakeSocket(inbound: Array<Uint8Array | null>) {46  const sent: Uint8Array[] = [];47  let closed = false;48  let lastWasClose = false;49  const queue = [...inbound];50  const sock: PairingSocket = {51    send(bytes) { sent.push(bytes); },52    async receive() {53      const next = queue.shift();54      if (next === undefined || next === null) {55        closed = true;56        lastWasClose = true;57        throw new Error("socket closed");58      }59      lastWasClose = false;60      return next;61    },62    close() { closed = true; },63    closedBeforeFrame() { return lastWasClose; },64  };65  return { sock, sent, isClosed: () => closed };66}6768/**69 * A fake Noise session that no-ops encrypt/decrypt (identity transform) so we70 * can drive the PAIR_IDENTITY exchange without real crypto. We stub71 * `noiseNKInitiator` via vi.mock below; this is the shape it returns.72 */73const FAKE_HANDSHAKE_HASH = Uint8Array.from([74  0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),75]);7677// Mock the NK initiator: skip the two handshake messages, hand back a session78// whose encrypt/decrypt are identity transforms and whose handshakeHash is the79// peach-sync KAT prefix (so the SAS test below is end-to-end through the flow).80vi.mock("../shared/noise-js", async (orig) => {81  const actual = await orig<typeof import("../shared/noise-js")>();82  return {83    ...actual,84    noiseNKInitiator: vi.fn(async (_responderStatic, _send, _receive) => ({85      encrypt: (pt: Uint8Array) => pt,86      decrypt: (ct: Uint8Array) => ct,87      handshakeHash: FAKE_HANDSHAKE_HASH,88    })),89  };90});9192function makeDeps(overrides: Partial<CodePairingDeps> = {}): {93  deps: CodePairingDeps;94  persisted: PairedDevice[];95  sockets: ReturnType<typeof makeFakeSocket>[];96} {97  const persisted: PairedDevice[] = [];98  const sockets: ReturnType<typeof makeFakeSocket>[] = [];99  const deps: CodePairingDeps = {100    findHost: vi.fn(async () => "192.168.1.50"),101    openSocket: vi.fn(async () => {102      // default: ACK then a PAIR_IDENTITY response103      const s = makeFakeSocket([104        enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),105        enc({106          type: "PAIR_IDENTITY",107          device_id: "desktop-001",108          device_name: "June's iMac",109          device_type: "desktop",110          long_term_public_key_hex: HELPER_LONGTERM_HEX,111          ws_port: 8765,112        }),113      ]);114      sockets.push(s);115      return s.sock;116    }),117    getLocalIdentity: vi.fn(async () => OUR_IDENTITY),118    persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }),119    ...overrides,120  };121  return { deps, persisted, sockets };122}123124function makeCallbacks(): {125  cb: CodePairingCallbacks;126  sas: { value?: string };127  errors: string[];128  completed: PairedDevice[];129} {130  const sas: { value?: string } = {};131  const errors: string[] = [];132  const completed: PairedDevice[] = [];133  const cb: CodePairingCallbacks = {134    onSasReady: (s) => { sas.value = s; },135    onError: (e) => { errors.push(e); },136    onComplete: (d) => { completed.push(d); },137  };138  return { cb, sas, errors, completed };139}140141beforeEach(() => {142  vi.clearAllMocks();143});144145// ── specs ────────────────────────────────────────────────────────────────146147describe("pairWithCode", () => {148  it("sends PEACH_HELLO with a 6-digit zero-padded code to port 49142", async () => {149    const { deps, sockets } = makeDeps();150    const { cb } = makeCallbacks();151152    await pairWithCode("123", cb, deps, async () => true);153154    expect(deps.openSocket).toHaveBeenCalledWith("192.168.1.50", PAIRING_PORT);155    const firstFrame = dec(sockets[0].sent[0]);156    expect(firstFrame.type).toBe("PEACH_HELLO");157    // "123" must be normalised to exactly 6 ASCII digits, zero-padded.158    expect(firstFrame.code).toBe("000123");159    expect(firstFrame.code).toMatch(/^\d{6}$/);160  });161162  it("treats a socket close without PEACH_HELLO_ACK as invalid_code", async () => {163    // peach-sync sends NO error frame on a wrong/expired code — it SILENTLY164    // CLOSES the socket and rotates the code (pairing.zig:117,119-125). So the165    // absence of a PEACH_HELLO_ACK *is* the wrong-code signal. (This replaces166    // the original brief's bogus PEACH_HELLO_ERROR test — no such frame exists.)167    const closeImmediately = makeFakeSocket([null]); // close, no data168    const { deps } = makeDeps({169      openSocket: vi.fn(async () => closeImmediately.sock),170    });171    const { cb, errors, completed } = makeCallbacks();172173    await pairWithCode("654321", 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    // Cross-impl KAT pinned in peach-sync (pairing.zig:275-279): the handshake182    // hash beginning 0f ba b8 cd yields SAS "895245". 0x0fbab8cd = 263895245;183    // 263895245 mod 1_000_000 = 895245.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    // FAKE_HANDSHAKE_HASH starts 0fbab8cd, so onSasReady must report 895245.192    const { deps } = makeDeps();193    const { cb, sas } = makeCallbacks();194195    await pairWithCode("000123", cb, deps, async () => true);196197    expect(sas.value).toBe("895245");198  });199200  it("persists the device with deviceType 'desktop' on SAS confirm", async () => {201    const { deps, persisted } = makeDeps();202    const { cb, completed, errors } = makeCallbacks();203204    await pairWithCode("000123", cb, deps, async () => true);205206    expect(errors).toHaveLength(0);207    expect(persisted).toHaveLength(1);208    expect(persisted[0].deviceType).toBe("desktop");209    expect(persisted[0].deviceId).toBe("desktop-001");210    expect(persisted[0].longTermPublicKeyHex).toBe(HELPER_LONGTERM_HEX);211    expect(persisted[0].trustSource).toBe("direct");212    expect(persisted[0].wsPort).toBe(8765);213    expect(completed).toHaveLength(1);214  });215216  it("aborts with sas_mismatch and persists nothing when the user rejects", async () => {217    const { deps, persisted } = makeDeps();218    const { cb, errors } = makeCallbacks();219220    await pairWithCode("000123", cb, deps, async () => false);221222    expect(errors).toEqual(["sas_mismatch"]);223    expect(persisted).toHaveLength(0);224    expect(deps.persistDevice).not.toHaveBeenCalled();225  });226227  it("includes vault_id in the outbound PAIR_IDENTITY (Change 1)", async () => {228    const { deps, sockets } = makeDeps();229    const { cb } = makeCallbacks();230231    await pairWithCode("000123", cb, deps, async () => true);232233    // sent[0] = PEACH_HELLO (plaintext); sent[1] = our PAIR_IDENTITY234    // (encrypt is identity in the mock, so it decodes directly).235    const ourPairIdentity = dec(sockets[0].sent[1]);236    expect(ourPairIdentity.type).toBe("PAIR_IDENTITY");237    expect(ourPairIdentity.vault_id).toBe(OUR_IDENTITY.vaultId);238  });239240  it("reports no_host_found when the probe returns null", async () => {241    const { deps } = makeDeps({ findHost: vi.fn(async () => null) });242    const { cb, errors } = makeCallbacks();243244    await pairWithCode("000123", cb, deps, async () => true);245246    expect(errors).toEqual(["no_host_found"]);247    expect(deps.openSocket).not.toHaveBeenCalled();248  });249250  it("reports vault_locked when local identity is unavailable", async () => {251    const { deps } = makeDeps({252      getLocalIdentity: vi.fn(async () => { throw new Error("vault_locked"); }),253    });254    const { cb, errors } = makeCallbacks();255256    await pairWithCode("000123", cb, deps, async () => true);257258    expect(errors).toEqual(["vault_locked"]);259    expect(deps.findHost).not.toHaveBeenCalled();260  });261});262263// ── pairing-code path (the phone is the listener) ──────────────────────────264265const PHONE_TOKEN = Uint8Array.of(0xde, 0xad, 0xbe, 0xef);266const PHONE_CODE = encodePairingCode("192.168.4.77", 51555, PHONE_TOKEN);267const PHONE_LONGTERM_HEX = "f".repeat(64);268269/** Deps whose socket replays an Android peer's ACK + PAIR_IDENTITY. */270function makePhoneDeps(deviceType: string | undefined = "android") {271  const identity: Record<string, unknown> = {272    type: "PAIR_IDENTITY",273    device_id: "android-001",274    device_name: "Pixel 8",275    long_term_public_key_hex: PHONE_LONGTERM_HEX,276    ws_port: 8765,277  };278  identity.device_type = deviceType;279  return makeDeps({280    openSocket: vi.fn(async () => makeFakeSocket([281      enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),282      enc(identity),283    ]).sock),284  });285}286287describe("pairWithCode — pairing code", () => {288  it("dials the address in the code instead of probing for a host", async () => {289    const { deps } = makePhoneDeps();290    const { cb, errors } = makeCallbacks();291292    await pairWithCode(PHONE_CODE, cb, deps, async () => true);293294    expect(errors).toHaveLength(0);295    expect(deps.findHost).not.toHaveBeenCalled();296    expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);297  });298299  it("presents the token as hex in the plaintext hello, not a 6-digit code", async () => {300    const sockets: ReturnType<typeof makeFakeSocket>[] = [];301    const { deps } = makeDeps({302      openSocket: vi.fn(async () => {303        const s = makeFakeSocket([304          enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),305          enc({306            type: "PAIR_IDENTITY",307            device_id: "android-001",308            device_name: "Pixel 8",309            device_type: "android",310            long_term_public_key_hex: PHONE_LONGTERM_HEX,311            ws_port: 8765,312          }),313        ]);314        sockets.push(s);315        return s.sock;316      }),317    });318    const { cb } = makeCallbacks();319320    await pairWithCode(PHONE_CODE, cb, deps, async () => true);321322    const hello = dec(sockets[0].sent[0]);323    expect(hello.type).toBe("PEACH_HELLO");324    expect(hello.token).toBe("deadbeef");325    expect(hello.code).toBeUndefined();326  });327328  it("persists the phone with the device type it announced", async () => {329    const { deps, persisted } = makePhoneDeps();330    const { cb, completed } = makeCallbacks();331332    await pairWithCode(PHONE_CODE, cb, deps, async () => true);333334    expect(persisted).toHaveLength(1);335    expect(persisted[0].deviceType).toBe("android");336    expect(persisted[0].deviceId).toBe("android-001");337    expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);338    expect(persisted[0].trustSource).toBe("direct");339    expect(persisted[0].wsPort).toBe(8765);340    expect(completed).toHaveLength(1);341  });342343  it("falls back to 'android', never 'desktop', when the peer omits its type", async () => {344    const { deps, persisted } = makePhoneDeps("");345    const { cb } = makeCallbacks();346347    await pairWithCode(PHONE_CODE, cb, deps, async () => true);348349    expect(persisted[0].deviceType).toBe("android");350  });351352  it("caches the coded IP for the next sync round", async () => {353    const cacheLastKnownIp = vi.fn(async () => undefined);354    const { deps } = makePhoneDeps();355    deps.cacheLastKnownIp = cacheLastKnownIp;356    const { cb } = makeCallbacks();357358    await pairWithCode(PHONE_CODE, cb, deps, async () => true);359360    expect(cacheLastKnownIp).toHaveBeenCalledWith(PHONE_LONGTERM_HEX, "192.168.4.77");361  });362363  it("reports no_host_found when the coded endpoint refuses the connection", async () => {364    const { deps } = makeDeps({365      openSocket: vi.fn(async () => { throw new Error("ECONNREFUSED"); }),366    });367    const { cb, errors } = makeCallbacks();368369    await pairWithCode(PHONE_CODE, cb, deps, async () => true);370371    expect(errors).toEqual(["no_host_found"]);372    expect(deps.persistDevice).not.toHaveBeenCalled();373  });374375  it("still aborts on SAS rejection — the token is not the security boundary", async () => {376    const { deps, persisted } = makePhoneDeps();377    const { cb, errors } = makeCallbacks();378379    await pairWithCode(PHONE_CODE, cb, deps, async () => false);380381    expect(errors).toEqual(["sas_mismatch"]);382    expect(persisted).toHaveLength(0);383  });384});