chirp-pair.ts

13.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} 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  /**61   * Show the Short Authentication String and resolve true only when the user62   * confirms it matches the code displayed on the OTHER device. Pairing is63   * aborted (the peer is NOT persisted) on false. This is the out-of-band64   * mutual-authentication step that defeats a LAN/audio-range attacker who65   * completes the Noise NK handshake in place of the real device — their66   * handshake produces a different SAS than the genuine peer shows, so the67   * user declines (audit HIGH — pairing initiator authentication).68   */69  confirmSAS(sas: string): Promise<boolean>;70  /** Optional — cache the peer's IP for next session's fast path. */71  cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;72  /**73   * RESPONDER ONLY — returns "192.168.1" for a /24; null if not detectable.74   * Used to scan the LAN for the initiator's pairing-port WebSocket server.75   */76  getLocalSubnet(): Promise<string | null>;77  /**78   * RESPONDER ONLY — connect to the initiator's WebSocket server at the79   * given endpoint. Used during the /24 scan and again for the real80   * handshake transport.81   */82  openPairingTransport(endpoint: { ip: string; port: number }): Promise<PairingTransport>;83  /**84   * INITIATOR ONLY — open a WebSocket server on CHIRP_PAIRING_PORT and85   * resolve with a transport when the responder connects. Backed by a86   * native plugin on Capacitor; not used in the extension build.87   *88   * The promise resolves once a peer connection has been accepted. If no89   * peer arrives before the chirp window expires, reject so the orchestrator90   * can surface a `handshake_failed` phase. Close the server in `.close()`.91   */92  openPairingServer?(): Promise<{93    transport: PairingTransport;94    peerIp: string;95  }>;96  /** Override the chirp exchange's `AudioIO` for tests. */97  openAudio?(): Promise<Awaited<ReturnType<typeof openBrowserAudioIO>>>;98  /** Override the audio-exchange timeout — defaults to 90s per the brief. */99  chirpTimeoutMs?: number;100  onProgress(p: PairingProgress): void;101}102103export type PairingRole = "initiator" | "responder";104105export class ChirpPairingFailure extends Error {106  readonly phase: PairingProgress["phase"];107  constructor(phase: PairingProgress["phase"], message: string) {108    super(message);109    this.phase = phase;110  }111}112113export async function runChirpPairing(114  deps: ChirpPairingDeps,115  role: PairingRole = "responder",116): Promise<PairedDevice> {117  deps.onProgress({ phase: "idle" });118119  const localIdentity = await deps.fetchLocalIdentity();120121  // Each role only runs on the matching platform — the extension is always122  // responder (it scans the LAN), Android/iOS/desktop are always initiator123  // (they open the WS server). Mismatches indicate a wiring bug upstream.124  if (role === "responder" && localIdentity.deviceType !== "extension") {125    throw new ChirpPairingFailure("handshake_failed", "responder requires extension identity");126  }127  if (role === "initiator" && localIdentity.deviceType === "extension") {128    throw new ChirpPairingFailure("handshake_failed", "initiator requires non-extension identity");129  }130  if (role === "initiator" && !deps.openPairingServer) {131    throw new ChirpPairingFailure("handshake_failed", "initiator requires openPairingServer dep");132  }133134  deps.onProgress({ phase: "listening" });135136  const deviceTypeByte =137    localIdentity.deviceType === "android" ? DEVICE_TYPE_ANDROID :138    localIdentity.deviceType === "ios" ? DEVICE_TYPE_IOS :139    DEVICE_TYPE_EXTENSION;140  const attempt = createPairingAttempt(deviceTypeByte);141142  let remoteChirpPublicKey: Uint8Array;143  try {144    const audio = await (deps.openAudio ?? openBrowserAudioIO)();145    const result = await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });146    remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey;147  } catch (err) {148    if (err instanceof ChirpExchangeError) {149      const phase: PairingProgress["phase"] =150        err.kind === "timeout" ? "timeout" :151        err.kind === "version_mismatch" ? "version_mismatch" :152        err.kind === "mic_denied" ? "mic_denied" :153        "handshake_failed";154      deps.onProgress({ phase, errorMessage: err.message });155      throw new ChirpPairingFailure(phase, err.message);156    }157    throw err;158  }159160  deps.onProgress({ phase: "handshake" });161162  if (role === "responder") {163    return runResponderHandshake(deps, attempt, localIdentity);164  }165  return runInitiatorHandshake(deps, remoteChirpPublicKey, localIdentity);166}167168async function runResponderHandshake(169  deps: ChirpPairingDeps,170  attempt: ReturnType<typeof createPairingAttempt>,171  localIdentity: LocalIdentity,172): Promise<PairedDevice> {173  // The peer is somewhere on the LAN; we don't know its IP yet because174  // the chirp payload deliberately omits it. Scan the /24 for the175  // pairing-mode WebSocket port.176  const subnet = await deps.getLocalSubnet();177  if (!subnet) {178    deps.onProgress({ phase: "handshake_failed", errorMessage: "no_subnet" });179    throw new ChirpPairingFailure("handshake_failed", "could not determine local subnet");180  }181182  const peerEndpoint = await firstResponsivePeer(deps, subnet, CHIRP_PAIRING_PORT);183  if (!peerEndpoint) {184    deps.onProgress({ phase: "handshake_failed", errorMessage: "peer_not_found" });185    throw new ChirpPairingFailure("handshake_failed", "could not reach paired peer on subnet");186  }187188  const transport = await deps.openPairingTransport(peerEndpoint);189  let result: { device: PairedDevice; sas: string };190  try {191    result = await runPairingResponder(192      attempt.ephemeralPrivateKey,193      attempt.ephemeralPublicKey,194      localIdentity,195      transport,196    );197  } catch (err) {198    await transport.close();199    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });200    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);201  }202  await transport.close();203204  // Out-of-band verification BEFORE persisting: the user must confirm the SAS205  // matches the code on the other device. Without this, whoever completed the206  // handshake (possibly a LAN/audio-range attacker) would be enrolled as a207  // trusted sync peer (audit HIGH).208  const paired = await gateOnSAS(deps, result);209210  // The connection's IP is cached in `chrome.storage.local` keyed by211  // pubkey — see `paired-devices.ts#setLastKnownIp`. The persistent v3212  // `PairedDevice` record itself has no `lastKnownIp` field because the213  // IP is per-network and must not roam over `chrome.storage.sync`.214  await deps.completePairing(paired);215  await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", peerEndpoint.ip);216  deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });217  return paired;218}219220/**221 * Surface the SAS for user confirmation and abort the pairing if it is not222 * confirmed to match the peer device. Shared by both roles.223 */224// Exported for the PCH-01-024 regression test that pins the SAS gate as225// MANDATORY (the chirp-handshake.ts security comment depends on this staying true).226export async function gateOnSAS(227  deps: ChirpPairingDeps,228  result: { device: PairedDevice; sas: string },229): Promise<PairedDevice> {230  let confirmed: boolean;231  try {232    confirmed = await deps.confirmSAS(result.sas);233  } catch (err) {234    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });235    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);236  }237  if (!confirmed) {238    deps.onProgress({ phase: "handshake_failed", errorMessage: "sas_rejected" });239    throw new ChirpPairingFailure(240      "handshake_failed",241      "pairing rejected — the verification codes did not match",242    );243  }244  return result.device;245}246247async function runInitiatorHandshake(248  deps: ChirpPairingDeps,249  remoteChirpPublicKey: Uint8Array,250  localIdentity: LocalIdentity,251): Promise<PairedDevice> {252  // Initiator opened a WebSocket server on CHIRP_PAIRING_PORT before the253  // chirp exchange started; here we wait for the responder (extension)254  // to finish its /24 scan and connect to us.255  let server: { transport: PairingTransport; peerIp: string };256  try {257    server = await deps.openPairingServer!();258  } catch (err) {259    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });260    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);261  }262263  let result: { device: PairedDevice; sas: string };264  try {265    result = await runPairingInitiator(266      remoteChirpPublicKey,267      localIdentity,268      server.transport,269    );270  } catch (err) {271    await server.transport.close();272    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });273    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);274  }275  await server.transport.close();276277  // Same out-of-band SAS confirmation as the responder side — the initiator278  // (mobile vault host) must not sync to an unverified peer either.279  const paired = await gateOnSAS(deps, result);280281  await deps.completePairing(paired);282  await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", server.peerIp);283  deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });284  return paired;285}286287async function firstResponsivePeer(288  deps: ChirpPairingDeps,289  subnet: string,290  port: number,291): Promise<{ ip: string; port: number } | null> {292  // We don't have the peer's long-term key yet (that's what we're about293  // to exchange), so we can't run a Noise XX probe like the steady-state294  // sync path does. The pairing-port server accepts any incoming Noise NK295  // request during its short pairing window; the first WebSocket that296  // accepts our connection is the peer.297  //298  // Per-IP timeout strategy: browser WebSocket constructors against an299  // unbound TCP port can hang for several seconds before erroring, which300  // would stretch a /24 scan to minutes. Each probe is raced against a301  // 250ms timer via Promise.race — if the timer wins, we treat the IP as302  // a miss and close the underlying socket if it lands after the loss so303  // we don't leak connections. With 16-wide batching this bounds the full304  // scan to ~254 * 250ms / 16 ≈ 4 seconds in the worst case.305  const PROBE_TIMEOUT_MS = 250;306  const batch = 16;307  for (let start = 1; start <= 254; start += batch) {308    const end = Math.min(start + batch - 1, 254);309    const ips: string[] = [];310    for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);311312    const results = await Promise.all(313      ips.map(async (ip) => {314        let timer: ReturnType<typeof setTimeout> | undefined;315        const timeout = new Promise<null>((resolve) => {316          timer = setTimeout(() => resolve(null), PROBE_TIMEOUT_MS);317        });318        const probe = (async () => {319          try {320            const t = await deps.openPairingTransport({ ip, port });321            return { transport: t, ip, port };322          } catch {323            return null;324          }325        })();326        const winner = await Promise.race([probe, timeout]);327        if (winner && "transport" in winner) {328          if (timer !== undefined) clearTimeout(timer);329          await winner.transport.close();330          return { ip: winner.ip, port: winner.port };331        }332        // Timer won — make sure a late-arriving open socket is closed so333        // we don't leak a connection past the scan.334        probe.then((late) => {335          if (late) void late.transport.close();336        }).catch(() => { /* already a miss */ });337        return null;338      }),339    );340    const hit = results.find((r): r is { ip: string; port: number } => r !== null);341    if (hit) return hit;342  }343  return null;344}