desktop-pairing.ts

10.5 KB · raw

1// Code-pairing client — the browser always dials out, never listens.2//3// Two code formats reach this module, and they differ ONLY in how the4// endpoint is resolved and what the plaintext hello carries:5//6//   - Pairing code (`A74H-48J3-KI32-OEP4`, pairing-code.ts) — the primary7//     path. The phone is the listener and the code carries its IP, port and8//     one-time token, so there is nothing to discover: dial it directly.9//   - Legacy 6-digit helper code — the speculative `peach-sync` desktop10//     helper, which has no address of its own, so the host is probed for.11//12// Everything after the hello is one protocol, shared by both:13//   1. open ws://<host>:<port>14//   2. send PEACH_HELLO { token } (pairing code) or { code } (legacy) in15//      PLAINTEXT16//   3. await PEACH_HELLO_ACK { ephemeralPublicKeyHex } OR socket close17//        (socket close before ACK == wrong/expired code — the listener sends18//         NO error frame; it silently closes and rotates the code)19//   4. run Noise NK as INITIATOR against the listener's ephemeral key20//   5. exchange PAIR_IDENTITY (send ours incl. vault_id, then read theirs)21//   6. derive the SAS, surface it for user confirmation22//   7. on confirm -> persist the PairedDevice; on reject -> abort without23//        persisting24//25// ROLE: the BROWSER is the NK INITIATOR here — the OPPOSITE of the audio26// chirp flow (chirp-handshake.ts), where the extension is the responder.27// The listener's `ephemeralPublicKeyHex` is the NK responder static `s`.28//29// The browser can never accept an incoming connection (MV3 has no listening30// socket), which is why the dialler role is structural and not a choice.3132import { noiseNKInitiator } from "../shared/noise-js";33import { parsePairingCode } from "../shared/pairing-code";34import { derivePairingSAS } from "../shared/chirp-handshake";35import type { LocalIdentity } from "../shared/chirp-handshake";36import type { DeviceType, PairedDevice } from "../shared/sync-types";3738/** The pairing-only port peach-sync listens on while `peach-sync pair` runs. */39export const DESKTOP_PAIRING_PORT = 49142;40/** Default sync port the helper announces; used when ws_port is absent. */41const DEFAULT_SYNC_PORT = 8765;4243export type DesktopPairingError =44  | "invalid_code"      // socket closed before PEACH_HELLO_ACK (wrong/expired)45  | "no_host_found"     // no WebSocket server answered on the pairing port46  | "handshake_failed"  // Noise NK / PAIR_IDENTITY decode failed47  | "sas_mismatch"      // user rejected the SAS48  | "vault_locked";     // no master key available4950/**51 * Minimal binary WebSocket transport seam. Production wires the real52 * `WebSocket`; tests inject a fake. One binary message per frame.53 */54export interface DesktopPairingSocket {55  send(bytes: Uint8Array): void;56  /** Resolves with the next inbound binary frame, or rejects on close. */57  receive(): Promise<Uint8Array>;58  close(): void;59  /** Resolves true if the socket closed before the next frame arrived. */60  closedBeforeFrame(): boolean;61}6263export interface DesktopPairingDeps {64  /** Change 5 — find a host answering WebSocket on the pairing port. */65  findHost(port: number): Promise<string | null>;66  /** Open a binary WebSocket to ws://<ip>:<port>/ . */67  openSocket(ip: string, port: number): Promise<DesktopPairingSocket>;68  /** Public identity for our outbound PAIR_IDENTITY (incl. vaultId). */69  getLocalIdentity(): Promise<LocalIdentity>;70  /** Persist on user confirm. */71  persistDevice(device: PairedDevice): Promise<void>;72  /** Optional fast-path cache for the next sync round. */73  cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;74}7576export interface PairWithDesktopCallbacks {77  /** Show the 6-digit SAS; the popup must compare against the helper's. */78  onSasReady(sas: string): void;79  /** Pairing succeeded and the device was persisted. */80  onComplete(device: PairedDevice): void;81  /** Pairing failed at some stage. */82  onError(error: DesktopPairingError): void;83}8485interface PairingFrame {86  type: "PAIR_IDENTITY";87  device_id: string;88  device_name: string;89  device_type: DeviceType;90  long_term_public_key_hex: string;91  signing_public_key_hex?: string;92  ws_port?: number;93  vault_id?: string; // Change 1 — additive, optional94}9596function encodeJson(obj: unknown): Uint8Array {97  return new TextEncoder().encode(JSON.stringify(obj));98}99100function decodeJson<T>(bytes: Uint8Array): T {101  return JSON.parse(new TextDecoder().decode(bytes)) as T;102}103104function hexToBytes(hex: string): Uint8Array {105  if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");106  const out = new Uint8Array(32);107  for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);108  return out;109}110111function decodePairIdentity(bytes: Uint8Array): PairingFrame {112  const p = decodeJson<Record<string, unknown>>(bytes);113  if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");114  if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");115  if (typeof p.device_name !== "string") throw new Error("device_name missing");116  if (typeof p.device_type !== "string") throw new Error("device_type missing");117  if (typeof p.long_term_public_key_hex !== "string" ||118      !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {119    throw new Error("long_term_public_key_hex is not 32-byte hex");120  }121  return p as unknown as PairingFrame;122}123124/**125 * Drive a complete desktop-helper code pairing.126 *127 * `confirm` is a function the caller resolves AFTER the user has compared128 * the SAS shown here against the SAS the helper displays. Resolving false129 * (or rejecting) aborts before any device is persisted — this is the130 * out-of-band MITM defence (see SECURITY note in chirp-handshake.ts).131 */132export async function pairWithDesktopHelper(133  code: string,134  cb: PairWithDesktopCallbacks,135  deps: DesktopPairingDeps,136  confirm: (sas: string) => Promise<boolean>,137): Promise<void> {138  const pairing = parsePairingCode(code);139140  let identity: LocalIdentity;141  try {142    identity = await deps.getLocalIdentity();143  } catch {144    cb.onError("vault_locked");145    return;146  }147148  // 1) Resolve the endpoint. A pairing code already carries one; a legacy149  //    helper code does not, so the /24 has to be probed for it.150  const port = pairing?.port ?? DESKTOP_PAIRING_PORT;151  const host = pairing?.ip ?? (await deps.findHost(DESKTOP_PAIRING_PORT));152  if (!host) {153    cb.onError("no_host_found");154    return;155  }156157  // 2) Open the WebSocket.158  let sock: DesktopPairingSocket;159  try {160    sock = await deps.openSocket(host, port);161  } catch {162    cb.onError("no_host_found");163    return;164  }165166  try {167    // 3) Send PEACH_HELLO (PLAINTEXT binary frame). The legacy helper compares168    //    the code byte-for-byte against a [6]u8, so that form is normalised to169    //    exactly 6 zero-padded ASCII digits (ground truth §2); a pairing token170    //    is sent as the hex it decoded to.171    sock.send(encodeJson(172      pairing173        ? { type: "PEACH_HELLO", token: pairing.tokenHex }174        : { type: "PEACH_HELLO", code: String(code).replace(/\D/g, "").padStart(6, "0").slice(-6) },175    ));176177    // 4) Await PEACH_HELLO_ACK OR socket close.178    //    peach-sync sends NO error frame; a wrong/expired code closes the179    //    socket. So "receive() rejected / closed before a frame" == invalid_code.180    let ackBytes: Uint8Array;181    try {182      ackBytes = await sock.receive();183    } catch {184      cb.onError("invalid_code");185      return;186    }187    if (sock.closedBeforeFrame() || ackBytes.length === 0) {188      cb.onError("invalid_code");189      return;190    }191192    let ephemeralPublicKeyHex: string;193    try {194      const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);195      if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {196        // A non-ACK frame on a matching code should not happen; treat the197        // missing ACK exactly like a wrong code.198        cb.onError("invalid_code");199        return;200      }201      ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;202    } catch {203      cb.onError("invalid_code");204      return;205    }206207    // 5) Noise NK as INITIATOR against the helper's ephemeral static key.208    const responderStatic = hexToBytes(ephemeralPublicKeyHex);209    const session = await noiseNKInitiator(210      responderStatic,211      async (msg) => sock.send(msg),212      () => sock.receive(),213    );214215    // 6) Exchange PAIR_IDENTITY — initiator (browser) sends FIRST, then reads.216    const ourFrame: PairingFrame = {217      type: "PAIR_IDENTITY",218      device_id: identity.deviceId,219      device_name: identity.deviceName,220      device_type: identity.deviceType,221      long_term_public_key_hex: identity.longTermPublicKeyHex,222      signing_public_key_hex: identity.signingPublicKeyHex,223      ws_port: identity.wsPort,224      vault_id: identity.vaultId, // Change 1225    };226    sock.send(session.encrypt(encodeJson(ourFrame)));227228    const theirCipher = await sock.receive();229    const theirFrame = decodePairIdentity(session.decrypt(theirCipher));230231    // 7) Derive the SAS (REUSED — same value the helper computes from the232    //    shared handshake hash) and surface it for user confirmation.233    const sas = derivePairingSAS(session.handshakeHash);234    cb.onSasReady(sas);235236    const confirmed = await confirm(sas);237    if (!confirmed) {238      cb.onError("sas_mismatch");239      return;240    }241242    // 8) Persist. ws_port comes from the peer frame, defaulting to 8765 (both243    //    peers announce it, but guard it). The device-type fallback follows the244    //    code form so a phone never lands in the registry labelled "desktop".245    const device: PairedDevice = {246      deviceId: theirFrame.device_id,247      deviceName: theirFrame.device_name,248      deviceType: (theirFrame.device_type as DeviceType) || (pairing ? "android" : "desktop"),249      longTermPublicKeyHex: theirFrame.long_term_public_key_hex,250      signingPublicKeyHex: theirFrame.signing_public_key_hex,251      trustSource: "direct",252      wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,253      pairedAt: Date.now(),254    };255    await deps.persistDevice(device);256    if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {257      await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);258    }259    cb.onComplete(device);260  } catch (err) {261    // The mapped error is intentionally coarse for the UI; log the real cause262    // so a failed pair is diagnosable from the service-worker console.263    if (typeof console !== "undefined") console.error("[desktop-pairing] handshake failed:", err);264    cb.onError("handshake_failed");265  } finally {266    sock.close();267  }268}