pairing-dial.ts

9.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 {17  noiseNKInitiator,18  performInitiatorAuth,19  type NoiseNKSession,20} from "./noise-js";21import { derivePairingSAS, type PairingTransport } from "./pairing-handshake";22import {23  pairingInvitationUrl,24  parsePairingInvitation,25} from "./pairing-invitation";26import { bytesToHex, deriveRendezvousSecret } from "./rendezvous";2728/** Default pairing port used by native listeners. */29/**30 * The port Android's reconnect listener used to accept on. Retained only31 * because paired records may still carry a `wsPort`; nothing dials it. LAN32 * transport is retired — see docs/sync-lan-retirement.md.33 */34export const LEGACY_LAN_PORT = 49142;3536export type PairingDialError =37  | "invalid_code" // socket closed before PEACH_HELLO_ACK (wrong/expired)38  | "no_host_found" // nothing answered at the endpoint39  | "handshake_failed"; // Noise NK failed or the ACK was malformed4041/**42 * Minimal binary WebSocket seam. Production wires the real `WebSocket`; tests43 * inject a fake. One binary message per frame.44 */45export interface PairingSocket {46  send(bytes: Uint8Array): void;47  /** Resolves with the next inbound binary frame, or rejects on close. */48  receive(): Promise<Uint8Array>;49  close(): void;50  /** True if the socket closed before the next frame arrived. */51  closedBeforeFrame(): boolean;52}5354export interface PairingDialDeps {55  openSocket(ip: string, port: number): Promise<PairingSocket>;56}5758export interface PairingDialOptions {59  flow?: "adoption";60}6162export interface RelayPairingDialDeps {63  openSocket(url: string): Promise<PairingSocket>;64}6566export interface DialledPairingChannel {67  /** Encrypted channel — everything written to it is Noise-protected. */68  channel: PairingTransport;69  /** The 8-digit code the user must confirm matches the listener's screen. */70  sas: string;71  /** The address actually connected to, for the reconnect cache. */72  host: string;73  /** Present only for relay-first pairing/adoption. */74  relayUrl?: string;75  /** Pair-only reconnect capability derived independently from the SAS. */76  rendezvousSecretHex: string;77  /** Static key authenticated by Noise NK. PAIR_IDENTITY must repeat this key. */78  remoteStaticPublicKey: Uint8Array;79  /**80   * Prove possession of the initiator key before any identity or consent frame.81   * Device pairing must call this exactly once; adoption cannot use it until it82   * has a long-term key and remains a separately gated protocol.83   */84  authenticateInitiator(85    privateKey: Uint8Array,86    publicKey: Uint8Array,87  ): Promise<void>;88  socket: PairingSocket;89}9091export type DialResult =92  | { ok: true; dialled: DialledPairingChannel }93  | { ok: false; error: PairingDialError };9495export async function dialRelayPairingChannel(96  encodedInvitation: string,97  deps: RelayPairingDialDeps,98): Promise<DialResult> {99  const invitation = parsePairingInvitation(encodedInvitation);100  if (!invitation) return { ok: false, error: "invalid_code" };101102  let sock: PairingSocket;103  try {104    sock = await deps.openSocket(pairingInvitationUrl(invitation));105  } catch {106    return { ok: false, error: "no_host_found" };107  }108  const result = await finishPairingHandshake(109    sock,110    invitation.token,111    new URL(invitation.relayUrl).hostname,112  );113  if (result.ok) result.dialled.relayUrl = invitation.relayUrl;114  return result;115}116117async function finishPairingHandshake(118  sock: PairingSocket,119  token: string,120  host: string,121  flow?: "adoption",122): Promise<DialResult> {123  let noise: NoiseNKSession | undefined;124  try {125    sock.send(encodeJson({126      type: "PEACH_HELLO",127      token,128      ...(flow ? { flow } : {}),129    }));130131    // A wrong or expired code gets NO error frame — the listener silently132    // closes and rotates. That silence IS the wrong-code signal.133    let ackBytes: Uint8Array;134    try {135      ackBytes = await sock.receive();136    } catch {137      sock.close();138      return { ok: false, error: "invalid_code" };139    }140    if (sock.closedBeforeFrame() || ackBytes.length === 0) {141      sock.close();142      return { ok: false, error: "invalid_code" };143    }144145    let ephemeralPublicKeyHex: string;146    try {147      const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);148      if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {149        // A non-ACK frame on a matching code should not happen; treat a missing150        // ACK exactly like a wrong code.151        sock.close();152        return { ok: false, error: "invalid_code" };153      }154      ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;155    } catch {156      sock.close();157      return { ok: false, error: "invalid_code" };158    }159160    const remoteStaticPublicKey = hexToBytes32(ephemeralPublicKeyHex);161    const session = await noiseNKInitiator(162      remoteStaticPublicKey,163      async (msg) => sock.send(msg),164      () => sock.receive(),165    );166    noise = session;167168    const channel: PairingTransport = {169      async send(plaintext) { sock.send(session.encrypt(plaintext)); },170      async receive() { return session.decrypt(await sock.receive()); },171    };172173    return {174      ok: true,175      dialled: {176        channel,177        sas: derivePairingSAS(session.handshakeHash),178        host,179        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),180        remoteStaticPublicKey,181        authenticateInitiator: (privateKey, publicKey) =>182          performInitiatorAuth(183            session,184            privateKey,185            publicKey,186            remoteStaticPublicKey,187            channel.send,188            channel.receive,189          ),190        socket: {191          send: (bytes) => sock.send(bytes),192          receive: () => sock.receive(),193          close() {194            session.close();195            sock.close();196          },197          closedBeforeFrame: () => sock.closedBeforeFrame(),198        },199      },200    };201  } catch (err) {202    // Coarse for the UI; log the real cause so a failed dial is diagnosable203    // from the service-worker console.204    if (typeof console !== "undefined") console.error("[pairing-dial] handshake failed:", err);205    noise?.close();206    sock.close();207    return { ok: false, error: "handshake_failed" };208  }209}210211/** Relay-first pairing uses the same binary socket seam over `wss://`. */212export function openRelayPairingSocket(url: string): Promise<PairingSocket> {213  return openPairingSocketUrl(url);214}215216function openPairingSocketUrl(url: string): Promise<PairingSocket> {217  return new Promise((resolve, reject) => {218    let ws: WebSocket;219    try {220      ws = new WebSocket(url);221    } catch (err) {222      reject(err instanceof Error ? err : new Error("websocket open failed"));223      return;224    }225    ws.binaryType = "arraybuffer";226227    const inbox: Uint8Array[] = [];228    const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];229    let opened = false;230    let closed = false;231    // True iff the socket closed with no frame delivered for the pending read —232    // the wrong/expired-code signal (the listener sends no error frame).233    let sawCloseBeforeFrame = false;234235    ws.onopen = () => {236      opened = true;237      resolve(sock);238    };239    ws.onmessage = (ev: MessageEvent) => {240      const bytes = new Uint8Array(ev.data as ArrayBuffer);241      const r = readers.shift();242      if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }243      else inbox.push(bytes);244    };245    ws.onerror = () => {246      if (!opened) reject(new Error("websocket connection failed"));247    };248    ws.onclose = () => {249      closed = true;250      if (readers.length) {251        sawCloseBeforeFrame = true;252        while (readers.length) readers.shift()!.reject(new Error("socket closed"));253      }254      if (!opened) reject(new Error("socket closed before open"));255    };256257    const sock: PairingSocket = {258      send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },259      receive(): Promise<Uint8Array> {260        const queued = inbox.shift();261        if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }262        if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }263        return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));264      },265      close() { try { ws.close(); } catch { /* ignore */ } },266      closedBeforeFrame() { return sawCloseBeforeFrame; },267    };268  });269}270271function encodeJson(obj: unknown): Uint8Array {272  return new TextEncoder().encode(JSON.stringify(obj));273}274275function decodeJson<T>(bytes: Uint8Array): T {276  return JSON.parse(new TextDecoder().decode(bytes)) as T;277}278279function hexToBytes32(hex: string): Uint8Array {280  if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");281  const out = new Uint8Array(32);282  for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);283  return out;284}