code-pairing.test.ts

15.2 KB · raw

1// Code pairing — the PAIR_IDENTITY half over an address-bearing pairing code.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 type { PairingSocket } from "../shared/pairing-dial";1617// REAL SAS implementation — reused, not re-derived (ground truth §5).18import { 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";2223// ── helpers ────────────────────────────────────────────────────────────────2425const 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);29// The ACK key is the Noise NK responder static, so the encrypted identity must30// repeat exactly this key after the handshake.31const 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);4546/**47 * Scripted fake socket. Captures everything sent, and replays a queued list48 * of inbound frames. A frame of `null` means "the socket closes here with no49 * data" — the wrong-code behaviour (no error frame).50 */51function 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}7374/**75 * A fake Noise session that no-ops encrypt/decrypt (identity transform) so we76 * can drive the PAIR_IDENTITY exchange without real crypto. We stub77 * `noiseNKInitiator` via vi.mock below; this is the shape it returns.78 */79const FAKE_HANDSHAKE_HASH = Uint8Array.from([80  0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),81]);8283// Mock the NK initiator: skip the two handshake messages, hand back a session84// whose encrypt/decrypt are identity transforms and whose handshakeHash is the85// cross-platform KAT prefix (so the SAS test below is end-to-end through the flow).86vi.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      // default: ACK then a PAIR_IDENTITY response110      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});157158// ── specs ────────────────────────────────────────────────────────────────159160describe("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    // The listener sends NO error frame on a wrong/expired code — it SILENTLY174    // CLOSES the socket and rotates the code. So the175    // absence of a PEACH_HELLO_ACK *is* the wrong-code signal. (This replaces176    // the original brief's bogus PEACH_HELLO_ERROR test — no such frame exists.)177    const closeImmediately = makeFakeSocket([null]); // close, no data178    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    // Cross-impl KAT pinned in peach-sync (pairing.zig:275-279): the handshake192    // hash beginning 0f ba b8 cd yields SAS "895245". 0x0fbab8cd = 263895245;193    // 263895245 mod 1_000_000 = 895245.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    // FAKE_HANDSHAKE_HASH starts 0fbab8cd, so onSasReady must report 895245.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    // sent[0] = PEACH_HELLO (plaintext); sent[1] = our PAIR_IDENTITY287    // (encrypt is identity in the mock, so it decodes directly).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});305306// ── pairing-code path (the phone is the listener) ──────────────────────────307308/** Deps whose socket replays an Android peer's ACK + PAIR_IDENTITY. */309function 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});