pairing-dial.ts

10.0 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, performInitiatorAuth } from "./noise-js";17import { derivePairingSAS, type PairingTransport } from "./pairing-handshake";18import { parsePairingCode } from "./pairing-code";19import {20  pairingInvitationUrl,21  parsePairingInvitation,22} from "./pairing-invitation";23import { bytesToHex, deriveRendezvousSecret } from "./rendezvous";2425/** Default pairing port used by native listeners. */26export const PAIRING_PORT = 49142;2728export type PairingDialError =29  | "invalid_code" // socket closed before PEACH_HELLO_ACK (wrong/expired)30  | "no_host_found" // nothing answered at the endpoint31  | "handshake_failed"; // Noise NK failed or the ACK was malformed3233/**34 * Minimal binary WebSocket seam. Production wires the real `WebSocket`; tests35 * inject a fake. One binary message per frame.36 */37export interface PairingSocket {38  send(bytes: Uint8Array): void;39  /** Resolves with the next inbound binary frame, or rejects on close. */40  receive(): Promise<Uint8Array>;41  close(): void;42  /** True if the socket closed before the next frame arrived. */43  closedBeforeFrame(): boolean;44}4546export interface PairingDialDeps {47  openSocket(ip: string, port: number): Promise<PairingSocket>;48}4950export interface PairingDialOptions {51  flow?: "adoption";52}5354export interface RelayPairingDialDeps {55  openSocket(url: string): Promise<PairingSocket>;56}5758export interface DialledPairingChannel {59  /** Encrypted channel — everything written to it is Noise-protected. */60  channel: PairingTransport;61  /** The 6-digit code the user must confirm matches the listener's screen. */62  sas: string;63  /** The address actually connected to, for the reconnect cache. */64  host: string;65  /** Present only for relay-first pairing/adoption. */66  relayUrl?: string;67  /** Pair-only reconnect capability derived independently from the SAS. */68  rendezvousSecretHex: string;69  /** Static key authenticated by Noise NK. PAIR_IDENTITY must repeat this key. */70  remoteStaticPublicKey: Uint8Array;71  /**72   * Prove possession of the initiator key before any identity or consent frame.73   * Device pairing must call this exactly once; adoption cannot use it until it74   * has a long-term key and remains a separately gated protocol.75   */76  authenticateInitiator(77    privateKey: Uint8Array,78    publicKey: Uint8Array,79  ): Promise<void>;80  socket: PairingSocket;81}8283export type DialResult =84  | { ok: true; dialled: DialledPairingChannel }85  | { ok: false; error: PairingDialError };8687/**88 * Dial a pairing code and bring up the encrypted channel behind it.89 *90 * On success the socket is left OPEN and owned by the caller, which must close91 * it. On failure it is closed here.92 */93export async function dialPairingChannel(94  code: string,95  deps: PairingDialDeps,96  options: PairingDialOptions = {},97): Promise<DialResult> {98  const pairing = parsePairingCode(code);99  if (!pairing) return { ok: false, error: "invalid_code" };100  const { ip: host, port } = pairing;101102  let sock: PairingSocket;103  try {104    sock = await deps.openSocket(host, port);105  } catch {106    return { ok: false, error: "no_host_found" };107  }108109  return finishPairingHandshake(sock, pairing.tokenHex, host, options.flow);110}111112/**113 * Dial a relay-first invitation. The source must already occupy the room114 * because the relay deliberately buffers nothing.115 */116export async function dialRelayPairingChannel(117  encodedInvitation: string,118  deps: RelayPairingDialDeps,119): Promise<DialResult> {120  const invitation = parsePairingInvitation(encodedInvitation);121  if (!invitation) return { ok: false, error: "invalid_code" };122123  let sock: PairingSocket;124  try {125    sock = await deps.openSocket(pairingInvitationUrl(invitation));126  } catch {127    return { ok: false, error: "no_host_found" };128  }129  const result = await finishPairingHandshake(130    sock,131    invitation.token,132    new URL(invitation.relayUrl).hostname,133  );134  if (result.ok) result.dialled.relayUrl = invitation.relayUrl;135  return result;136}137138async function finishPairingHandshake(139  sock: PairingSocket,140  token: string,141  host: string,142  flow?: "adoption",143): Promise<DialResult> {144  try {145    sock.send(encodeJson({146      type: "PEACH_HELLO",147      token,148      ...(flow ? { flow } : {}),149    }));150151    // A wrong or expired code gets NO error frame — the listener silently152    // closes and rotates. That silence IS the wrong-code signal.153    let ackBytes: Uint8Array;154    try {155      ackBytes = await sock.receive();156    } catch {157      sock.close();158      return { ok: false, error: "invalid_code" };159    }160    if (sock.closedBeforeFrame() || ackBytes.length === 0) {161      sock.close();162      return { ok: false, error: "invalid_code" };163    }164165    let ephemeralPublicKeyHex: string;166    try {167      const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);168      if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {169        // A non-ACK frame on a matching code should not happen; treat a missing170        // ACK exactly like a wrong code.171        sock.close();172        return { ok: false, error: "invalid_code" };173      }174      ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;175    } catch {176      sock.close();177      return { ok: false, error: "invalid_code" };178    }179180    const remoteStaticPublicKey = hexToBytes32(ephemeralPublicKeyHex);181    const session = await noiseNKInitiator(182      remoteStaticPublicKey,183      async (msg) => sock.send(msg),184      () => sock.receive(),185    );186187    const channel: PairingTransport = {188      async send(plaintext) { sock.send(session.encrypt(plaintext)); },189      async receive() { return session.decrypt(await sock.receive()); },190    };191192    return {193      ok: true,194      dialled: {195        channel,196        sas: derivePairingSAS(session.handshakeHash),197        host,198        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),199        remoteStaticPublicKey,200        authenticateInitiator: (privateKey, publicKey) =>201          performInitiatorAuth(202            session,203            privateKey,204            publicKey,205            remoteStaticPublicKey,206            channel.send,207            channel.receive,208          ),209        socket: sock,210      },211    };212  } catch (err) {213    // Coarse for the UI; log the real cause so a failed dial is diagnosable214    // from the service-worker console.215    if (typeof console !== "undefined") console.error("[pairing-dial] handshake failed:", err);216    sock.close();217    return { ok: false, error: "handshake_failed" };218  }219}220221/** Adapt a real `WebSocket` to the `PairingSocket` seam (binary frames). */222export function openPairingSocket(ip: string, port: number): Promise<PairingSocket> {223  return openPairingSocketUrl(`ws://${ip}:${port}/`);224}225226/** Relay-first pairing uses the same binary socket seam over `wss://`. */227export function openRelayPairingSocket(url: string): Promise<PairingSocket> {228  return openPairingSocketUrl(url);229}230231function openPairingSocketUrl(url: string): Promise<PairingSocket> {232  return new Promise((resolve, reject) => {233    let ws: WebSocket;234    try {235      ws = new WebSocket(url);236    } catch (err) {237      reject(err instanceof Error ? err : new Error("websocket open failed"));238      return;239    }240    ws.binaryType = "arraybuffer";241242    const inbox: Uint8Array[] = [];243    const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];244    let opened = false;245    let closed = false;246    // True iff the socket closed with no frame delivered for the pending read —247    // the wrong/expired-code signal (the listener sends no error frame).248    let sawCloseBeforeFrame = false;249250    ws.onopen = () => {251      opened = true;252      resolve(sock);253    };254    ws.onmessage = (ev: MessageEvent) => {255      const bytes = new Uint8Array(ev.data as ArrayBuffer);256      const r = readers.shift();257      if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }258      else inbox.push(bytes);259    };260    ws.onerror = () => {261      if (!opened) reject(new Error("websocket connection failed"));262    };263    ws.onclose = () => {264      closed = true;265      if (readers.length) {266        sawCloseBeforeFrame = true;267        while (readers.length) readers.shift()!.reject(new Error("socket closed"));268      }269      if (!opened) reject(new Error("socket closed before open"));270    };271272    const sock: PairingSocket = {273      send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },274      receive(): Promise<Uint8Array> {275        const queued = inbox.shift();276        if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }277        if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }278        return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));279      },280      close() { try { ws.close(); } catch { /* ignore */ } },281      closedBeforeFrame() { return sawCloseBeforeFrame; },282    };283  });284}285286function encodeJson(obj: unknown): Uint8Array {287  return new TextEncoder().encode(JSON.stringify(obj));288}289290function decodeJson<T>(bytes: Uint8Array): T {291  return JSON.parse(new TextDecoder().decode(bytes)) as T;292}293294function hexToBytes32(hex: string): Uint8Array {295  if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");296  const out = new Uint8Array(32);297  for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);298  return out;299}