chirp-pair.ts

6.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  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  /** Returns "192.168.1" for a /24; null if not detectable. */52  getLocalSubnet(): Promise<string | null>;53  /** Inject the WebSocket transport. Override in tests. */54  openPairingTransport(endpoint: { ip: string; port: number }): Promise<{55    send(msg: Uint8Array): Promise<void>;56    receive(): Promise<Uint8Array>;57    close(): Promise<void>;58  }>;59  /** Override the chirp exchange's `AudioIO` for tests. */60  openAudio?(): Promise<Awaited<ReturnType<typeof openBrowserAudioIO>>>;61  /** Override the audio-exchange timeout — defaults to 90s per the brief. */62  chirpTimeoutMs?: number;63  onProgress(p: PairingProgress): void;64}6566export class ChirpPairingFailure extends Error {67  readonly phase: PairingProgress["phase"];68  constructor(phase: PairingProgress["phase"], message: string) {69    super(message);70    this.phase = phase;71  }72}7374export async function runChirpPairing(deps: ChirpPairingDeps): Promise<PairedDevice> {75  deps.onProgress({ phase: "idle" });7677  const localIdentity = await deps.fetchLocalIdentity();78  if (localIdentity.deviceType !== "extension") {79    // The Capacitor build runs the host-side flow; this orchestrator is80    // for the extension responder side only.81    throw new ChirpPairingFailure("handshake_failed", "wrong-platform orchestrator");82  }8384  deps.onProgress({ phase: "listening" });8586  const attempt = createPairingAttempt(DEVICE_TYPE_EXTENSION);87  try {88    const audio = await (deps.openAudio ?? openBrowserAudioIO)();89    await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });90  } catch (err) {91    if (err instanceof ChirpExchangeError) {92      const phase: PairingProgress["phase"] =93        err.kind === "timeout" ? "timeout" :94        err.kind === "version_mismatch" ? "version_mismatch" :95        err.kind === "mic_denied" ? "mic_denied" :96        "handshake_failed";97      deps.onProgress({ phase, errorMessage: err.message });98      throw new ChirpPairingFailure(phase, err.message);99    }100    throw err;101  }102103  deps.onProgress({ phase: "handshake" });104105  // The peer is somewhere on the LAN; we don't know its IP yet because106  // the chirp payload deliberately omits it. Scan the /24 for the107  // pairing-mode WebSocket port.108  const subnet = await deps.getLocalSubnet();109  if (!subnet) {110    deps.onProgress({ phase: "handshake_failed", errorMessage: "no_subnet" });111    throw new ChirpPairingFailure("handshake_failed", "could not determine local subnet");112  }113114  const peerEndpoint = await firstResponsivePeer(deps, subnet, CHIRP_PAIRING_PORT);115  if (!peerEndpoint) {116    deps.onProgress({ phase: "handshake_failed", errorMessage: "peer_not_found" });117    throw new ChirpPairingFailure("handshake_failed", "could not reach paired peer on subnet");118  }119120  const transport = await deps.openPairingTransport(peerEndpoint);121  let paired: PairedDevice;122  try {123    paired = await runPairingResponder(124      attempt.ephemeralPrivateKey,125      attempt.ephemeralPublicKey,126      localIdentity,127      transport,128    );129  } catch (err) {130    await transport.close();131    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });132    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);133  }134  await transport.close();135136  // Use the connection's IP as our cached `lastKnownIp`; the wsPort came137  // in the PAIR_IDENTITY frame.138  paired = { ...paired, lastKnownIp: peerEndpoint.ip };139  await deps.completePairing(paired);140  deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });141  return paired;142}143144async function firstResponsivePeer(145  deps: ChirpPairingDeps,146  subnet: string,147  port: number,148): Promise<{ ip: string; port: number } | null> {149  // We don't have the peer's long-term key yet (that's what we're about150  // to exchange), so we can't run a Noise XX probe like the steady-state151  // sync path does. The pairing-port server accepts any incoming Noise NK152  // request during its short pairing window; the first WebSocket that153  // accepts our connection is the peer.154  const batch = 16;155  for (let start = 1; start <= 254; start += batch) {156    const end = Math.min(start + batch - 1, 254);157    const ips: string[] = [];158    for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);159160    const results = await Promise.all(161      ips.map(async (ip) => {162        try {163          const t = await deps.openPairingTransport({ ip, port });164          await t.close();165          return { ip, port };166        } catch {167          return null;168        }169      }),170    );171    const hit = results.find((r): r is { ip: string; port: number } => r !== null);172    if (hit) return hit;173  }174  return null;175}