chirp-pair.ts

11.4 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} from "../shared/chirp-handshake";32import {33  DEVICE_TYPE_EXTENSION,34  DEVICE_TYPE_ANDROID,35  DEVICE_TYPE_IOS,36  type PairedDevice,37  type PairingProgress,38} from "../shared/sync-types";3940/**41 * Well-known TCP port used during Phase-1 pairing. Android opens a42 * WebSocket server on this port only while the pairing screen is active;43 * the extension scans the /24 for it after the chirp exchange completes.44 * The persistent sync port (random high port) is announced via the45 * encrypted PAIR_IDENTITY frame and stored on the PairedDevice.46 */47export const CHIRP_PAIRING_PORT = 49142;4849export interface PairingTransport {50  send(msg: Uint8Array): Promise<void>;51  receive(): Promise<Uint8Array>;52  close(): Promise<void>;53}5455export interface ChirpPairingDeps {56  /** Background round-trip — returns the local public identity. */57  fetchLocalIdentity(): Promise<LocalIdentity>;58  /** Background round-trip — persists the paired device. */59  completePairing(paired: PairedDevice): Promise<void>;60  /** Optional — cache the peer's IP for next session's fast path. */61  cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;62  /**63   * RESPONDER ONLY — returns "192.168.1" for a /24; null if not detectable.64   * Used to scan the LAN for the initiator's pairing-port WebSocket server.65   */66  getLocalSubnet(): Promise<string | null>;67  /**68   * RESPONDER ONLY — connect to the initiator's WebSocket server at the69   * given endpoint. Used during the /24 scan and again for the real70   * handshake transport.71   */72  openPairingTransport(endpoint: { ip: string; port: number }): Promise<PairingTransport>;73  /**74   * INITIATOR ONLY — open a WebSocket server on CHIRP_PAIRING_PORT and75   * resolve with a transport when the responder connects. Backed by a76   * native plugin on Capacitor; not used in the extension build.77   *78   * The promise resolves once a peer connection has been accepted. If no79   * peer arrives before the chirp window expires, reject so the orchestrator80   * can surface a `handshake_failed` phase. Close the server in `.close()`.81   */82  openPairingServer?(): Promise<{83    transport: PairingTransport;84    peerIp: string;85  }>;86  /** Override the chirp exchange's `AudioIO` for tests. */87  openAudio?(): Promise<Awaited<ReturnType<typeof openBrowserAudioIO>>>;88  /** Override the audio-exchange timeout — defaults to 90s per the brief. */89  chirpTimeoutMs?: number;90  onProgress(p: PairingProgress): void;91}9293export type PairingRole = "initiator" | "responder";9495export class ChirpPairingFailure extends Error {96  readonly phase: PairingProgress["phase"];97  constructor(phase: PairingProgress["phase"], message: string) {98    super(message);99    this.phase = phase;100  }101}102103export async function runChirpPairing(104  deps: ChirpPairingDeps,105  role: PairingRole = "responder",106): Promise<PairedDevice> {107  deps.onProgress({ phase: "idle" });108109  const localIdentity = await deps.fetchLocalIdentity();110111  // Each role only runs on the matching platform — the extension is always112  // responder (it scans the LAN), Android/iOS/desktop are always initiator113  // (they open the WS server). Mismatches indicate a wiring bug upstream.114  if (role === "responder" && localIdentity.deviceType !== "extension") {115    throw new ChirpPairingFailure("handshake_failed", "responder requires extension identity");116  }117  if (role === "initiator" && localIdentity.deviceType === "extension") {118    throw new ChirpPairingFailure("handshake_failed", "initiator requires non-extension identity");119  }120  if (role === "initiator" && !deps.openPairingServer) {121    throw new ChirpPairingFailure("handshake_failed", "initiator requires openPairingServer dep");122  }123124  deps.onProgress({ phase: "listening" });125126  const deviceTypeByte =127    localIdentity.deviceType === "android" ? DEVICE_TYPE_ANDROID :128    localIdentity.deviceType === "ios" ? DEVICE_TYPE_IOS :129    DEVICE_TYPE_EXTENSION;130  const attempt = createPairingAttempt(deviceTypeByte);131132  let remoteChirpPublicKey: Uint8Array;133  try {134    const audio = await (deps.openAudio ?? openBrowserAudioIO)();135    const result = await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });136    remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey;137  } catch (err) {138    if (err instanceof ChirpExchangeError) {139      const phase: PairingProgress["phase"] =140        err.kind === "timeout" ? "timeout" :141        err.kind === "version_mismatch" ? "version_mismatch" :142        err.kind === "mic_denied" ? "mic_denied" :143        "handshake_failed";144      deps.onProgress({ phase, errorMessage: err.message });145      throw new ChirpPairingFailure(phase, err.message);146    }147    throw err;148  }149150  deps.onProgress({ phase: "handshake" });151152  if (role === "responder") {153    return runResponderHandshake(deps, attempt, localIdentity);154  }155  return runInitiatorHandshake(deps, remoteChirpPublicKey, localIdentity);156}157158async function runResponderHandshake(159  deps: ChirpPairingDeps,160  attempt: ReturnType<typeof createPairingAttempt>,161  localIdentity: LocalIdentity,162): Promise<PairedDevice> {163  // The peer is somewhere on the LAN; we don't know its IP yet because164  // the chirp payload deliberately omits it. Scan the /24 for the165  // pairing-mode WebSocket port.166  const subnet = await deps.getLocalSubnet();167  if (!subnet) {168    deps.onProgress({ phase: "handshake_failed", errorMessage: "no_subnet" });169    throw new ChirpPairingFailure("handshake_failed", "could not determine local subnet");170  }171172  const peerEndpoint = await firstResponsivePeer(deps, subnet, CHIRP_PAIRING_PORT);173  if (!peerEndpoint) {174    deps.onProgress({ phase: "handshake_failed", errorMessage: "peer_not_found" });175    throw new ChirpPairingFailure("handshake_failed", "could not reach paired peer on subnet");176  }177178  const transport = await deps.openPairingTransport(peerEndpoint);179  let paired: PairedDevice;180  try {181    paired = await runPairingResponder(182      attempt.ephemeralPrivateKey,183      attempt.ephemeralPublicKey,184      localIdentity,185      transport,186    );187  } catch (err) {188    await transport.close();189    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });190    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);191  }192  await transport.close();193194  // The connection's IP is cached in `chrome.storage.local` keyed by195  // pubkey — see `paired-devices.ts#setLastKnownIp`. The persistent v3196  // `PairedDevice` record itself has no `lastKnownIp` field because the197  // IP is per-network and must not roam over `chrome.storage.sync`.198  await deps.completePairing(paired);199  await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", peerEndpoint.ip);200  deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });201  return paired;202}203204async function runInitiatorHandshake(205  deps: ChirpPairingDeps,206  remoteChirpPublicKey: Uint8Array,207  localIdentity: LocalIdentity,208): Promise<PairedDevice> {209  // Initiator opened a WebSocket server on CHIRP_PAIRING_PORT before the210  // chirp exchange started; here we wait for the responder (extension)211  // to finish its /24 scan and connect to us.212  let server: { transport: PairingTransport; peerIp: string };213  try {214    server = await deps.openPairingServer!();215  } catch (err) {216    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });217    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);218  }219220  let paired: PairedDevice;221  try {222    paired = await runPairingInitiator(223      remoteChirpPublicKey,224      localIdentity,225      server.transport,226    );227  } catch (err) {228    await server.transport.close();229    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });230    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);231  }232  await server.transport.close();233234  await deps.completePairing(paired);235  await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", server.peerIp);236  deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });237  return paired;238}239240async function firstResponsivePeer(241  deps: ChirpPairingDeps,242  subnet: string,243  port: number,244): Promise<{ ip: string; port: number } | null> {245  // We don't have the peer's long-term key yet (that's what we're about246  // to exchange), so we can't run a Noise XX probe like the steady-state247  // sync path does. The pairing-port server accepts any incoming Noise NK248  // request during its short pairing window; the first WebSocket that249  // accepts our connection is the peer.250  //251  // Per-IP timeout strategy: browser WebSocket constructors against an252  // unbound TCP port can hang for several seconds before erroring, which253  // would stretch a /24 scan to minutes. Each probe is raced against a254  // 250ms timer via Promise.race — if the timer wins, we treat the IP as255  // a miss and close the underlying socket if it lands after the loss so256  // we don't leak connections. With 16-wide batching this bounds the full257  // scan to ~254 * 250ms / 16 ≈ 4 seconds in the worst case.258  const PROBE_TIMEOUT_MS = 250;259  const batch = 16;260  for (let start = 1; start <= 254; start += batch) {261    const end = Math.min(start + batch - 1, 254);262    const ips: string[] = [];263    for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);264265    const results = await Promise.all(266      ips.map(async (ip) => {267        let timer: ReturnType<typeof setTimeout> | undefined;268        const timeout = new Promise<null>((resolve) => {269          timer = setTimeout(() => resolve(null), PROBE_TIMEOUT_MS);270        });271        const probe = (async () => {272          try {273            const t = await deps.openPairingTransport({ ip, port });274            return { transport: t, ip, port };275          } catch {276            return null;277          }278        })();279        const winner = await Promise.race([probe, timeout]);280        if (winner && "transport" in winner) {281          if (timer !== undefined) clearTimeout(timer);282          await winner.transport.close();283          return { ip: winner.ip, port: winner.port };284        }285        // Timer won — make sure a late-arriving open socket is closed so286        // we don't leak a connection past the scan.287        probe.then((late) => {288          if (late) void late.transport.close();289        }).catch(() => { /* already a miss */ });290        return null;291      }),292    );293    const hit = results.find((r): r is { ip: string; port: number } => r !== null);294    if (hit) return hit;295  }296  return null;297}