pairing-dial.ts

10.3 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 {17  noiseNKInitiator,18  performInitiatorAuth,19  type NoiseNKSession,20} from "./noise-js";21import { derivePairingSAS, type PairingTransport } from "./pairing-handshake";22import { parsePairingCode } from "./pairing-code";23import {24  pairingInvitationUrl,25  parsePairingInvitation,26} from "./pairing-invitation";27import { bytesToHex, deriveRendezvousSecret } from "./rendezvous";2829/** Default pairing port used by native listeners. */30export const PAIRING_PORT = 49142;3132export type PairingDialError =33  | "invalid_code" // socket closed before PEACH_HELLO_ACK (wrong/expired)34  | "no_host_found" // nothing answered at the endpoint35  | "handshake_failed"; // Noise NK failed or the ACK was malformed3637/**38 * Minimal binary WebSocket seam. Production wires the real `WebSocket`; tests39 * inject a fake. One binary message per frame.40 */41export interface PairingSocket {42  send(bytes: Uint8Array): void;43  /** Resolves with the next inbound binary frame, or rejects on close. */44  receive(): Promise<Uint8Array>;45  close(): void;46  /** True if the socket closed before the next frame arrived. */47  closedBeforeFrame(): boolean;48}4950export interface PairingDialDeps {51  openSocket(ip: string, port: number): Promise<PairingSocket>;52}5354export interface PairingDialOptions {55  flow?: "adoption";56}5758export interface RelayPairingDialDeps {59  openSocket(url: string): Promise<PairingSocket>;60}6162export interface DialledPairingChannel {63  /** Encrypted channel — everything written to it is Noise-protected. */64  channel: PairingTransport;65  /** The 8-digit code the user must confirm matches the listener's screen. */66  sas: string;67  /** The address actually connected to, for the reconnect cache. */68  host: string;69  /** Present only for relay-first pairing/adoption. */70  relayUrl?: string;71  /** Pair-only reconnect capability derived independently from the SAS. */72  rendezvousSecretHex: string;73  /** Static key authenticated by Noise NK. PAIR_IDENTITY must repeat this key. */74  remoteStaticPublicKey: Uint8Array;75  /**76   * Prove possession of the initiator key before any identity or consent frame.77   * Device pairing must call this exactly once; adoption cannot use it until it78   * has a long-term key and remains a separately gated protocol.79   */80  authenticateInitiator(81    privateKey: Uint8Array,82    publicKey: Uint8Array,83  ): Promise<void>;84  socket: PairingSocket;85}8687export type DialResult =88  | { ok: true; dialled: DialledPairingChannel }89  | { ok: false; error: PairingDialError };9091/**92 * Dial a pairing code and bring up the encrypted channel behind it.93 *94 * On success the socket is left OPEN and owned by the caller, which must close95 * it. On failure it is closed here.96 */97export async function dialPairingChannel(98  code: string,99  deps: PairingDialDeps,100  options: PairingDialOptions = {},101): Promise<DialResult> {102  const pairing = parsePairingCode(code);103  if (!pairing) return { ok: false, error: "invalid_code" };104  const { ip: host, port } = pairing;105106  let sock: PairingSocket;107  try {108    sock = await deps.openSocket(host, port);109  } catch {110    return { ok: false, error: "no_host_found" };111  }112113  return finishPairingHandshake(sock, pairing.tokenHex, host, options.flow);114}115116/**117 * Dial a relay-first invitation. The source must already occupy the room118 * because the relay deliberately buffers nothing.119 */120export async function dialRelayPairingChannel(121  encodedInvitation: string,122  deps: RelayPairingDialDeps,123): Promise<DialResult> {124  const invitation = parsePairingInvitation(encodedInvitation);125  if (!invitation) return { ok: false, error: "invalid_code" };126127  let sock: PairingSocket;128  try {129    sock = await deps.openSocket(pairingInvitationUrl(invitation));130  } catch {131    return { ok: false, error: "no_host_found" };132  }133  const result = await finishPairingHandshake(134    sock,135    invitation.token,136    new URL(invitation.relayUrl).hostname,137  );138  if (result.ok) result.dialled.relayUrl = invitation.relayUrl;139  return result;140}141142async function finishPairingHandshake(143  sock: PairingSocket,144  token: string,145  host: string,146  flow?: "adoption",147): Promise<DialResult> {148  let noise: NoiseNKSession | undefined;149  try {150    sock.send(encodeJson({151      type: "PEACH_HELLO",152      token,153      ...(flow ? { flow } : {}),154    }));155156    // A wrong or expired code gets NO error frame — the listener silently157    // closes and rotates. That silence IS the wrong-code signal.158    let ackBytes: Uint8Array;159    try {160      ackBytes = await sock.receive();161    } catch {162      sock.close();163      return { ok: false, error: "invalid_code" };164    }165    if (sock.closedBeforeFrame() || ackBytes.length === 0) {166      sock.close();167      return { ok: false, error: "invalid_code" };168    }169170    let ephemeralPublicKeyHex: string;171    try {172      const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);173      if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {174        // A non-ACK frame on a matching code should not happen; treat a missing175        // ACK exactly like a wrong code.176        sock.close();177        return { ok: false, error: "invalid_code" };178      }179      ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;180    } catch {181      sock.close();182      return { ok: false, error: "invalid_code" };183    }184185    const remoteStaticPublicKey = hexToBytes32(ephemeralPublicKeyHex);186    const session = await noiseNKInitiator(187      remoteStaticPublicKey,188      async (msg) => sock.send(msg),189      () => sock.receive(),190    );191    noise = session;192193    const channel: PairingTransport = {194      async send(plaintext) { sock.send(session.encrypt(plaintext)); },195      async receive() { return session.decrypt(await sock.receive()); },196    };197198    return {199      ok: true,200      dialled: {201        channel,202        sas: derivePairingSAS(session.handshakeHash),203        host,204        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),205        remoteStaticPublicKey,206        authenticateInitiator: (privateKey, publicKey) =>207          performInitiatorAuth(208            session,209            privateKey,210            publicKey,211            remoteStaticPublicKey,212            channel.send,213            channel.receive,214          ),215        socket: {216          send: (bytes) => sock.send(bytes),217          receive: () => sock.receive(),218          close() {219            session.close();220            sock.close();221          },222          closedBeforeFrame: () => sock.closedBeforeFrame(),223        },224      },225    };226  } catch (err) {227    // Coarse for the UI; log the real cause so a failed dial is diagnosable228    // from the service-worker console.229    if (typeof console !== "undefined") console.error("[pairing-dial] handshake failed:", err);230    noise?.close();231    sock.close();232    return { ok: false, error: "handshake_failed" };233  }234}235236/** Adapt a real `WebSocket` to the `PairingSocket` seam (binary frames). */237export function openPairingSocket(ip: string, port: number): Promise<PairingSocket> {238  return openPairingSocketUrl(`ws://${ip}:${port}/`);239}240241/** Relay-first pairing uses the same binary socket seam over `wss://`. */242export function openRelayPairingSocket(url: string): Promise<PairingSocket> {243  return openPairingSocketUrl(url);244}245246function openPairingSocketUrl(url: string): Promise<PairingSocket> {247  return new Promise((resolve, reject) => {248    let ws: WebSocket;249    try {250      ws = new WebSocket(url);251    } catch (err) {252      reject(err instanceof Error ? err : new Error("websocket open failed"));253      return;254    }255    ws.binaryType = "arraybuffer";256257    const inbox: Uint8Array[] = [];258    const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];259    let opened = false;260    let closed = false;261    // True iff the socket closed with no frame delivered for the pending read —262    // the wrong/expired-code signal (the listener sends no error frame).263    let sawCloseBeforeFrame = false;264265    ws.onopen = () => {266      opened = true;267      resolve(sock);268    };269    ws.onmessage = (ev: MessageEvent) => {270      const bytes = new Uint8Array(ev.data as ArrayBuffer);271      const r = readers.shift();272      if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }273      else inbox.push(bytes);274    };275    ws.onerror = () => {276      if (!opened) reject(new Error("websocket connection failed"));277    };278    ws.onclose = () => {279      closed = true;280      if (readers.length) {281        sawCloseBeforeFrame = true;282        while (readers.length) readers.shift()!.reject(new Error("socket closed"));283      }284      if (!opened) reject(new Error("socket closed before open"));285    };286287    const sock: PairingSocket = {288      send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },289      receive(): Promise<Uint8Array> {290        const queued = inbox.shift();291        if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }292        if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }293        return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));294      },295      close() { try { ws.close(); } catch { /* ignore */ } },296      closedBeforeFrame() { return sawCloseBeforeFrame; },297    };298  });299}300301function encodeJson(obj: unknown): Uint8Array {302  return new TextEncoder().encode(JSON.stringify(obj));303}304305function decodeJson<T>(bytes: Uint8Array): T {306  return JSON.parse(new TextDecoder().decode(bytes)) as T;307}308309function hexToBytes32(hex: string): Uint8Array {310  if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");311  const out = new Uint8Array(32);312  for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);313  return out;314}