pairing-dial.ts

7.5 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";19import { bytesToHex, deriveRendezvousSecret } from "./rendezvous";2021/** Default pairing port used by native listeners. */22export const PAIRING_PORT = 49142;2324export type PairingDialError =25  | "invalid_code" // socket closed before PEACH_HELLO_ACK (wrong/expired)26  | "no_host_found" // nothing answered at the endpoint27  | "handshake_failed"; // Noise NK failed or the ACK was malformed2829/**30 * Minimal binary WebSocket seam. Production wires the real `WebSocket`; tests31 * inject a fake. One binary message per frame.32 */33export interface PairingSocket {34  send(bytes: Uint8Array): void;35  /** Resolves with the next inbound binary frame, or rejects on close. */36  receive(): Promise<Uint8Array>;37  close(): void;38  /** True if the socket closed before the next frame arrived. */39  closedBeforeFrame(): boolean;40}4142export interface PairingDialDeps {43  openSocket(ip: string, port: number): Promise<PairingSocket>;44}4546export interface DialledPairingChannel {47  /** Encrypted channel — everything written to it is Noise-protected. */48  channel: PairingTransport;49  /** The 6-digit code the user must confirm matches the listener's screen. */50  sas: string;51  /** The address actually connected to, for the reconnect cache. */52  host: string;53  /** Pair-only reconnect capability derived independently from the SAS. */54  rendezvousSecretHex: string;55  socket: PairingSocket;56}5758export type DialResult =59  | { ok: true; dialled: DialledPairingChannel }60  | { ok: false; error: PairingDialError };6162/**63 * Dial a pairing code and bring up the encrypted channel behind it.64 *65 * On success the socket is left OPEN and owned by the caller, which must close66 * it. On failure it is closed here.67 */68export async function dialPairingChannel(69  code: string,70  deps: PairingDialDeps,71): Promise<DialResult> {72  const pairing = parsePairingCode(code);73  if (!pairing) return { ok: false, error: "invalid_code" };74  const { ip: host, port } = pairing;7576  let sock: PairingSocket;77  try {78    sock = await deps.openSocket(host, port);79  } catch {80    return { ok: false, error: "no_host_found" };81  }8283  try {84    sock.send(encodeJson({ type: "PEACH_HELLO", token: pairing.tokenHex }));8586    // A wrong or expired code gets NO error frame — the listener silently87    // closes and rotates. That silence IS the wrong-code signal.88    let ackBytes: Uint8Array;89    try {90      ackBytes = await sock.receive();91    } catch {92      sock.close();93      return { ok: false, error: "invalid_code" };94    }95    if (sock.closedBeforeFrame() || ackBytes.length === 0) {96      sock.close();97      return { ok: false, error: "invalid_code" };98    }99100    let ephemeralPublicKeyHex: string;101    try {102      const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);103      if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {104        // A non-ACK frame on a matching code should not happen; treat a missing105        // ACK exactly like a wrong code.106        sock.close();107        return { ok: false, error: "invalid_code" };108      }109      ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;110    } catch {111      sock.close();112      return { ok: false, error: "invalid_code" };113    }114115    const session = await noiseNKInitiator(116      hexToBytes32(ephemeralPublicKeyHex),117      async (msg) => sock.send(msg),118      () => sock.receive(),119    );120121    const channel: PairingTransport = {122      async send(plaintext) { sock.send(session.encrypt(plaintext)); },123      async receive() { return session.decrypt(await sock.receive()); },124    };125126    return {127      ok: true,128      dialled: {129        channel,130        sas: derivePairingSAS(session.handshakeHash),131        host,132        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),133        socket: sock,134      },135    };136  } catch (err) {137    // Coarse for the UI; log the real cause so a failed dial is diagnosable138    // from the service-worker console.139    if (typeof console !== "undefined") console.error("[pairing-dial] handshake failed:", err);140    sock.close();141    return { ok: false, error: "handshake_failed" };142  }143}144145/** Adapt a real `WebSocket` to the `PairingSocket` seam (binary frames). */146export function openPairingSocket(ip: string, port: number): Promise<PairingSocket> {147  return new Promise((resolve, reject) => {148    let ws: WebSocket;149    try {150      ws = new WebSocket(`ws://${ip}:${port}/`);151    } catch (err) {152      reject(err instanceof Error ? err : new Error("websocket open failed"));153      return;154    }155    ws.binaryType = "arraybuffer";156157    const inbox: Uint8Array[] = [];158    const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];159    let opened = false;160    let closed = false;161    // True iff the socket closed with no frame delivered for the pending read —162    // the wrong/expired-code signal (the listener sends no error frame).163    let sawCloseBeforeFrame = false;164165    ws.onopen = () => {166      opened = true;167      resolve(sock);168    };169    ws.onmessage = (ev: MessageEvent) => {170      const bytes = new Uint8Array(ev.data as ArrayBuffer);171      const r = readers.shift();172      if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }173      else inbox.push(bytes);174    };175    ws.onerror = () => {176      if (!opened) reject(new Error("websocket connection failed"));177    };178    ws.onclose = () => {179      closed = true;180      if (readers.length) {181        sawCloseBeforeFrame = true;182        while (readers.length) readers.shift()!.reject(new Error("socket closed"));183      }184      if (!opened) reject(new Error("socket closed before open"));185    };186187    const sock: PairingSocket = {188      send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },189      receive(): Promise<Uint8Array> {190        const queued = inbox.shift();191        if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }192        if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }193        return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));194      },195      close() { try { ws.close(); } catch { /* ignore */ } },196      closedBeforeFrame() { return sawCloseBeforeFrame; },197    };198  });199}200201function encodeJson(obj: unknown): Uint8Array {202  return new TextEncoder().encode(JSON.stringify(obj));203}204205function decodeJson<T>(bytes: Uint8Array): T {206  return JSON.parse(new TextDecoder().decode(bytes)) as T;207}208209function hexToBytes32(hex: string): Uint8Array {210  if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");211  const out = new Uint8Array(32);212  for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);213  return out;214}