desktop-pairing.ts

6.3 KB · raw

1// Device pairing over a code — the PAIR_IDENTITY half.2//3// The dialling half (decode the code, connect, plaintext hello, Noise NK) lives4// in `shared/pairing-dial.ts` and is shared with vault adoption, which speaks5// ADOPT_* frames over the same channel instead. This module is only what6// happens once that channel is open:7//8//   1. exchange PAIR_IDENTITY (send ours incl. vault_id, then read theirs)9//   2. derive the SAS, surface it for user confirmation10//   3. on confirm -> persist the PairedDevice; on reject -> abort without11//      persisting12//13// Two code forms reach here. A pairing code (`A74H-48J3-KI32-OEP4`) is the14// primary path: the phone listens and the code carries its address. A legacy15// 6-digit helper code has no address, so the host is probed for.1617import {18  dialPairingChannel,19  PAIRING_PORT,20  type PairingDialDeps,21  type PairingSocket,22} from "../shared/pairing-dial";23import type { LocalIdentity } from "../shared/chirp-handshake";24import { parsePairingCode } from "../shared/pairing-code";25import type { DeviceType, PairedDevice } from "../shared/sync-types";2627/** The pairing-only port a legacy helper code implies. */28export const DESKTOP_PAIRING_PORT = PAIRING_PORT;29/** Default sync port a peer announces; used when ws_port is absent. */30const DEFAULT_SYNC_PORT = 8765;3132export type DesktopPairingError =33  | "invalid_code"      // socket closed before PEACH_HELLO_ACK (wrong/expired)34  | "no_host_found"     // nothing answered at the endpoint35  | "handshake_failed"  // Noise NK / PAIR_IDENTITY decode failed36  | "sas_mismatch"      // user rejected the SAS37  | "vault_locked";     // no master key available3839/** Re-exported so existing callers and specs keep one name for the seam. */40export type DesktopPairingSocket = PairingSocket;4142export interface DesktopPairingDeps extends PairingDialDeps {43  /** Public identity for our outbound PAIR_IDENTITY (incl. vaultId). */44  getLocalIdentity(): Promise<LocalIdentity>;45  /** Persist on user confirm. */46  persistDevice(device: PairedDevice): Promise<void>;47  /** Optional fast-path cache for the next sync round. */48  cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;49}5051export interface PairWithDesktopCallbacks {52  /** Show the 6-digit SAS; the popup must compare it against the peer's. */53  onSasReady(sas: string): void;54  /** Pairing succeeded and the device was persisted. */55  onComplete(device: PairedDevice): void;56  /** Pairing failed at some stage. */57  onError(error: DesktopPairingError): void;58}5960interface PairingFrame {61  type: "PAIR_IDENTITY";62  device_id: string;63  device_name: string;64  device_type: DeviceType;65  long_term_public_key_hex: string;66  signing_public_key_hex?: string;67  ws_port?: number;68  vault_id?: string; // Change 1 — additive, optional69}7071function encodeJson(obj: unknown): Uint8Array {72  return new TextEncoder().encode(JSON.stringify(obj));73}7475function decodePairIdentity(bytes: Uint8Array): PairingFrame {76  const p = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;77  if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");78  if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");79  if (typeof p.device_name !== "string") throw new Error("device_name missing");80  if (typeof p.device_type !== "string") throw new Error("device_type missing");81  if (typeof p.long_term_public_key_hex !== "string" ||82      !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {83    throw new Error("long_term_public_key_hex is not 32-byte hex");84  }85  return p as unknown as PairingFrame;86}8788/**89 * Drive a complete code pairing.90 *91 * `confirm` is a function the caller resolves AFTER the user has compared the92 * SAS shown here against the SAS the peer displays. Resolving false (or93 * rejecting) aborts before any device is persisted — this is the out-of-band94 * MITM defence (see the SECURITY note in chirp-handshake.ts), and it, not the95 * pairing token, is what actually authenticates the peer.96 */97export async function pairWithDesktopHelper(98  code: string,99  cb: PairWithDesktopCallbacks,100  deps: DesktopPairingDeps,101  confirm: (sas: string) => Promise<boolean>,102): Promise<void> {103  let identity: LocalIdentity;104  try {105    identity = await deps.getLocalIdentity();106  } catch {107    cb.onError("vault_locked");108    return;109  }110111  const dial = await dialPairingChannel(code, deps);112  if (!dial.ok) {113    cb.onError(dial.error);114    return;115  }116  const { channel, sas, host, socket } = dial.dialled;117118  try {119    // Initiator (browser) sends its identity FIRST, then reads the peer's.120    const ourFrame: PairingFrame = {121      type: "PAIR_IDENTITY",122      device_id: identity.deviceId,123      device_name: identity.deviceName,124      device_type: identity.deviceType,125      long_term_public_key_hex: identity.longTermPublicKeyHex,126      signing_public_key_hex: identity.signingPublicKeyHex,127      ws_port: identity.wsPort,128      vault_id: identity.vaultId, // Change 1129    };130    await channel.send(encodeJson(ourFrame));131132    const theirFrame = decodePairIdentity(await channel.receive());133134    cb.onSasReady(sas);135    const confirmed = await confirm(sas);136    if (!confirmed) {137      cb.onError("sas_mismatch");138      return;139    }140141    // ws_port comes from the peer frame, defaulting to 8765 (both peers142    // announce it, but guard it). The device-type fallback follows the code143    // form so a phone never lands in the registry labelled "desktop".144    const device: PairedDevice = {145      deviceId: theirFrame.device_id,146      deviceName: theirFrame.device_name,147      deviceType:148        (theirFrame.device_type as DeviceType) ||149        (parsePairingCode(code) ? "android" : "desktop"),150      longTermPublicKeyHex: theirFrame.long_term_public_key_hex,151      signingPublicKeyHex: theirFrame.signing_public_key_hex,152      trustSource: "direct",153      wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,154      pairedAt: Date.now(),155    };156    await deps.persistDevice(device);157    if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {158      await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);159    }160    cb.onComplete(device);161  } catch (err) {162    if (typeof console !== "undefined") console.error("[desktop-pairing] handshake failed:", err);163    cb.onError("handshake_failed");164  } finally {165    socket.close();166  }167}