pairing-dial.ts

8.1 KB · raw

1// The dialling half of code pairing — shared by device pairing and vault2// adoption.3//4// Both flows are identical up to the point where they diverge: decode a5// pairing code, dial the address inside it, present the token in a plaintext6// hello, and run Noise NK as the INITIATOR against the ephemeral key the7// listener returns. What they then say over the encrypted channel differs8// (PAIR_IDENTITY vs ADOPT_REQUEST); how they get there does not.9//10// The browser is always the dialler. It cannot listen, so it is always the NK11// initiator and the listener's `ephemeralPublicKeyHex` is the responder static.12//13// Nothing here touches chrome, the vault, or a real WebSocket — the socket is14// injected — because vault adoption runs before a vault exists at all.1516import { noiseNKInitiator } from "./noise-js";17import { derivePairingSAS, type PairingTransport } from "./pairing-handshake";18import { parsePairingCode } from "./pairing-code";1920/** The pairing-only port a legacy helper code implies. */21export const PAIRING_PORT = 49142;2223export type PairingDialError =24  | "invalid_code" // socket closed before PEACH_HELLO_ACK (wrong/expired)25  | "no_host_found" // nothing answered at the endpoint26  | "handshake_failed"; // Noise NK failed or the ACK was malformed2728/**29 * Minimal binary WebSocket seam. Production wires the real `WebSocket`; tests30 * inject a fake. One binary message per frame.31 */32export interface PairingSocket {33  send(bytes: Uint8Array): void;34  /** Resolves with the next inbound binary frame, or rejects on close. */35  receive(): Promise<Uint8Array>;36  close(): void;37  /** True if the socket closed before the next frame arrived. */38  closedBeforeFrame(): boolean;39}4041export interface PairingDialDeps {42  /**43   * Legacy 6-digit codes carry no address, so a host has to be found. Omit it44   * where only pairing codes are accepted — a code without an address then45   * simply has nowhere to go.46   */47  findHost?(port: number): Promise<string | null>;48  openSocket(ip: string, port: number): Promise<PairingSocket>;49}5051export interface DialledPairingChannel {52  /** Encrypted channel — everything written to it is Noise-protected. */53  channel: PairingTransport;54  /** The 6-digit code the user must confirm matches the listener's screen. */55  sas: string;56  /** The address actually connected to, for the reconnect cache. */57  host: string;58  socket: PairingSocket;59}6061export type DialResult =62  | { ok: true; dialled: DialledPairingChannel }63  | { ok: false; error: PairingDialError };6465/**66 * Dial a pairing code and bring up the encrypted channel behind it.67 *68 * On success the socket is left OPEN and owned by the caller, which must close69 * it. On failure it is closed here.70 */71export async function dialPairingChannel(72  code: string,73  deps: PairingDialDeps,74): Promise<DialResult> {75  const pairing = parsePairingCode(code);7677  // A pairing code already carries an endpoint; a legacy helper code does not,78  // so the /24 has to be probed for it.79  const port = pairing?.port ?? PAIRING_PORT;80  const host = pairing?.ip ?? (await deps.findHost?.(PAIRING_PORT)) ?? null;81  if (!host) return { ok: false, error: "no_host_found" };8283  let sock: PairingSocket;84  try {85    sock = await deps.openSocket(host, port);86  } catch {87    return { ok: false, error: "no_host_found" };88  }8990  try {91    // The legacy helper compares the code byte-for-byte against a [6]u8, so92    // that form is normalised to exactly 6 zero-padded ASCII digits (ground93    // truth §2); a pairing token is sent as the hex it decoded to.94    sock.send(encodeJson(95      pairing96        ? { type: "PEACH_HELLO", token: pairing.tokenHex }97        : { type: "PEACH_HELLO", code: String(code).replace(/\D/g, "").padStart(6, "0").slice(-6) },98    ));99100    // A wrong or expired code gets NO error frame — the listener silently101    // closes and rotates. That silence IS the wrong-code signal.102    let ackBytes: Uint8Array;103    try {104      ackBytes = await sock.receive();105    } catch {106      sock.close();107      return { ok: false, error: "invalid_code" };108    }109    if (sock.closedBeforeFrame() || ackBytes.length === 0) {110      sock.close();111      return { ok: false, error: "invalid_code" };112    }113114    let ephemeralPublicKeyHex: string;115    try {116      const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);117      if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {118        // A non-ACK frame on a matching code should not happen; treat a missing119        // ACK exactly like a wrong code.120        sock.close();121        return { ok: false, error: "invalid_code" };122      }123      ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;124    } catch {125      sock.close();126      return { ok: false, error: "invalid_code" };127    }128129    const session = await noiseNKInitiator(130      hexToBytes32(ephemeralPublicKeyHex),131      async (msg) => sock.send(msg),132      () => sock.receive(),133    );134135    const channel: PairingTransport = {136      async send(plaintext) { sock.send(session.encrypt(plaintext)); },137      async receive() { return session.decrypt(await sock.receive()); },138    };139140    return {141      ok: true,142      dialled: {143        channel,144        sas: derivePairingSAS(session.handshakeHash),145        host,146        socket: sock,147      },148    };149  } catch (err) {150    // Coarse for the UI; log the real cause so a failed dial is diagnosable151    // from the service-worker console.152    if (typeof console !== "undefined") console.error("[pairing-dial] handshake failed:", err);153    sock.close();154    return { ok: false, error: "handshake_failed" };155  }156}157158/** Adapt a real `WebSocket` to the `PairingSocket` seam (binary frames). */159export function openPairingSocket(ip: string, port: number): Promise<PairingSocket> {160  return new Promise((resolve, reject) => {161    let ws: WebSocket;162    try {163      ws = new WebSocket(`ws://${ip}:${port}/`);164    } catch (err) {165      reject(err instanceof Error ? err : new Error("websocket open failed"));166      return;167    }168    ws.binaryType = "arraybuffer";169170    const inbox: Uint8Array[] = [];171    const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];172    let opened = false;173    let closed = false;174    // True iff the socket closed with no frame delivered for the pending read —175    // the wrong/expired-code signal (the listener sends no error frame).176    let sawCloseBeforeFrame = false;177178    ws.onopen = () => {179      opened = true;180      resolve(sock);181    };182    ws.onmessage = (ev: MessageEvent) => {183      const bytes = new Uint8Array(ev.data as ArrayBuffer);184      const r = readers.shift();185      if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }186      else inbox.push(bytes);187    };188    ws.onerror = () => {189      if (!opened) reject(new Error("websocket connection failed"));190    };191    ws.onclose = () => {192      closed = true;193      if (readers.length) {194        sawCloseBeforeFrame = true;195        while (readers.length) readers.shift()!.reject(new Error("socket closed"));196      }197      if (!opened) reject(new Error("socket closed before open"));198    };199200    const sock: PairingSocket = {201      send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },202      receive(): Promise<Uint8Array> {203        const queued = inbox.shift();204        if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }205        if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }206        return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));207      },208      close() { try { ws.close(); } catch { /* ignore */ } },209      closedBeforeFrame() { return sawCloseBeforeFrame; },210    };211  });212}213214function encodeJson(obj: unknown): Uint8Array {215  return new TextEncoder().encode(JSON.stringify(obj));216}217218function decodeJson<T>(bytes: Uint8Array): T {219  return JSON.parse(new TextDecoder().decode(bytes)) as T;220}221222function hexToBytes32(hex: string): Uint8Array {223  if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");224  const out = new Uint8Array(32);225  for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);226  return out;227}