desktop-pairing.test.ts

10.0 KB · raw

1// Tests for Change 3 — desktop helper code pairing.2//3// DRAFT / apply-ready. These specs import from the proposed module4// `src/background/desktop-pairing.ts` (Change 3). Path assumes this file is5// relocated to `src/background/desktop-pairing.test.ts` on apply; from the6// draft location the imports below would resolve as written if vitest's7// rootDir maps `src/` — adjust the two `../../` prefixes when you move it.8//9// They use the dependency-injection seam (DesktopPairingDeps / a fake10// DesktopPairingSocket) so no real WebSocket or Noise crypto runs — except11// the SAS KAT, which calls the REAL `derivePairingSAS` from the shipped12// chirp-handshake module to pin cross-impl agreement with peach-sync.1314import { beforeEach, describe, expect, it, vi } from "vitest";1516// Change 3 module (proposed):17import {18  pairWithDesktopHelper,19  DESKTOP_PAIRING_PORT,20  type DesktopPairingDeps,21  type DesktopPairingSocket,22  type PairWithDesktopCallbacks,23} from "./desktop-pairing";2425// REAL SAS implementation — reused, not re-derived (ground truth §5).26import { derivePairingSAS } from "../shared/chirp-handshake";27import type { LocalIdentity } from "../shared/chirp-handshake";28import type { PairedDevice } from "../shared/sync-types";2930// ── helpers ────────────────────────────────────────────────────────────────3132const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o));33const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b));3435const HELPER_EPHEMERAL_HEX = "a".repeat(64);36const HELPER_LONGTERM_HEX = "b".repeat(64);3738const OUR_IDENTITY: LocalIdentity = {39  deviceId: "ext-local-001",40  deviceName: "Firefox on MacBook",41  deviceType: "extension",42  longTermPublicKeyHex: "c".repeat(64),43  signingPublicKeyHex: "d".repeat(64),44  vaultId: "e".repeat(64),45};4647/**48 * Scripted fake socket. Captures everything sent, and replays a queued list49 * of inbound frames. A frame of `null` means "the socket closes here with no50 * data" — the peach-sync wrong-code behaviour (no error frame).51 */52function makeFakeSocket(inbound: Array<Uint8Array | null>) {53  const sent: Uint8Array[] = [];54  let closed = false;55  let lastWasClose = false;56  const queue = [...inbound];57  const sock: DesktopPairingSocket = {58    send(bytes) { sent.push(bytes); },59    async receive() {60      const next = queue.shift();61      if (next === undefined || next === null) {62        closed = true;63        lastWasClose = true;64        throw new Error("socket closed");65      }66      lastWasClose = false;67      return next;68    },69    close() { closed = true; },70    closedBeforeFrame() { return lastWasClose; },71  };72  return { sock, sent, isClosed: () => closed };73}7475/**76 * A fake Noise session that no-ops encrypt/decrypt (identity transform) so we77 * can drive the PAIR_IDENTITY exchange without real crypto. We stub78 * `noiseNKInitiator` via vi.mock below; this is the shape it returns.79 */80const FAKE_HANDSHAKE_HASH = Uint8Array.from([81  0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),82]);8384// Mock the NK initiator: skip the two handshake messages, hand back a session85// whose encrypt/decrypt are identity transforms and whose handshakeHash is the86// peach-sync KAT prefix (so the SAS test below is end-to-end through the flow).87vi.mock("../shared/noise-js", async (orig) => {88  const actual = await orig<typeof import("../shared/noise-js")>();89  return {90    ...actual,91    noiseNKInitiator: vi.fn(async (_responderStatic, _send, _receive) => ({92      encrypt: (pt: Uint8Array) => pt,93      decrypt: (ct: Uint8Array) => ct,94      handshakeHash: FAKE_HANDSHAKE_HASH,95    })),96  };97});9899function makeDeps(overrides: Partial<DesktopPairingDeps> = {}): {100  deps: DesktopPairingDeps;101  persisted: PairedDevice[];102  sockets: ReturnType<typeof makeFakeSocket>[];103} {104  const persisted: PairedDevice[] = [];105  const sockets: ReturnType<typeof makeFakeSocket>[] = [];106  const deps: DesktopPairingDeps = {107    findHost: vi.fn(async () => "192.168.1.50"),108    openSocket: vi.fn(async () => {109      // default: ACK then a PAIR_IDENTITY response110      const s = makeFakeSocket([111        enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),112        enc({113          type: "PAIR_IDENTITY",114          device_id: "desktop-001",115          device_name: "June's iMac",116          device_type: "desktop",117          long_term_public_key_hex: HELPER_LONGTERM_HEX,118          ws_port: 8765,119        }),120      ]);121      sockets.push(s);122      return s.sock;123    }),124    getLocalIdentity: vi.fn(async () => OUR_IDENTITY),125    persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }),126    ...overrides,127  };128  return { deps, persisted, sockets };129}130131function makeCallbacks(): {132  cb: PairWithDesktopCallbacks;133  sas: { value?: string };134  errors: string[];135  completed: PairedDevice[];136} {137  const sas: { value?: string } = {};138  const errors: string[] = [];139  const completed: PairedDevice[] = [];140  const cb: PairWithDesktopCallbacks = {141    onSasReady: (s) => { sas.value = s; },142    onError: (e) => { errors.push(e); },143    onComplete: (d) => { completed.push(d); },144  };145  return { cb, sas, errors, completed };146}147148beforeEach(() => {149  vi.clearAllMocks();150});151152// ── specs ────────────────────────────────────────────────────────────────153154describe("pairWithDesktopHelper", () => {155  it("sends PEACH_HELLO with a 6-digit zero-padded code to port 49142", async () => {156    const { deps, sockets } = makeDeps();157    const { cb } = makeCallbacks();158159    await pairWithDesktopHelper("123", cb, deps, async () => true);160161    expect(deps.openSocket).toHaveBeenCalledWith("192.168.1.50", DESKTOP_PAIRING_PORT);162    const firstFrame = dec(sockets[0].sent[0]);163    expect(firstFrame.type).toBe("PEACH_HELLO");164    // "123" must be normalised to exactly 6 ASCII digits, zero-padded.165    expect(firstFrame.code).toBe("000123");166    expect(firstFrame.code).toMatch(/^\d{6}$/);167  });168169  it("treats a socket close without PEACH_HELLO_ACK as invalid_code", async () => {170    // peach-sync sends NO error frame on a wrong/expired code — it SILENTLY171    // CLOSES the socket and rotates the code (pairing.zig:117,119-125). So the172    // absence of a PEACH_HELLO_ACK *is* the wrong-code signal. (This replaces173    // the original brief's bogus PEACH_HELLO_ERROR test — no such frame exists.)174    const closeImmediately = makeFakeSocket([null]); // close, no data175    const { deps } = makeDeps({176      openSocket: vi.fn(async () => closeImmediately.sock),177    });178    const { cb, errors, completed } = makeCallbacks();179180    await pairWithDesktopHelper("654321", cb, deps, async () => true);181182    expect(errors).toEqual(["invalid_code"]);183    expect(completed).toHaveLength(0);184    expect(deps.persistDevice).not.toHaveBeenCalled();185  });186187  it("derives the SAS via the real derivePairingSAS — KAT 0fbab8cd… → 895245", () => {188    // Cross-impl KAT pinned in peach-sync (pairing.zig:275-279): the handshake189    // hash beginning 0f ba b8 cd yields SAS "895245". 0x0fbab8cd = 263895245;190    // 263895245 mod 1_000_000 = 895245.191    const hash = Uint8Array.from([192      0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),193    ]);194    expect(derivePairingSAS(hash)).toBe("895245");195  });196197  it("surfaces that same SAS through the pairing flow", async () => {198    // FAKE_HANDSHAKE_HASH starts 0fbab8cd, so onSasReady must report 895245.199    const { deps } = makeDeps();200    const { cb, sas } = makeCallbacks();201202    await pairWithDesktopHelper("000123", cb, deps, async () => true);203204    expect(sas.value).toBe("895245");205  });206207  it("persists the device with deviceType 'desktop' on SAS confirm", async () => {208    const { deps, persisted } = makeDeps();209    const { cb, completed, errors } = makeCallbacks();210211    await pairWithDesktopHelper("000123", cb, deps, async () => true);212213    expect(errors).toHaveLength(0);214    expect(persisted).toHaveLength(1);215    expect(persisted[0].deviceType).toBe("desktop");216    expect(persisted[0].deviceId).toBe("desktop-001");217    expect(persisted[0].longTermPublicKeyHex).toBe(HELPER_LONGTERM_HEX);218    expect(persisted[0].trustSource).toBe("direct");219    expect(persisted[0].wsPort).toBe(8765);220    expect(completed).toHaveLength(1);221  });222223  it("aborts with sas_mismatch and persists nothing when the user rejects", async () => {224    const { deps, persisted } = makeDeps();225    const { cb, errors } = makeCallbacks();226227    await pairWithDesktopHelper("000123", cb, deps, async () => false);228229    expect(errors).toEqual(["sas_mismatch"]);230    expect(persisted).toHaveLength(0);231    expect(deps.persistDevice).not.toHaveBeenCalled();232  });233234  it("includes vault_id in the outbound PAIR_IDENTITY (Change 1)", async () => {235    const { deps, sockets } = makeDeps();236    const { cb } = makeCallbacks();237238    await pairWithDesktopHelper("000123", cb, deps, async () => true);239240    // sent[0] = PEACH_HELLO (plaintext); sent[1] = our PAIR_IDENTITY241    // (encrypt is identity in the mock, so it decodes directly).242    const ourPairIdentity = dec(sockets[0].sent[1]);243    expect(ourPairIdentity.type).toBe("PAIR_IDENTITY");244    expect(ourPairIdentity.vault_id).toBe(OUR_IDENTITY.vaultId);245  });246247  it("reports no_host_found when the probe returns null", async () => {248    const { deps } = makeDeps({ findHost: vi.fn(async () => null) });249    const { cb, errors } = makeCallbacks();250251    await pairWithDesktopHelper("000123", cb, deps, async () => true);252253    expect(errors).toEqual(["no_host_found"]);254    expect(deps.openSocket).not.toHaveBeenCalled();255  });256257  it("reports vault_locked when local identity is unavailable", async () => {258    const { deps } = makeDeps({259      getLocalIdentity: vi.fn(async () => { throw new Error("vault_locked"); }),260    });261    const { cb, errors } = makeCallbacks();262263    await pairWithDesktopHelper("000123", cb, deps, async () => true);264265    expect(errors).toEqual(["vault_locked"]);266    expect(deps.findHost).not.toHaveBeenCalled();267  });268});