code-pairing.ts

7.4 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";19import { bytesToHex, normalizeRelayUrl } from "../shared/rendezvous";20import { constantTimeEqualHex } from "../shared/constant-time";2122/** Default sync port a peer announces; used when ws_port is absent. Same23 *  fixed port pairing dials — SyncReconnectListener.SYNC_PORT on Android. */24const DEFAULT_SYNC_PORT = PAIRING_PORT;2526export type CodePairingError =27  | "invalid_code"      // socket closed before PEACH_HELLO_ACK (wrong/expired)28  | "no_host_found"     // nothing answered at the endpoint29  | "handshake_failed"  // Noise NK / PAIR_IDENTITY decode failed30  | "sas_mismatch"      // user rejected the SAS31  | "vault_locked";     // no master key available3233export interface CodePairingDeps extends PairingDialDeps {34  /**35   * Public identity plus the private key used for structural36   * proof-of-possession. The private half never enters a wire frame.37   */38  getLocalIdentity(): Promise<{39    identity: LocalIdentity;40    privateKey: Uint8Array;41    publicKey: Uint8Array;42  }>;43  /** Current mesh relay URL; sent in the encrypted pairing identity. */44  getRelayUrl(): Promise<string>;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 CodePairingCallbacks {52  /** Show the 8-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: CodePairingError): 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  relay_url?: string;70}7172function encodeJson(obj: unknown): Uint8Array {73  return new TextEncoder().encode(JSON.stringify(obj));74}7576function decodePairIdentity(bytes: Uint8Array): PairingFrame {77  const p = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;78  if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");79  if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");80  if (typeof p.device_name !== "string") throw new Error("device_name missing");81  if (82    p.device_type !== "extension" &&83    p.device_type !== "android" &&84    p.device_type !== "ios"85  ) {86    throw new Error("device_type invalid");87  }88  if (typeof p.long_term_public_key_hex !== "string" ||89      !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {90    throw new Error("long_term_public_key_hex is not 32-byte hex");91  }92  if (p.relay_url !== undefined) {93    if (typeof p.relay_url !== "string") throw new Error("relay_url invalid");94    p.relay_url = normalizeRelayUrl(p.relay_url);95  }96  return p as unknown as PairingFrame;97}9899/**100 * Drive a complete code pairing.101 *102 * `confirm` is a function the caller resolves AFTER the user has compared the103 * SAS shown here against the SAS the peer displays. Resolving false (or104 * rejecting) aborts before any device is persisted — this is the out-of-band105 * MITM defence (see the SECURITY note in pairing-handshake.ts), and it, not the106 * pairing token, is what actually authenticates the peer.107 */108export async function pairWithCode(109  code: string,110  cb: CodePairingCallbacks,111  deps: CodePairingDeps,112  confirm: (sas: string) => Promise<boolean>,113): Promise<void> {114  let local: Awaited<ReturnType<CodePairingDeps["getLocalIdentity"]>>;115  try {116    local = await deps.getLocalIdentity();117  } catch {118    cb.onError("vault_locked");119    return;120  }121122  const dial = await dialPairingChannel(code, deps);123  if (!dial.ok) {124    cb.onError(dial.error);125    return;126  }127  const {128    channel,129    sas,130    host,131    rendezvousSecretHex,132    remoteStaticPublicKey,133    authenticateInitiator,134    socket,135  } = dial.dialled;136137  try {138    const { identity, privateKey, publicKey } = local;139    if (!constantTimeEqualHex(bytesToHex(publicKey), identity.longTermPublicKeyHex)) {140      throw new Error("local pairing key does not match advertised identity");141    }142    // E1: prove the private half of the advertised initiator identity before143    // either side trusts a PAIR_IDENTITY frame or asks the user for SAS consent.144    await authenticateInitiator(privateKey, publicKey);145146    const relayUrl = await deps.getRelayUrl();147    // Initiator (browser) sends its identity FIRST, then reads the peer's.148    const ourFrame: PairingFrame = {149      type: "PAIR_IDENTITY",150      device_id: identity.deviceId,151      device_name: identity.deviceName,152      device_type: identity.deviceType,153      long_term_public_key_hex: identity.longTermPublicKeyHex,154      signing_public_key_hex: identity.signingPublicKeyHex,155      ws_port: identity.wsPort,156      vault_id: identity.vaultId, // Change 1157      relay_url: relayUrl,158    };159    await channel.send(encodeJson(ourFrame));160161    const theirFrame = decodePairIdentity(await channel.receive());162    if (!constantTimeEqualHex(163      theirFrame.long_term_public_key_hex,164      bytesToHex(remoteStaticPublicKey),165    )) {166      throw new Error("peer identity does not match Noise responder key");167    }168169    cb.onSasReady(sas);170    const confirmed = await confirm(sas);171    if (!confirmed) {172      cb.onError("sas_mismatch");173      return;174    }175176    // ws_port comes from the authenticated peer frame and falls back to the177    // native listener's fixed pairing/sync port for older peers.178    const device: PairedDevice = {179      deviceId: theirFrame.device_id,180      deviceName: theirFrame.device_name,181      deviceType: theirFrame.device_type,182      longTermPublicKeyHex: theirFrame.long_term_public_key_hex,183      signingPublicKeyHex: theirFrame.signing_public_key_hex,184      trustSource: "direct",185      wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,186      rendezvousSecretHex,187      // The listening phone is the authority for the relay this pair will use.188      relayUrl: theirFrame.relay_url ?? relayUrl,189      pairedAt: Date.now(),190    };191    await deps.persistDevice(device);192    if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {193      await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);194    }195    cb.onComplete(device);196  } catch (err) {197    if (typeof console !== "undefined") console.error("[code-pairing] handshake failed:", err);198    cb.onError("handshake_failed");199  } finally {200    socket.close();201  }202}