code-pairing.test.ts

13.5 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);29const PHONE_LONGTERM_HEX = "f".repeat(64);30const PHONE_TOKEN = Uint8Array.of(0xde, 0xad, 0xbe, 0xef);31const PHONE_CODE = encodePairingCode("192.168.4.77", 51555, PHONE_TOKEN);3233const OUR_IDENTITY: LocalIdentity = {34  deviceId: "ext-local-001",35  deviceName: "Firefox on MacBook",36  deviceType: "extension",37  longTermPublicKeyHex: "c".repeat(64),38  signingPublicKeyHex: "d".repeat(64),39  vaultId: "e".repeat(64),40};4142/**43 * Scripted fake socket. Captures everything sent, and replays a queued list44 * of inbound frames. A frame of `null` means "the socket closes here with no45 * data" — the wrong-code behaviour (no error frame).46 */47function makeFakeSocket(inbound: Array<Uint8Array | null>) {48  const sent: Uint8Array[] = [];49  let closed = false;50  let lastWasClose = false;51  const queue = [...inbound];52  const sock: PairingSocket = {53    send(bytes) { sent.push(bytes); },54    async receive() {55      const next = queue.shift();56      if (next === undefined || next === null) {57        closed = true;58        lastWasClose = true;59        throw new Error("socket closed");60      }61      lastWasClose = false;62      return next;63    },64    close() { closed = true; },65    closedBeforeFrame() { return lastWasClose; },66  };67  return { sock, sent, isClosed: () => closed };68}6970/**71 * A fake Noise session that no-ops encrypt/decrypt (identity transform) so we72 * can drive the PAIR_IDENTITY exchange without real crypto. We stub73 * `noiseNKInitiator` via vi.mock below; this is the shape it returns.74 */75const FAKE_HANDSHAKE_HASH = Uint8Array.from([76  0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),77]);7879// Mock the NK initiator: skip the two handshake messages, hand back a session80// whose encrypt/decrypt are identity transforms and whose handshakeHash is the81// cross-platform KAT prefix (so the SAS test below is end-to-end through the flow).82vi.mock("../shared/noise-js", async (orig) => {83  const actual = await orig<typeof import("../shared/noise-js")>();84  return {85    ...actual,86    noiseNKInitiator: vi.fn(async (_responderStatic, _send, _receive) => ({87      encrypt: (pt: Uint8Array) => pt,88      decrypt: (ct: Uint8Array) => ct,89      handshakeHash: FAKE_HANDSHAKE_HASH,90    })),91  };92});9394function makeDeps(overrides: Partial<CodePairingDeps> = {}): {95  deps: CodePairingDeps;96  persisted: PairedDevice[];97  sockets: ReturnType<typeof makeFakeSocket>[];98} {99  const persisted: PairedDevice[] = [];100  const sockets: ReturnType<typeof makeFakeSocket>[] = [];101  const deps: CodePairingDeps = {102    openSocket: vi.fn(async () => {103      // default: ACK then a PAIR_IDENTITY response104      const s = makeFakeSocket([105        enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),106        enc({107          type: "PAIR_IDENTITY",108          device_id: "android-001",109          device_name: "Pixel 8",110          device_type: "android",111          long_term_public_key_hex: PHONE_LONGTERM_HEX,112          ws_port: 49142,113          relay_url: "wss://relay.peachpasswords.com",114        }),115      ]);116      sockets.push(s);117      return s.sock;118    }),119    getLocalIdentity: vi.fn(async () => OUR_IDENTITY),120    getRelayUrl: vi.fn(async () => "wss://relay.peachpasswords.com"),121    persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }),122    ...overrides,123  };124  return { deps, persisted, sockets };125}126127function makeCallbacks(): {128  cb: CodePairingCallbacks;129  sas: { value?: string };130  errors: string[];131  completed: PairedDevice[];132} {133  const sas: { value?: string } = {};134  const errors: string[] = [];135  const completed: PairedDevice[] = [];136  const cb: CodePairingCallbacks = {137    onSasReady: (s) => { sas.value = s; },138    onError: (e) => { errors.push(e); },139    onComplete: (d) => { completed.push(d); },140  };141  return { cb, sas, errors, completed };142}143144beforeEach(() => {145  vi.clearAllMocks();146});147148// ── specs ────────────────────────────────────────────────────────────────149150describe("pairWithCode", () => {151  it("dials the coded endpoint and sends its token in PEACH_HELLO", async () => {152    const { deps, sockets } = makeDeps();153    const { cb } = makeCallbacks();154155    await pairWithCode(PHONE_CODE, cb, deps, async () => true);156157    expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);158    const firstFrame = dec(sockets[0].sent[0]);159    expect(firstFrame).toEqual({ type: "PEACH_HELLO", token: "deadbeef" });160  });161162  it("treats a socket close without PEACH_HELLO_ACK as invalid_code", async () => {163    // The listener sends NO error frame on a wrong/expired code — it SILENTLY164    // CLOSES the socket and rotates the code. 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(PHONE_CODE, 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(PHONE_CODE, cb, deps, async () => true);196197    expect(sas.value).toBe("895245");198  });199200  it("persists the phone identity on SAS confirm", async () => {201    const { deps, persisted } = makeDeps();202    const { cb, completed, errors } = makeCallbacks();203204    await pairWithCode(PHONE_CODE, cb, deps, async () => true);205206    expect(errors).toHaveLength(0);207    expect(persisted).toHaveLength(1);208    expect(persisted[0].deviceType).toBe("android");209    expect(persisted[0].deviceId).toBe("android-001");210    expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);211    expect(persisted[0].trustSource).toBe("direct");212    expect(persisted[0].wsPort).toBe(49142);213    expect(persisted[0].relayUrl).toBe("wss://relay.peachpasswords.com");214    expect(persisted[0].rendezvousSecretHex).toMatch(/^[0-9a-f]{64}$/);215    expect(completed).toHaveLength(1);216  });217218  it("aborts with sas_mismatch and persists nothing when the user rejects", async () => {219    const { deps, persisted } = makeDeps();220    const { cb, errors } = makeCallbacks();221222    await pairWithCode(PHONE_CODE, cb, deps, async () => false);223224    expect(errors).toEqual(["sas_mismatch"]);225    expect(persisted).toHaveLength(0);226    expect(deps.persistDevice).not.toHaveBeenCalled();227  });228229  it("includes vault_id in the outbound PAIR_IDENTITY (Change 1)", async () => {230    const { deps, sockets } = makeDeps();231    const { cb } = makeCallbacks();232233    await pairWithCode(PHONE_CODE, cb, deps, async () => true);234235    // sent[0] = PEACH_HELLO (plaintext); sent[1] = our PAIR_IDENTITY236    // (encrypt is identity in the mock, so it decodes directly).237    const ourPairIdentity = dec(sockets[0].sent[1]);238    expect(ourPairIdentity.type).toBe("PAIR_IDENTITY");239    expect(ourPairIdentity.vault_id).toBe(OUR_IDENTITY.vaultId);240  });241242  it("reports vault_locked when local identity is unavailable", async () => {243    const { deps } = makeDeps({244      getLocalIdentity: vi.fn(async () => { throw new Error("vault_locked"); }),245    });246    const { cb, errors } = makeCallbacks();247248    await pairWithCode(PHONE_CODE, cb, deps, async () => true);249250    expect(errors).toEqual(["vault_locked"]);251    expect(deps.openSocket).not.toHaveBeenCalled();252  });253});254255// ── pairing-code path (the phone is the listener) ──────────────────────────256257/** Deps whose socket replays an Android peer's ACK + PAIR_IDENTITY. */258function makePhoneDeps(deviceType: string | undefined = "android") {259  const identity: Record<string, unknown> = {260    type: "PAIR_IDENTITY",261    device_id: "android-001",262    device_name: "Pixel 8",263    long_term_public_key_hex: PHONE_LONGTERM_HEX,264    ws_port: 49142,265    relay_url: "wss://relay.peachpasswords.com",266  };267  identity.device_type = deviceType;268  return makeDeps({269    openSocket: vi.fn(async () => makeFakeSocket([270      enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),271      enc(identity),272    ]).sock),273  });274}275276describe("pairWithCode — pairing code", () => {277  it("dials the address in the code", async () => {278    const { deps } = makePhoneDeps();279    const { cb, errors } = makeCallbacks();280281    await pairWithCode(PHONE_CODE, cb, deps, async () => true);282283    expect(errors).toHaveLength(0);284    expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);285  });286287  it("presents the token as hex in the plaintext hello, not a 6-digit code", async () => {288    const sockets: ReturnType<typeof makeFakeSocket>[] = [];289    const { deps } = makeDeps({290      openSocket: vi.fn(async () => {291        const s = makeFakeSocket([292          enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),293          enc({294            type: "PAIR_IDENTITY",295            device_id: "android-001",296            device_name: "Pixel 8",297            device_type: "android",298            long_term_public_key_hex: PHONE_LONGTERM_HEX,299            ws_port: 49142,300            relay_url: "wss://relay.peachpasswords.com",301          }),302        ]);303        sockets.push(s);304        return s.sock;305      }),306    });307    const { cb } = makeCallbacks();308309    await pairWithCode(PHONE_CODE, cb, deps, async () => true);310311    const hello = dec(sockets[0].sent[0]);312    expect(hello.type).toBe("PEACH_HELLO");313    expect(hello.token).toBe("deadbeef");314    expect(hello.code).toBeUndefined();315  });316317  it("persists the phone with the device type it announced", async () => {318    const { deps, persisted } = makePhoneDeps();319    const { cb, completed } = makeCallbacks();320321    await pairWithCode(PHONE_CODE, cb, deps, async () => true);322323    expect(persisted).toHaveLength(1);324    expect(persisted[0].deviceType).toBe("android");325    expect(persisted[0].deviceId).toBe("android-001");326    expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);327    expect(persisted[0].trustSource).toBe("direct");328    expect(persisted[0].wsPort).toBe(49142);329    expect(completed).toHaveLength(1);330  });331332  it("rejects a peer that omits its device type", async () => {333    const { deps, persisted } = makePhoneDeps("");334    const { cb, errors } = makeCallbacks();335336    await pairWithCode(PHONE_CODE, cb, deps, async () => true);337338    expect(errors).toEqual(["handshake_failed"]);339    expect(persisted).toHaveLength(0);340  });341342  it("caches the coded IP for the next sync round", async () => {343    const cacheLastKnownIp = vi.fn(async () => undefined);344    const { deps } = makePhoneDeps();345    deps.cacheLastKnownIp = cacheLastKnownIp;346    const { cb } = makeCallbacks();347348    await pairWithCode(PHONE_CODE, cb, deps, async () => true);349350    expect(cacheLastKnownIp).toHaveBeenCalledWith(PHONE_LONGTERM_HEX, "192.168.4.77");351  });352353  it("reports no_host_found when the coded endpoint refuses the connection", async () => {354    const { deps } = makeDeps({355      openSocket: vi.fn(async () => { throw new Error("ECONNREFUSED"); }),356    });357    const { cb, errors } = makeCallbacks();358359    await pairWithCode(PHONE_CODE, cb, deps, async () => true);360361    expect(errors).toEqual(["no_host_found"]);362    expect(deps.persistDevice).not.toHaveBeenCalled();363  });364365  it("still aborts on SAS rejection — the token is not the security boundary", async () => {366    const { deps, persisted } = makePhoneDeps();367    const { cb, errors } = makeCallbacks();368369    await pairWithCode(PHONE_CODE, cb, deps, async () => false);370371    expect(errors).toEqual(["sas_mismatch"]);372    expect(persisted).toHaveLength(0);373  });374});