desktop-pairing.test.ts

14.4 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 { encodePairingCode } from "../shared/pairing-code";28import type { LocalIdentity } from "../shared/chirp-handshake";29import type { PairedDevice } from "../shared/sync-types";3031// ── helpers ────────────────────────────────────────────────────────────────3233const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o));34const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b));3536const HELPER_EPHEMERAL_HEX = "a".repeat(64);37const HELPER_LONGTERM_HEX = "b".repeat(64);3839const OUR_IDENTITY: LocalIdentity = {40  deviceId: "ext-local-001",41  deviceName: "Firefox on MacBook",42  deviceType: "extension",43  longTermPublicKeyHex: "c".repeat(64),44  signingPublicKeyHex: "d".repeat(64),45  vaultId: "e".repeat(64),46};4748/**49 * Scripted fake socket. Captures everything sent, and replays a queued list50 * of inbound frames. A frame of `null` means "the socket closes here with no51 * data" — the peach-sync wrong-code behaviour (no error frame).52 */53function makeFakeSocket(inbound: Array<Uint8Array | null>) {54  const sent: Uint8Array[] = [];55  let closed = false;56  let lastWasClose = false;57  const queue = [...inbound];58  const sock: DesktopPairingSocket = {59    send(bytes) { sent.push(bytes); },60    async receive() {61      const next = queue.shift();62      if (next === undefined || next === null) {63        closed = true;64        lastWasClose = true;65        throw new Error("socket closed");66      }67      lastWasClose = false;68      return next;69    },70    close() { closed = true; },71    closedBeforeFrame() { return lastWasClose; },72  };73  return { sock, sent, isClosed: () => closed };74}7576/**77 * A fake Noise session that no-ops encrypt/decrypt (identity transform) so we78 * can drive the PAIR_IDENTITY exchange without real crypto. We stub79 * `noiseNKInitiator` via vi.mock below; this is the shape it returns.80 */81const FAKE_HANDSHAKE_HASH = Uint8Array.from([82  0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),83]);8485// Mock the NK initiator: skip the two handshake messages, hand back a session86// whose encrypt/decrypt are identity transforms and whose handshakeHash is the87// peach-sync KAT prefix (so the SAS test below is end-to-end through the flow).88vi.mock("../shared/noise-js", async (orig) => {89  const actual = await orig<typeof import("../shared/noise-js")>();90  return {91    ...actual,92    noiseNKInitiator: vi.fn(async (_responderStatic, _send, _receive) => ({93      encrypt: (pt: Uint8Array) => pt,94      decrypt: (ct: Uint8Array) => ct,95      handshakeHash: FAKE_HANDSHAKE_HASH,96    })),97  };98});99100function makeDeps(overrides: Partial<DesktopPairingDeps> = {}): {101  deps: DesktopPairingDeps;102  persisted: PairedDevice[];103  sockets: ReturnType<typeof makeFakeSocket>[];104} {105  const persisted: PairedDevice[] = [];106  const sockets: ReturnType<typeof makeFakeSocket>[] = [];107  const deps: DesktopPairingDeps = {108    findHost: vi.fn(async () => "192.168.1.50"),109    openSocket: vi.fn(async () => {110      // default: ACK then a PAIR_IDENTITY response111      const s = makeFakeSocket([112        enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),113        enc({114          type: "PAIR_IDENTITY",115          device_id: "desktop-001",116          device_name: "June's iMac",117          device_type: "desktop",118          long_term_public_key_hex: HELPER_LONGTERM_HEX,119          ws_port: 8765,120        }),121      ]);122      sockets.push(s);123      return s.sock;124    }),125    getLocalIdentity: vi.fn(async () => OUR_IDENTITY),126    persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }),127    ...overrides,128  };129  return { deps, persisted, sockets };130}131132function makeCallbacks(): {133  cb: PairWithDesktopCallbacks;134  sas: { value?: string };135  errors: string[];136  completed: PairedDevice[];137} {138  const sas: { value?: string } = {};139  const errors: string[] = [];140  const completed: PairedDevice[] = [];141  const cb: PairWithDesktopCallbacks = {142    onSasReady: (s) => { sas.value = s; },143    onError: (e) => { errors.push(e); },144    onComplete: (d) => { completed.push(d); },145  };146  return { cb, sas, errors, completed };147}148149beforeEach(() => {150  vi.clearAllMocks();151});152153// ── specs ────────────────────────────────────────────────────────────────154155describe("pairWithDesktopHelper", () => {156  it("sends PEACH_HELLO with a 6-digit zero-padded code to port 49142", async () => {157    const { deps, sockets } = makeDeps();158    const { cb } = makeCallbacks();159160    await pairWithDesktopHelper("123", cb, deps, async () => true);161162    expect(deps.openSocket).toHaveBeenCalledWith("192.168.1.50", DESKTOP_PAIRING_PORT);163    const firstFrame = dec(sockets[0].sent[0]);164    expect(firstFrame.type).toBe("PEACH_HELLO");165    // "123" must be normalised to exactly 6 ASCII digits, zero-padded.166    expect(firstFrame.code).toBe("000123");167    expect(firstFrame.code).toMatch(/^\d{6}$/);168  });169170  it("treats a socket close without PEACH_HELLO_ACK as invalid_code", async () => {171    // peach-sync sends NO error frame on a wrong/expired code — it SILENTLY172    // CLOSES the socket and rotates the code (pairing.zig:117,119-125). So the173    // absence of a PEACH_HELLO_ACK *is* the wrong-code signal. (This replaces174    // the original brief's bogus PEACH_HELLO_ERROR test — no such frame exists.)175    const closeImmediately = makeFakeSocket([null]); // close, no data176    const { deps } = makeDeps({177      openSocket: vi.fn(async () => closeImmediately.sock),178    });179    const { cb, errors, completed } = makeCallbacks();180181    await pairWithDesktopHelper("654321", cb, deps, async () => true);182183    expect(errors).toEqual(["invalid_code"]);184    expect(completed).toHaveLength(0);185    expect(deps.persistDevice).not.toHaveBeenCalled();186  });187188  it("derives the SAS via the real derivePairingSAS — KAT 0fbab8cd… → 895245", () => {189    // Cross-impl KAT pinned in peach-sync (pairing.zig:275-279): the handshake190    // hash beginning 0f ba b8 cd yields SAS "895245". 0x0fbab8cd = 263895245;191    // 263895245 mod 1_000_000 = 895245.192    const hash = Uint8Array.from([193      0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),194    ]);195    expect(derivePairingSAS(hash)).toBe("895245");196  });197198  it("surfaces that same SAS through the pairing flow", async () => {199    // FAKE_HANDSHAKE_HASH starts 0fbab8cd, so onSasReady must report 895245.200    const { deps } = makeDeps();201    const { cb, sas } = makeCallbacks();202203    await pairWithDesktopHelper("000123", cb, deps, async () => true);204205    expect(sas.value).toBe("895245");206  });207208  it("persists the device with deviceType 'desktop' on SAS confirm", async () => {209    const { deps, persisted } = makeDeps();210    const { cb, completed, errors } = makeCallbacks();211212    await pairWithDesktopHelper("000123", cb, deps, async () => true);213214    expect(errors).toHaveLength(0);215    expect(persisted).toHaveLength(1);216    expect(persisted[0].deviceType).toBe("desktop");217    expect(persisted[0].deviceId).toBe("desktop-001");218    expect(persisted[0].longTermPublicKeyHex).toBe(HELPER_LONGTERM_HEX);219    expect(persisted[0].trustSource).toBe("direct");220    expect(persisted[0].wsPort).toBe(8765);221    expect(completed).toHaveLength(1);222  });223224  it("aborts with sas_mismatch and persists nothing when the user rejects", async () => {225    const { deps, persisted } = makeDeps();226    const { cb, errors } = makeCallbacks();227228    await pairWithDesktopHelper("000123", cb, deps, async () => false);229230    expect(errors).toEqual(["sas_mismatch"]);231    expect(persisted).toHaveLength(0);232    expect(deps.persistDevice).not.toHaveBeenCalled();233  });234235  it("includes vault_id in the outbound PAIR_IDENTITY (Change 1)", async () => {236    const { deps, sockets } = makeDeps();237    const { cb } = makeCallbacks();238239    await pairWithDesktopHelper("000123", cb, deps, async () => true);240241    // sent[0] = PEACH_HELLO (plaintext); sent[1] = our PAIR_IDENTITY242    // (encrypt is identity in the mock, so it decodes directly).243    const ourPairIdentity = dec(sockets[0].sent[1]);244    expect(ourPairIdentity.type).toBe("PAIR_IDENTITY");245    expect(ourPairIdentity.vault_id).toBe(OUR_IDENTITY.vaultId);246  });247248  it("reports no_host_found when the probe returns null", async () => {249    const { deps } = makeDeps({ findHost: vi.fn(async () => null) });250    const { cb, errors } = makeCallbacks();251252    await pairWithDesktopHelper("000123", cb, deps, async () => true);253254    expect(errors).toEqual(["no_host_found"]);255    expect(deps.openSocket).not.toHaveBeenCalled();256  });257258  it("reports vault_locked when local identity is unavailable", async () => {259    const { deps } = makeDeps({260      getLocalIdentity: vi.fn(async () => { throw new Error("vault_locked"); }),261    });262    const { cb, errors } = makeCallbacks();263264    await pairWithDesktopHelper("000123", cb, deps, async () => true);265266    expect(errors).toEqual(["vault_locked"]);267    expect(deps.findHost).not.toHaveBeenCalled();268  });269});270271// ── pairing-code path (the phone is the listener) ──────────────────────────272273const PHONE_TOKEN = Uint8Array.of(0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04);274const PHONE_CODE = encodePairingCode("192.168.4.77", 51555, PHONE_TOKEN);275const PHONE_LONGTERM_HEX = "f".repeat(64);276277/** Deps whose socket replays an Android peer's ACK + PAIR_IDENTITY. */278function makePhoneDeps(deviceType: string | undefined = "android") {279  const identity: Record<string, unknown> = {280    type: "PAIR_IDENTITY",281    device_id: "android-001",282    device_name: "Pixel 8",283    long_term_public_key_hex: PHONE_LONGTERM_HEX,284    ws_port: 8765,285  };286  identity.device_type = deviceType;287  return makeDeps({288    openSocket: vi.fn(async () => makeFakeSocket([289      enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),290      enc(identity),291    ]).sock),292  });293}294295describe("pairWithDesktopHelper — pairing code", () => {296  it("dials the address in the code instead of probing for a host", async () => {297    const { deps } = makePhoneDeps();298    const { cb, errors } = makeCallbacks();299300    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);301302    expect(errors).toHaveLength(0);303    expect(deps.findHost).not.toHaveBeenCalled();304    expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);305  });306307  it("presents the token as hex in the plaintext hello, not a 6-digit code", async () => {308    const sockets: ReturnType<typeof makeFakeSocket>[] = [];309    const { deps } = makeDeps({310      openSocket: vi.fn(async () => {311        const s = makeFakeSocket([312          enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),313          enc({314            type: "PAIR_IDENTITY",315            device_id: "android-001",316            device_name: "Pixel 8",317            device_type: "android",318            long_term_public_key_hex: PHONE_LONGTERM_HEX,319            ws_port: 8765,320          }),321        ]);322        sockets.push(s);323        return s.sock;324      }),325    });326    const { cb } = makeCallbacks();327328    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);329330    const hello = dec(sockets[0].sent[0]);331    expect(hello.type).toBe("PEACH_HELLO");332    expect(hello.token).toBe("deadbeef01020304");333    expect(hello.code).toBeUndefined();334  });335336  it("persists the phone with the device type it announced", async () => {337    const { deps, persisted } = makePhoneDeps();338    const { cb, completed } = makeCallbacks();339340    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);341342    expect(persisted).toHaveLength(1);343    expect(persisted[0].deviceType).toBe("android");344    expect(persisted[0].deviceId).toBe("android-001");345    expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);346    expect(persisted[0].trustSource).toBe("direct");347    expect(persisted[0].wsPort).toBe(8765);348    expect(completed).toHaveLength(1);349  });350351  it("falls back to 'android', never 'desktop', when the peer omits its type", async () => {352    const { deps, persisted } = makePhoneDeps("");353    const { cb } = makeCallbacks();354355    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);356357    expect(persisted[0].deviceType).toBe("android");358  });359360  it("caches the coded IP for the next sync round", async () => {361    const cacheLastKnownIp = vi.fn(async () => undefined);362    const { deps } = makePhoneDeps();363    deps.cacheLastKnownIp = cacheLastKnownIp;364    const { cb } = makeCallbacks();365366    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);367368    expect(cacheLastKnownIp).toHaveBeenCalledWith(PHONE_LONGTERM_HEX, "192.168.4.77");369  });370371  it("reports no_host_found when the coded endpoint refuses the connection", async () => {372    const { deps } = makeDeps({373      openSocket: vi.fn(async () => { throw new Error("ECONNREFUSED"); }),374    });375    const { cb, errors } = makeCallbacks();376377    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);378379    expect(errors).toEqual(["no_host_found"]);380    expect(deps.persistDevice).not.toHaveBeenCalled();381  });382383  it("still aborts on SAS rejection — the token is not the security boundary", async () => {384    const { deps, persisted } = makePhoneDeps();385    const { cb, errors } = makeCallbacks();386387    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => false);388389    expect(errors).toEqual(["sas_mismatch"]);390    expect(persisted).toHaveLength(0);391  });392});