chirp-pair.ts

14.5 KB · raw

1// Popup-side orchestrator for Phase-1 chirp pairing.2//3// Runs end-to-end inside the popup or the settings page (it depends on4// `getUserMedia`, which is unavailable in the MV3 service worker). The5// background's role is limited to handing us the local identity and6// persisting the resulting PairedDevice record.7//8// Flow:9//   1. fetch local identity via CHIRP_GET_LOCAL_IDENTITY10//   2. open Web Audio + microphone11//   3. encode our chirp and broadcast on a 4s loop while listening12//   4. on chirp decoded, transition to handshake phase13//   5. probe local subnet on the well-known pairing port for the peer's14//      WebSocket; complete Noise NK over it; exchange long-term identities15//   6. send CHIRP_COMPLETE_PAIRING to persist the new pair16//17// The orchestrator notifies the UI through an `onProgress` callback so18// `ChirpPairingFlow` can drive its three-act animation in lockstep with19// the real protocol state.2021import {22  ChirpExchangeError,23  createPairingAttempt,24  openBrowserAudioIO,25  runChirpExchange,26} from "../shared/chirp-pairing";27import {28  runPairingInitiator,29  runPairingResponder,30  type LocalIdentity,31  type PairingHandshakeResult,32} from "../shared/chirp-handshake";33import {34  DEVICE_TYPE_EXTENSION,35  DEVICE_TYPE_ANDROID,36  DEVICE_TYPE_IOS,37  type PairedDevice,38  type PairingProgress,39} from "../shared/sync-types";4041/**42 * Well-known TCP port used during Phase-1 pairing. Android opens a43 * WebSocket server on this port only while the pairing screen is active;44 * the extension scans the /24 for it after the chirp exchange completes.45 * The persistent sync port (random high port) is announced via the46 * encrypted PAIR_IDENTITY frame and stored on the PairedDevice.47 */48export const CHIRP_PAIRING_PORT = 49142;4950export interface PairingTransport {51  send(msg: Uint8Array): Promise<void>;52  receive(): Promise<Uint8Array>;53  close(): Promise<void>;54}5556export interface ChirpPairingDeps {57  /** Background round-trip — returns the local public identity. */58  fetchLocalIdentity(): Promise<LocalIdentity>;59  /** Background round-trip — persists the paired device. */60  completePairing(paired: PairedDevice): Promise<void>;61  /**62   * Show the Short Authentication String and resolve true only when the user63   * confirms it matches the code displayed on the OTHER device. Pairing is64   * aborted (the peer is NOT persisted) on false. This is the out-of-band65   * mutual-authentication step that defeats a LAN/audio-range attacker who66   * completes the Noise NK handshake in place of the real device — their67   * handshake produces a different SAS than the genuine peer shows, so the68   * user declines (audit HIGH — pairing initiator authentication).69   */70  confirmSAS(sas: string): Promise<boolean>;71  /** Optional — cache the peer's IP for next session's fast path. */72  cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;73  /**74   * Optional — activate a Pro license the peer sent in its identity frame.75   * Called ONLY after the SAS is confirmed. The implementation verifies the76   * signature and ignores it if this device is already Pro. This is how one77   * Pro device grants Pro to the other when pairing (either direction).78   */79  activatePeerLicense?(license: string): Promise<void>;80  /**81   * RESPONDER ONLY — returns "192.168.1" for a /24; null if not detectable.82   * Used to scan the LAN for the initiator's pairing-port WebSocket server.83   */84  getLocalSubnet(): Promise<string | null>;85  /**86   * RESPONDER ONLY — connect to the initiator's WebSocket server at the87   * given endpoint. Used during the /24 scan and again for the real88   * handshake transport.89   */90  openPairingTransport(endpoint: { ip: string; port: number }): Promise<PairingTransport>;91  /**92   * INITIATOR ONLY — open a WebSocket server on CHIRP_PAIRING_PORT and93   * resolve with a transport when the responder connects. Backed by a94   * native plugin on Capacitor; not used in the extension build.95   *96   * The promise resolves once a peer connection has been accepted. If no97   * peer arrives before the chirp window expires, reject so the orchestrator98   * can surface a `handshake_failed` phase. Close the server in `.close()`.99   */100  openPairingServer?(): Promise<{101    transport: PairingTransport;102    peerIp: string;103  }>;104  /** Override the chirp exchange's `AudioIO` for tests. */105  openAudio?(): Promise<Awaited<ReturnType<typeof openBrowserAudioIO>>>;106  /** Override the audio-exchange timeout — defaults to 90s per the brief. */107  chirpTimeoutMs?: number;108  onProgress(p: PairingProgress): void;109}110111export type PairingRole = "initiator" | "responder";112113export class ChirpPairingFailure extends Error {114  readonly phase: PairingProgress["phase"];115  constructor(phase: PairingProgress["phase"], message: string) {116    super(message);117    this.phase = phase;118  }119}120121export async function runChirpPairing(122  deps: ChirpPairingDeps,123  role: PairingRole = "responder",124): Promise<PairedDevice> {125  deps.onProgress({ phase: "idle" });126127  const localIdentity = await deps.fetchLocalIdentity();128129  // Each role only runs on the matching platform — the extension is always130  // responder (it scans the LAN), Android/iOS/desktop are always initiator131  // (they open the WS server). Mismatches indicate a wiring bug upstream.132  if (role === "responder" && localIdentity.deviceType !== "extension") {133    throw new ChirpPairingFailure("handshake_failed", "responder requires extension identity");134  }135  if (role === "initiator" && localIdentity.deviceType === "extension") {136    throw new ChirpPairingFailure("handshake_failed", "initiator requires non-extension identity");137  }138  if (role === "initiator" && !deps.openPairingServer) {139    throw new ChirpPairingFailure("handshake_failed", "initiator requires openPairingServer dep");140  }141142  deps.onProgress({ phase: "listening" });143144  const deviceTypeByte =145    localIdentity.deviceType === "android" ? DEVICE_TYPE_ANDROID :146    localIdentity.deviceType === "ios" ? DEVICE_TYPE_IOS :147    DEVICE_TYPE_EXTENSION;148  const attempt = createPairingAttempt(deviceTypeByte);149150  let remoteChirpPublicKey: Uint8Array;151  try {152    const audio = await (deps.openAudio ?? openBrowserAudioIO)();153    const result = await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });154    remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey;155  } catch (err) {156    if (err instanceof ChirpExchangeError) {157      const phase: PairingProgress["phase"] =158        err.kind === "timeout" ? "timeout" :159        err.kind === "version_mismatch" ? "version_mismatch" :160        err.kind === "mic_denied" ? "mic_denied" :161        "handshake_failed";162      deps.onProgress({ phase, errorMessage: err.message });163      throw new ChirpPairingFailure(phase, err.message);164    }165    throw err;166  }167168  deps.onProgress({ phase: "handshake" });169170  if (role === "responder") {171    return runResponderHandshake(deps, attempt, localIdentity);172  }173  return runInitiatorHandshake(deps, remoteChirpPublicKey, localIdentity);174}175176async function runResponderHandshake(177  deps: ChirpPairingDeps,178  attempt: ReturnType<typeof createPairingAttempt>,179  localIdentity: LocalIdentity,180): Promise<PairedDevice> {181  // The peer is somewhere on the LAN; we don't know its IP yet because182  // the chirp payload deliberately omits it. Scan the /24 for the183  // pairing-mode WebSocket port.184  const subnet = await deps.getLocalSubnet();185  if (!subnet) {186    deps.onProgress({ phase: "handshake_failed", errorMessage: "no_subnet" });187    throw new ChirpPairingFailure("handshake_failed", "could not determine local subnet");188  }189190  const peerEndpoint = await firstResponsivePeer(deps, subnet, CHIRP_PAIRING_PORT);191  if (!peerEndpoint) {192    deps.onProgress({ phase: "handshake_failed", errorMessage: "peer_not_found" });193    throw new ChirpPairingFailure("handshake_failed", "could not reach paired peer on subnet");194  }195196  const transport = await deps.openPairingTransport(peerEndpoint);197  let result: PairingHandshakeResult;198  try {199    result = await runPairingResponder(200      attempt.ephemeralPrivateKey,201      attempt.ephemeralPublicKey,202      localIdentity,203      transport,204    );205  } catch (err) {206    await transport.close();207    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });208    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);209  }210  await transport.close();211212  // Out-of-band verification BEFORE persisting: the user must confirm the SAS213  // matches the code on the other device. Without this, whoever completed the214  // handshake (possibly a LAN/audio-range attacker) would be enrolled as a215  // trusted sync peer (audit HIGH).216  const paired = await gateOnSAS(deps, result);217  await activatePeerLicenseIfAny(deps, result);218219  // The connection's IP is cached in `chrome.storage.local` keyed by220  // pubkey — see `paired-devices.ts#setLastKnownIp`. The persistent v3221  // `PairedDevice` record itself has no `lastKnownIp` field because the222  // IP is per-network and must not roam over `chrome.storage.sync`.223  await deps.completePairing(paired);224  await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", peerEndpoint.ip);225  deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });226  return paired;227}228229/**230 * Surface the SAS for user confirmation and abort the pairing if it is not231 * confirmed to match the peer device. Shared by both roles.232 */233// Exported for the PCH-01-024 regression test that pins the SAS gate as234// MANDATORY (the chirp-handshake.ts security comment depends on this staying true).235export async function gateOnSAS(236  deps: ChirpPairingDeps,237  result: PairingHandshakeResult,238): Promise<PairedDevice> {239  let confirmed: boolean;240  try {241    confirmed = await deps.confirmSAS(result.sas);242  } catch (err) {243    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });244    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);245  }246  if (!confirmed) {247    deps.onProgress({ phase: "handshake_failed", errorMessage: "sas_rejected" });248    throw new ChirpPairingFailure(249      "handshake_failed",250      "pairing rejected — the verification codes did not match",251    );252  }253  return result.device;254}255256/**257 * If the (now SAS-confirmed) peer advertised a Pro license, hand it to the258 * background to verify + activate. Best-effort: a bad/duplicate license never259 * fails the pairing — the device link still succeeds.260 */261async function activatePeerLicenseIfAny(262  deps: ChirpPairingDeps,263  result: PairingHandshakeResult,264): Promise<void> {265  if (!result.peerLicense || !deps.activatePeerLicense) return;266  try {267    await deps.activatePeerLicense(result.peerLicense);268  } catch {269    // Invalid signature, already-Pro, or storage error — pairing stands.270  }271}272273async function runInitiatorHandshake(274  deps: ChirpPairingDeps,275  remoteChirpPublicKey: Uint8Array,276  localIdentity: LocalIdentity,277): Promise<PairedDevice> {278  // Initiator opened a WebSocket server on CHIRP_PAIRING_PORT before the279  // chirp exchange started; here we wait for the responder (extension)280  // to finish its /24 scan and connect to us.281  let server: { transport: PairingTransport; peerIp: string };282  try {283    server = await deps.openPairingServer!();284  } catch (err) {285    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });286    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);287  }288289  let result: PairingHandshakeResult;290  try {291    result = await runPairingInitiator(292      remoteChirpPublicKey,293      localIdentity,294      server.transport,295    );296  } catch (err) {297    await server.transport.close();298    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });299    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);300  }301  await server.transport.close();302303  // Same out-of-band SAS confirmation as the responder side — the initiator304  // (mobile vault host) must not sync to an unverified peer either.305  const paired = await gateOnSAS(deps, result);306  await activatePeerLicenseIfAny(deps, result);307308  await deps.completePairing(paired);309  await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", server.peerIp);310  deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });311  return paired;312}313314async function firstResponsivePeer(315  deps: ChirpPairingDeps,316  subnet: string,317  port: number,318): Promise<{ ip: string; port: number } | null> {319  // We don't have the peer's long-term key yet (that's what we're about320  // to exchange), so we can't run a Noise XX probe like the steady-state321  // sync path does. The pairing-port server accepts any incoming Noise NK322  // request during its short pairing window; the first WebSocket that323  // accepts our connection is the peer.324  //325  // Per-IP timeout strategy: browser WebSocket constructors against an326  // unbound TCP port can hang for several seconds before erroring, which327  // would stretch a /24 scan to minutes. Each probe is raced against a328  // 250ms timer via Promise.race — if the timer wins, we treat the IP as329  // a miss and close the underlying socket if it lands after the loss so330  // we don't leak connections. With 16-wide batching this bounds the full331  // scan to ~254 * 250ms / 16 ≈ 4 seconds in the worst case.332  const PROBE_TIMEOUT_MS = 250;333  const batch = 16;334  for (let start = 1; start <= 254; start += batch) {335    const end = Math.min(start + batch - 1, 254);336    const ips: string[] = [];337    for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);338339    const results = await Promise.all(340      ips.map(async (ip) => {341        let timer: ReturnType<typeof setTimeout> | undefined;342        const timeout = new Promise<null>((resolve) => {343          timer = setTimeout(() => resolve(null), PROBE_TIMEOUT_MS);344        });345        const probe = (async () => {346          try {347            const t = await deps.openPairingTransport({ ip, port });348            return { transport: t, ip, port };349          } catch {350            return null;351          }352        })();353        const winner = await Promise.race([probe, timeout]);354        if (winner && "transport" in winner) {355          if (timer !== undefined) clearTimeout(timer);356          await winner.transport.close();357          return { ip: winner.ip, port: winner.port };358        }359        // Timer won — make sure a late-arriving open socket is closed so360        // we don't leak a connection past the scan.361        probe.then((late) => {362          if (late) void late.transport.close();363        }).catch(() => { /* already a miss */ });364        return null;365      }),366    );367    const hit = results.find((r): r is { ip: string; port: number } => r !== null);368    if (hit) return hit;369  }370  return null;371}