pairing-dial.ts

7.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 { noiseNKInitiator } from "./noise-js";17import { derivePairingSAS, type PairingTransport } from "./pairing-handshake";18import { parsePairingCode } from "./pairing-code";1920/** Default pairing port used by native listeners. */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  openSocket(ip: string, port: number): Promise<PairingSocket>;43}4445export interface DialledPairingChannel {46  /** Encrypted channel — everything written to it is Noise-protected. */47  channel: PairingTransport;48  /** The 6-digit code the user must confirm matches the listener's screen. */49  sas: string;50  /** The address actually connected to, for the reconnect cache. */51  host: string;52  socket: PairingSocket;53}5455export type DialResult =56  | { ok: true; dialled: DialledPairingChannel }57  | { ok: false; error: PairingDialError };5859/**60 * Dial a pairing code and bring up the encrypted channel behind it.61 *62 * On success the socket is left OPEN and owned by the caller, which must close63 * it. On failure it is closed here.64 */65export async function dialPairingChannel(66  code: string,67  deps: PairingDialDeps,68): Promise<DialResult> {69  const pairing = parsePairingCode(code);70  if (!pairing) return { ok: false, error: "invalid_code" };71  const { ip: host, port } = pairing;7273  let sock: PairingSocket;74  try {75    sock = await deps.openSocket(host, port);76  } catch {77    return { ok: false, error: "no_host_found" };78  }7980  try {81    sock.send(encodeJson({ type: "PEACH_HELLO", token: pairing.tokenHex }));8283    // A wrong or expired code gets NO error frame — the listener silently84    // closes and rotates. That silence IS the wrong-code signal.85    let ackBytes: Uint8Array;86    try {87      ackBytes = await sock.receive();88    } catch {89      sock.close();90      return { ok: false, error: "invalid_code" };91    }92    if (sock.closedBeforeFrame() || ackBytes.length === 0) {93      sock.close();94      return { ok: false, error: "invalid_code" };95    }9697    let ephemeralPublicKeyHex: string;98    try {99      const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);100      if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {101        // A non-ACK frame on a matching code should not happen; treat a missing102        // ACK exactly like a wrong code.103        sock.close();104        return { ok: false, error: "invalid_code" };105      }106      ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;107    } catch {108      sock.close();109      return { ok: false, error: "invalid_code" };110    }111112    const session = await noiseNKInitiator(113      hexToBytes32(ephemeralPublicKeyHex),114      async (msg) => sock.send(msg),115      () => sock.receive(),116    );117118    const channel: PairingTransport = {119      async send(plaintext) { sock.send(session.encrypt(plaintext)); },120      async receive() { return session.decrypt(await sock.receive()); },121    };122123    return {124      ok: true,125      dialled: {126        channel,127        sas: derivePairingSAS(session.handshakeHash),128        host,129        socket: sock,130      },131    };132  } catch (err) {133    // Coarse for the UI; log the real cause so a failed dial is diagnosable134    // from the service-worker console.135    if (typeof console !== "undefined") console.error("[pairing-dial] handshake failed:", err);136    sock.close();137    return { ok: false, error: "handshake_failed" };138  }139}140141/** Adapt a real `WebSocket` to the `PairingSocket` seam (binary frames). */142export function openPairingSocket(ip: string, port: number): Promise<PairingSocket> {143  return new Promise((resolve, reject) => {144    let ws: WebSocket;145    try {146      ws = new WebSocket(`ws://${ip}:${port}/`);147    } catch (err) {148      reject(err instanceof Error ? err : new Error("websocket open failed"));149      return;150    }151    ws.binaryType = "arraybuffer";152153    const inbox: Uint8Array[] = [];154    const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];155    let opened = false;156    let closed = false;157    // True iff the socket closed with no frame delivered for the pending read —158    // the wrong/expired-code signal (the listener sends no error frame).159    let sawCloseBeforeFrame = false;160161    ws.onopen = () => {162      opened = true;163      resolve(sock);164    };165    ws.onmessage = (ev: MessageEvent) => {166      const bytes = new Uint8Array(ev.data as ArrayBuffer);167      const r = readers.shift();168      if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }169      else inbox.push(bytes);170    };171    ws.onerror = () => {172      if (!opened) reject(new Error("websocket connection failed"));173    };174    ws.onclose = () => {175      closed = true;176      if (readers.length) {177        sawCloseBeforeFrame = true;178        while (readers.length) readers.shift()!.reject(new Error("socket closed"));179      }180      if (!opened) reject(new Error("socket closed before open"));181    };182183    const sock: PairingSocket = {184      send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },185      receive(): Promise<Uint8Array> {186        const queued = inbox.shift();187        if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }188        if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }189        return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));190      },191      close() { try { ws.close(); } catch { /* ignore */ } },192      closedBeforeFrame() { return sawCloseBeforeFrame; },193    };194  });195}196197function encodeJson(obj: unknown): Uint8Array {198  return new TextEncoder().encode(JSON.stringify(obj));199}200201function decodeJson<T>(bytes: Uint8Array): T {202  return JSON.parse(new TextDecoder().decode(bytes)) as T;203}204205function hexToBytes32(hex: string): Uint8Array {206  if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");207  const out = new Uint8Array(32);208  for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);209  return out;210}