pairing-dial.ts

8.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, performInitiatorAuth } 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  /** Static key authenticated by Noise NK. PAIR_IDENTITY must repeat this key. */56  remoteStaticPublicKey: Uint8Array;57  /**58   * Prove possession of the initiator key before any identity or consent frame.59   * Device pairing must call this exactly once; adoption cannot use it until it60   * has a long-term key and remains a separately gated protocol.61   */62  authenticateInitiator(63    privateKey: Uint8Array,64    publicKey: Uint8Array,65  ): Promise<void>;66  socket: PairingSocket;67}6869export type DialResult =70  | { ok: true; dialled: DialledPairingChannel }71  | { ok: false; error: PairingDialError };7273/**74 * Dial a pairing code and bring up the encrypted channel behind it.75 *76 * On success the socket is left OPEN and owned by the caller, which must close77 * it. On failure it is closed here.78 */79export async function dialPairingChannel(80  code: string,81  deps: PairingDialDeps,82): Promise<DialResult> {83  const pairing = parsePairingCode(code);84  if (!pairing) return { ok: false, error: "invalid_code" };85  const { ip: host, port } = pairing;8687  let sock: PairingSocket;88  try {89    sock = await deps.openSocket(host, port);90  } catch {91    return { ok: false, error: "no_host_found" };92  }9394  try {95    sock.send(encodeJson({ type: "PEACH_HELLO", token: pairing.tokenHex }));9697    // A wrong or expired code gets NO error frame — the listener silently98    // closes and rotates. That silence IS the wrong-code signal.99    let ackBytes: Uint8Array;100    try {101      ackBytes = await sock.receive();102    } catch {103      sock.close();104      return { ok: false, error: "invalid_code" };105    }106    if (sock.closedBeforeFrame() || ackBytes.length === 0) {107      sock.close();108      return { ok: false, error: "invalid_code" };109    }110111    let ephemeralPublicKeyHex: string;112    try {113      const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);114      if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {115        // A non-ACK frame on a matching code should not happen; treat a missing116        // ACK exactly like a wrong code.117        sock.close();118        return { ok: false, error: "invalid_code" };119      }120      ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;121    } catch {122      sock.close();123      return { ok: false, error: "invalid_code" };124    }125126    const remoteStaticPublicKey = hexToBytes32(ephemeralPublicKeyHex);127    const session = await noiseNKInitiator(128      remoteStaticPublicKey,129      async (msg) => sock.send(msg),130      () => sock.receive(),131    );132133    const channel: PairingTransport = {134      async send(plaintext) { sock.send(session.encrypt(plaintext)); },135      async receive() { return session.decrypt(await sock.receive()); },136    };137138    return {139      ok: true,140      dialled: {141        channel,142        sas: derivePairingSAS(session.handshakeHash),143        host,144        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),145        remoteStaticPublicKey,146        authenticateInitiator: (privateKey, publicKey) =>147          performInitiatorAuth(148            session,149            privateKey,150            publicKey,151            remoteStaticPublicKey,152            channel.send,153            channel.receive,154          ),155        socket: sock,156      },157    };158  } catch (err) {159    // Coarse for the UI; log the real cause so a failed dial is diagnosable160    // from the service-worker console.161    if (typeof console !== "undefined") console.error("[pairing-dial] handshake failed:", err);162    sock.close();163    return { ok: false, error: "handshake_failed" };164  }165}166167/** Adapt a real `WebSocket` to the `PairingSocket` seam (binary frames). */168export function openPairingSocket(ip: string, port: number): Promise<PairingSocket> {169  return new Promise((resolve, reject) => {170    let ws: WebSocket;171    try {172      ws = new WebSocket(`ws://${ip}:${port}/`);173    } catch (err) {174      reject(err instanceof Error ? err : new Error("websocket open failed"));175      return;176    }177    ws.binaryType = "arraybuffer";178179    const inbox: Uint8Array[] = [];180    const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];181    let opened = false;182    let closed = false;183    // True iff the socket closed with no frame delivered for the pending read —184    // the wrong/expired-code signal (the listener sends no error frame).185    let sawCloseBeforeFrame = false;186187    ws.onopen = () => {188      opened = true;189      resolve(sock);190    };191    ws.onmessage = (ev: MessageEvent) => {192      const bytes = new Uint8Array(ev.data as ArrayBuffer);193      const r = readers.shift();194      if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }195      else inbox.push(bytes);196    };197    ws.onerror = () => {198      if (!opened) reject(new Error("websocket connection failed"));199    };200    ws.onclose = () => {201      closed = true;202      if (readers.length) {203        sawCloseBeforeFrame = true;204        while (readers.length) readers.shift()!.reject(new Error("socket closed"));205      }206      if (!opened) reject(new Error("socket closed before open"));207    };208209    const sock: PairingSocket = {210      send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },211      receive(): Promise<Uint8Array> {212        const queued = inbox.shift();213        if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }214        if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }215        return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));216      },217      close() { try { ws.close(); } catch { /* ignore */ } },218      closedBeforeFrame() { return sawCloseBeforeFrame; },219    };220  });221}222223function encodeJson(obj: unknown): Uint8Array {224  return new TextEncoder().encode(JSON.stringify(obj));225}226227function decodeJson<T>(bytes: Uint8Array): T {228  return JSON.parse(new TextDecoder().decode(bytes)) as T;229}230231function hexToBytes32(hex: string): Uint8Array {232  if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");233  const out = new Uint8Array(32);234  for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);235  return out;236}