chirp-pair.ts

8.1 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  runPairingResponder,29  type LocalIdentity,30} from "../shared/chirp-handshake";31import {32  DEVICE_TYPE_EXTENSION,33  type PairedDevice,34  type PairingProgress,35} from "../shared/sync-types";3637/**38 * Well-known TCP port used during Phase-1 pairing. Android opens a39 * WebSocket server on this port only while the pairing screen is active;40 * the extension scans the /24 for it after the chirp exchange completes.41 * The persistent sync port (random high port) is announced via the42 * encrypted PAIR_IDENTITY frame and stored on the PairedDevice.43 */44export const CHIRP_PAIRING_PORT = 49142;4546export interface ChirpPairingDeps {47  /** Background round-trip — returns the local public identity. */48  fetchLocalIdentity(): Promise<LocalIdentity>;49  /** Background round-trip — persists the paired device. */50  completePairing(paired: PairedDevice): Promise<void>;51  /** Optional — cache the peer's IP for next session's fast path. */52  cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;53  /** Returns "192.168.1" for a /24; null if not detectable. */54  getLocalSubnet(): Promise<string | null>;55  /** Inject the WebSocket transport. Override in tests. */56  openPairingTransport(endpoint: { ip: string; port: number }): Promise<{57    send(msg: Uint8Array): Promise<void>;58    receive(): Promise<Uint8Array>;59    close(): Promise<void>;60  }>;61  /** Override the chirp exchange's `AudioIO` for tests. */62  openAudio?(): Promise<Awaited<ReturnType<typeof openBrowserAudioIO>>>;63  /** Override the audio-exchange timeout — defaults to 90s per the brief. */64  chirpTimeoutMs?: number;65  onProgress(p: PairingProgress): void;66}6768export class ChirpPairingFailure extends Error {69  readonly phase: PairingProgress["phase"];70  constructor(phase: PairingProgress["phase"], message: string) {71    super(message);72    this.phase = phase;73  }74}7576export async function runChirpPairing(deps: ChirpPairingDeps): Promise<PairedDevice> {77  deps.onProgress({ phase: "idle" });7879  const localIdentity = await deps.fetchLocalIdentity();80  if (localIdentity.deviceType !== "extension") {81    // The Capacitor build runs the host-side flow; this orchestrator is82    // for the extension responder side only.83    throw new ChirpPairingFailure("handshake_failed", "wrong-platform orchestrator");84  }8586  deps.onProgress({ phase: "listening" });8788  const attempt = createPairingAttempt(DEVICE_TYPE_EXTENSION);89  try {90    const audio = await (deps.openAudio ?? openBrowserAudioIO)();91    await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });92  } catch (err) {93    if (err instanceof ChirpExchangeError) {94      const phase: PairingProgress["phase"] =95        err.kind === "timeout" ? "timeout" :96        err.kind === "version_mismatch" ? "version_mismatch" :97        err.kind === "mic_denied" ? "mic_denied" :98        "handshake_failed";99      deps.onProgress({ phase, errorMessage: err.message });100      throw new ChirpPairingFailure(phase, err.message);101    }102    throw err;103  }104105  deps.onProgress({ phase: "handshake" });106107  // The peer is somewhere on the LAN; we don't know its IP yet because108  // the chirp payload deliberately omits it. Scan the /24 for the109  // pairing-mode WebSocket port.110  const subnet = await deps.getLocalSubnet();111  if (!subnet) {112    deps.onProgress({ phase: "handshake_failed", errorMessage: "no_subnet" });113    throw new ChirpPairingFailure("handshake_failed", "could not determine local subnet");114  }115116  const peerEndpoint = await firstResponsivePeer(deps, subnet, CHIRP_PAIRING_PORT);117  if (!peerEndpoint) {118    deps.onProgress({ phase: "handshake_failed", errorMessage: "peer_not_found" });119    throw new ChirpPairingFailure("handshake_failed", "could not reach paired peer on subnet");120  }121122  const transport = await deps.openPairingTransport(peerEndpoint);123  let paired: PairedDevice;124  try {125    paired = await runPairingResponder(126      attempt.ephemeralPrivateKey,127      attempt.ephemeralPublicKey,128      localIdentity,129      transport,130    );131  } catch (err) {132    await transport.close();133    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });134    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);135  }136  await transport.close();137138  // The connection's IP is cached in `chrome.storage.local` keyed by139  // pubkey — see `paired-devices.ts#setLastKnownIp`. The persistent v3140  // `PairedDevice` record itself has no `lastKnownIp` field because the141  // IP is per-network and must not roam over `chrome.storage.sync`.142  await deps.completePairing(paired);143  await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", peerEndpoint.ip);144  deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });145  return paired;146}147148async function firstResponsivePeer(149  deps: ChirpPairingDeps,150  subnet: string,151  port: number,152): Promise<{ ip: string; port: number } | null> {153  // We don't have the peer's long-term key yet (that's what we're about154  // to exchange), so we can't run a Noise XX probe like the steady-state155  // sync path does. The pairing-port server accepts any incoming Noise NK156  // request during its short pairing window; the first WebSocket that157  // accepts our connection is the peer.158  //159  // Per-IP timeout strategy: browser WebSocket constructors against an160  // unbound TCP port can hang for several seconds before erroring, which161  // would stretch a /24 scan to minutes. Each probe is raced against a162  // 250ms timer via Promise.race — if the timer wins, we treat the IP as163  // a miss and close the underlying socket if it lands after the loss so164  // we don't leak connections. With 16-wide batching this bounds the full165  // scan to ~254 * 250ms / 16 ≈ 4 seconds in the worst case.166  const PROBE_TIMEOUT_MS = 250;167  const batch = 16;168  for (let start = 1; start <= 254; start += batch) {169    const end = Math.min(start + batch - 1, 254);170    const ips: string[] = [];171    for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);172173    const results = await Promise.all(174      ips.map(async (ip) => {175        let timer: ReturnType<typeof setTimeout> | undefined;176        const timeout = new Promise<null>((resolve) => {177          timer = setTimeout(() => resolve(null), PROBE_TIMEOUT_MS);178        });179        const probe = (async () => {180          try {181            const t = await deps.openPairingTransport({ ip, port });182            return { transport: t, ip, port };183          } catch {184            return null;185          }186        })();187        const winner = await Promise.race([probe, timeout]);188        if (winner && "transport" in winner) {189          if (timer !== undefined) clearTimeout(timer);190          await winner.transport.close();191          return { ip: winner.ip, port: winner.port };192        }193        // Timer won — make sure a late-arriving open socket is closed so194        // we don't leak a connection past the scan.195        probe.then((late) => {196          if (late) void late.transport.close();197        }).catch(() => { /* already a miss */ });198        return null;199      }),200    );201    const hit = results.find((r): r is { ip: string; port: number } => r !== null);202    if (hit) return hit;203  }204  return null;205}