code-pairing.ts

5.8 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// A pairing code (`A74H-48J3-KI32-OEP4`) carries the listener's endpoint and14// one-time token.1516import { dialPairingChannel, PAIRING_PORT, type PairingDialDeps } from "../shared/pairing-dial";17import type { LocalIdentity } from "../shared/pairing-handshake";18import type { DeviceType, PairedDevice } from "../shared/sync-types";1920/** Default sync port a peer announces; used when ws_port is absent. Same21 *  fixed port pairing dials — SyncReconnectListener.SYNC_PORT on Android. */22const DEFAULT_SYNC_PORT = PAIRING_PORT;2324export type CodePairingError =25  | "invalid_code"      // socket closed before PEACH_HELLO_ACK (wrong/expired)26  | "no_host_found"     // nothing answered at the endpoint27  | "handshake_failed"  // Noise NK / PAIR_IDENTITY decode failed28  | "sas_mismatch"      // user rejected the SAS29  | "vault_locked";     // no master key available3031export interface CodePairingDeps extends PairingDialDeps {32  /** Public identity for our outbound PAIR_IDENTITY (incl. vaultId). */33  getLocalIdentity(): Promise<LocalIdentity>;34  /** Persist on user confirm. */35  persistDevice(device: PairedDevice): Promise<void>;36  /** Optional fast-path cache for the next sync round. */37  cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;38}3940export interface CodePairingCallbacks {41  /** Show the 6-digit SAS; the popup must compare it against the peer's. */42  onSasReady(sas: string): void;43  /** Pairing succeeded and the device was persisted. */44  onComplete(device: PairedDevice): void;45  /** Pairing failed at some stage. */46  onError(error: CodePairingError): void;47}4849interface PairingFrame {50  type: "PAIR_IDENTITY";51  device_id: string;52  device_name: string;53  device_type: DeviceType;54  long_term_public_key_hex: string;55  signing_public_key_hex?: string;56  ws_port?: number;57  vault_id?: string; // Change 1 — additive, optional58}5960function encodeJson(obj: unknown): Uint8Array {61  return new TextEncoder().encode(JSON.stringify(obj));62}6364function decodePairIdentity(bytes: Uint8Array): PairingFrame {65  const p = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;66  if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");67  if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");68  if (typeof p.device_name !== "string") throw new Error("device_name missing");69  if (70    p.device_type !== "extension" &&71    p.device_type !== "android" &&72    p.device_type !== "ios"73  ) {74    throw new Error("device_type invalid");75  }76  if (typeof p.long_term_public_key_hex !== "string" ||77      !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {78    throw new Error("long_term_public_key_hex is not 32-byte hex");79  }80  return p as unknown as PairingFrame;81}8283/**84 * Drive a complete code pairing.85 *86 * `confirm` is a function the caller resolves AFTER the user has compared the87 * SAS shown here against the SAS the peer displays. Resolving false (or88 * rejecting) aborts before any device is persisted — this is the out-of-band89 * MITM defence (see the SECURITY note in pairing-handshake.ts), and it, not the90 * pairing token, is what actually authenticates the peer.91 */92export async function pairWithCode(93  code: string,94  cb: CodePairingCallbacks,95  deps: CodePairingDeps,96  confirm: (sas: string) => Promise<boolean>,97): Promise<void> {98  let identity: LocalIdentity;99  try {100    identity = await deps.getLocalIdentity();101  } catch {102    cb.onError("vault_locked");103    return;104  }105106  const dial = await dialPairingChannel(code, deps);107  if (!dial.ok) {108    cb.onError(dial.error);109    return;110  }111  const { channel, sas, host, socket } = dial.dialled;112113  try {114    // Initiator (browser) sends its identity FIRST, then reads the peer's.115    const ourFrame: PairingFrame = {116      type: "PAIR_IDENTITY",117      device_id: identity.deviceId,118      device_name: identity.deviceName,119      device_type: identity.deviceType,120      long_term_public_key_hex: identity.longTermPublicKeyHex,121      signing_public_key_hex: identity.signingPublicKeyHex,122      ws_port: identity.wsPort,123      vault_id: identity.vaultId, // Change 1124    };125    await channel.send(encodeJson(ourFrame));126127    const theirFrame = decodePairIdentity(await channel.receive());128129    cb.onSasReady(sas);130    const confirmed = await confirm(sas);131    if (!confirmed) {132      cb.onError("sas_mismatch");133      return;134    }135136    // ws_port comes from the authenticated peer frame and falls back to the137    // native listener's fixed pairing/sync port for older peers.138    const device: PairedDevice = {139      deviceId: theirFrame.device_id,140      deviceName: theirFrame.device_name,141      deviceType: theirFrame.device_type,142      longTermPublicKeyHex: theirFrame.long_term_public_key_hex,143      signingPublicKeyHex: theirFrame.signing_public_key_hex,144      trustSource: "direct",145      wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,146      pairedAt: Date.now(),147    };148    await deps.persistDevice(device);149    if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {150      await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);151    }152    cb.onComplete(device);153  } catch (err) {154    if (typeof console !== "undefined") console.error("[code-pairing] handshake failed:", err);155    cb.onError("handshake_failed");156  } finally {157    socket.close();158  }159}