desktop-pairing.ts

9.2 KB · raw

1// Desktop helper code-pairing client (Change 3).2//3// Pairs the extension with the `peach-sync` desktop helper:4//   1. probe the /24 for a host accepting WebSocket on the pairing port5//   2. open ws://<host>:491426//   3. send PEACH_HELLO { code } in PLAINTEXT7//   4. await PEACH_HELLO_ACK { ephemeralPublicKeyHex } OR socket close8//        (socket close before ACK == wrong/expired code — peach-sync sends9//         NO error frame; it silently closes and rotates the code)10//   5. run Noise NK as INITIATOR against the helper's ephemeral key11//   6. exchange PAIR_IDENTITY (send ours incl. vault_id, then read theirs)12//   7. derive the SAS, surface it for user confirmation13//   8. on confirm -> persist the PairedDevice (deviceType "desktop"); on14//        reject -> abort without persisting15//16// ROLE: the BROWSER is the NK INITIATOR here — the OPPOSITE of the audio17// chirp flow (chirp-handshake.ts), where the extension is the responder.18// The helper's `ephemeralPublicKeyHex` is the NK responder static `s`.1920import { noiseNKInitiator } from "../shared/noise-js";21import { derivePairingSAS } from "../shared/chirp-handshake";22import type { LocalIdentity } from "../shared/chirp-handshake";23import type { DeviceType, PairedDevice } from "../shared/sync-types";2425/** The pairing-only port peach-sync listens on while `peach-sync pair` runs. */26export const DESKTOP_PAIRING_PORT = 49142;27/** Default sync port the helper announces; used when ws_port is absent. */28const DEFAULT_SYNC_PORT = 8765;2930export type DesktopPairingError =31  | "invalid_code"      // socket closed before PEACH_HELLO_ACK (wrong/expired)32  | "no_host_found"     // no WebSocket server answered on the pairing port33  | "handshake_failed"  // Noise NK / PAIR_IDENTITY decode failed34  | "sas_mismatch"      // user rejected the SAS35  | "vault_locked";     // no master key available3637/**38 * Minimal binary WebSocket transport seam. Production wires the real39 * `WebSocket`; tests inject a fake. One binary message per frame.40 */41export interface DesktopPairingSocket {42  send(bytes: Uint8Array): void;43  /** Resolves with the next inbound binary frame, or rejects on close. */44  receive(): Promise<Uint8Array>;45  close(): void;46  /** Resolves true if the socket closed before the next frame arrived. */47  closedBeforeFrame(): boolean;48}4950export interface DesktopPairingDeps {51  /** Change 5 — find a host answering WebSocket on the pairing port. */52  findHost(port: number): Promise<string | null>;53  /** Open a binary WebSocket to ws://<ip>:<port>/ . */54  openSocket(ip: string, port: number): Promise<DesktopPairingSocket>;55  /** Public identity for our outbound PAIR_IDENTITY (incl. vaultId). */56  getLocalIdentity(): Promise<LocalIdentity>;57  /** Persist on user confirm. */58  persistDevice(device: PairedDevice): Promise<void>;59  /** Optional fast-path cache for the next sync round. */60  cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;61}6263export interface PairWithDesktopCallbacks {64  /** Show the 6-digit SAS; the popup must compare against the helper's. */65  onSasReady(sas: string): void;66  /** Pairing succeeded and the device was persisted. */67  onComplete(device: PairedDevice): void;68  /** Pairing failed at some stage. */69  onError(error: DesktopPairingError): void;70}7172interface PairingFrame {73  type: "PAIR_IDENTITY";74  device_id: string;75  device_name: string;76  device_type: DeviceType;77  long_term_public_key_hex: string;78  signing_public_key_hex?: string;79  ws_port?: number;80  vault_id?: string; // Change 1 — additive, optional81}8283function encodeJson(obj: unknown): Uint8Array {84  return new TextEncoder().encode(JSON.stringify(obj));85}8687function decodeJson<T>(bytes: Uint8Array): T {88  return JSON.parse(new TextDecoder().decode(bytes)) as T;89}9091function hexToBytes(hex: string): Uint8Array {92  if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");93  const out = new Uint8Array(32);94  for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);95  return out;96}9798function decodePairIdentity(bytes: Uint8Array): PairingFrame {99  const p = decodeJson<Record<string, unknown>>(bytes);100  if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");101  if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");102  if (typeof p.device_name !== "string") throw new Error("device_name missing");103  if (typeof p.device_type !== "string") throw new Error("device_type missing");104  if (typeof p.long_term_public_key_hex !== "string" ||105      !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {106    throw new Error("long_term_public_key_hex is not 32-byte hex");107  }108  return p as unknown as PairingFrame;109}110111/**112 * Drive a complete desktop-helper code pairing.113 *114 * `confirm` is a function the caller resolves AFTER the user has compared115 * the SAS shown here against the SAS the helper displays. Resolving false116 * (or rejecting) aborts before any device is persisted — this is the117 * out-of-band MITM defence (see SECURITY note in chirp-handshake.ts).118 */119export async function pairWithDesktopHelper(120  code: string,121  cb: PairWithDesktopCallbacks,122  deps: DesktopPairingDeps,123  confirm: (sas: string) => Promise<boolean>,124): Promise<void> {125  // The helper compares the code byte-for-byte against a [6]u8. Normalise to126  // exactly 6 ASCII digits, zero-padded, no separators (ground truth §2).127  const normalizedCode = String(code).replace(/\D/g, "").padStart(6, "0").slice(-6);128129  let identity: LocalIdentity;130  try {131    identity = await deps.getLocalIdentity();132  } catch {133    cb.onError("vault_locked");134    return;135  }136137  // 1) Find a host on the pairing port.138  const host = await deps.findHost(DESKTOP_PAIRING_PORT);139  if (!host) {140    cb.onError("no_host_found");141    return;142  }143144  // 2) Open the WebSocket.145  let sock: DesktopPairingSocket;146  try {147    sock = await deps.openSocket(host, DESKTOP_PAIRING_PORT);148  } catch {149    cb.onError("no_host_found");150    return;151  }152153  try {154    // 3) Send PEACH_HELLO (PLAINTEXT binary frame).155    sock.send(encodeJson({ type: "PEACH_HELLO", code: normalizedCode }));156157    // 4) Await PEACH_HELLO_ACK OR socket close.158    //    peach-sync sends NO error frame; a wrong/expired code closes the159    //    socket. So "receive() rejected / closed before a frame" == invalid_code.160    let ackBytes: Uint8Array;161    try {162      ackBytes = await sock.receive();163    } catch {164      cb.onError("invalid_code");165      return;166    }167    if (sock.closedBeforeFrame() || ackBytes.length === 0) {168      cb.onError("invalid_code");169      return;170    }171172    let ephemeralPublicKeyHex: string;173    try {174      const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);175      if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {176        // A non-ACK frame on a matching code should not happen; treat the177        // missing ACK exactly like a wrong code.178        cb.onError("invalid_code");179        return;180      }181      ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;182    } catch {183      cb.onError("invalid_code");184      return;185    }186187    // 5) Noise NK as INITIATOR against the helper's ephemeral static key.188    const responderStatic = hexToBytes(ephemeralPublicKeyHex);189    const session = await noiseNKInitiator(190      responderStatic,191      async (msg) => sock.send(msg),192      () => sock.receive(),193    );194195    // 6) Exchange PAIR_IDENTITY — initiator (browser) sends FIRST, then reads.196    const ourFrame: PairingFrame = {197      type: "PAIR_IDENTITY",198      device_id: identity.deviceId,199      device_name: identity.deviceName,200      device_type: identity.deviceType,201      long_term_public_key_hex: identity.longTermPublicKeyHex,202      signing_public_key_hex: identity.signingPublicKeyHex,203      ws_port: identity.wsPort,204      vault_id: identity.vaultId, // Change 1205    };206    sock.send(session.encrypt(encodeJson(ourFrame)));207208    const theirCipher = await sock.receive();209    const theirFrame = decodePairIdentity(session.decrypt(theirCipher));210211    // 7) Derive the SAS (REUSED — same value the helper computes from the212    //    shared handshake hash) and surface it for user confirmation.213    const sas = derivePairingSAS(session.handshakeHash);214    cb.onSasReady(sas);215216    const confirmed = await confirm(sas);217    if (!confirmed) {218      cb.onError("sas_mismatch");219      return;220    }221222    // 8) Persist. deviceType "desktop"; ws_port from the helper frame,223    //    defaulting to 8765 (the helper always announces 8765, but guard it).224    const device: PairedDevice = {225      deviceId: theirFrame.device_id,226      deviceName: theirFrame.device_name,227      deviceType: (theirFrame.device_type as DeviceType) || "desktop",228      longTermPublicKeyHex: theirFrame.long_term_public_key_hex,229      signingPublicKeyHex: theirFrame.signing_public_key_hex,230      trustSource: "direct",231      wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,232      pairedAt: Date.now(),233    };234    await deps.persistDevice(device);235    if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {236      await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);237    }238    cb.onComplete(device);239  } catch {240    cb.onError("handshake_failed");241  } finally {242    sock.close();243  }244}