chirp-pair.ts

17.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  handlePairIdentityFrame,29  runPairingInitiator,30  runPairingResponderOpenFrame,31  type LocalIdentity,32  type PairingHandshakeResult,33  type PostNoiseFrame,34} from "../shared/chirp-handshake";35import { computeVaultId } from "../shared/sync-identity";36import { grantAdoption, isAdoptRequest, type AdoptRequest } from "../shared/chirp-adopt";37import {38  DEVICE_TYPE_EXTENSION,39  DEVICE_TYPE_ANDROID,40  DEVICE_TYPE_IOS,41  type PairedDevice,42  type PairingProgress,43} from "../shared/sync-types";4445/**46 * Well-known TCP port used during Phase-1 pairing. Android opens a47 * WebSocket server on this port only while the pairing screen is active;48 * the extension scans the /24 for it after the chirp exchange completes.49 * The persistent sync port (random high port) is announced via the50 * encrypted PAIR_IDENTITY frame and stored on the PairedDevice.51 */52export const CHIRP_PAIRING_PORT = 49142;5354export interface PairingTransport {55  send(msg: Uint8Array): Promise<void>;56  receive(): Promise<Uint8Array>;57  close(): Promise<void>;58}5960export interface ChirpPairingDeps {61  /** Background round-trip — returns the local public identity. */62  fetchLocalIdentity(): Promise<LocalIdentity>;63  /** Background round-trip — persists the paired device. */64  completePairing(paired: PairedDevice): Promise<void>;65  /**66   * Show the Short Authentication String and resolve true only when the user67   * confirms it matches the code displayed on the OTHER device. Pairing is68   * aborted (the peer is NOT persisted) on false. This is the out-of-band69   * mutual-authentication step that defeats a LAN/audio-range attacker who70   * completes the Noise NK handshake in place of the real device — their71   * handshake produces a different SAS than the genuine peer shows, so the72   * user declines (audit HIGH — pairing initiator authentication).73   */74  confirmSAS(sas: string): Promise<boolean>;75  /** Optional — cache the peer's IP for next session's fast path. */76  cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;77  /**78   * Optional — activate a Pro license the peer sent in its identity frame.79   * Called ONLY after the SAS is confirmed. The implementation verifies the80   * signature and ignores it if this device is already Pro. This is how one81   * Pro device grants Pro to the other when pairing (either direction).82   */83  activatePeerLicense?(license: string): Promise<void>;84  /** Source-device adoption consent gate, shown only after SAS confirmation. */85  grantAdoptionConsent?(request: AdoptRequest, sas: string): Promise<boolean>;86  /** Source-device grant material, fetched only after SAS and consent. */87  fetchAdoptionMaterial?(): Promise<{ rootKeyHex: string; vaultId: string; license?: string }>;88  /**89   * RESPONDER ONLY — returns "192.168.1" for a /24; null if not detectable.90   * Used to scan the LAN for the initiator's pairing-port WebSocket server.91   */92  getLocalSubnet(): Promise<string | null>;93  /**94   * RESPONDER ONLY — connect to the initiator's WebSocket server at the95   * given endpoint. Used during the /24 scan and again for the real96   * handshake transport.97   */98  openPairingTransport(endpoint: { ip: string; port: number }): Promise<PairingTransport>;99  /**100   * INITIATOR ONLY — open a WebSocket server on CHIRP_PAIRING_PORT and101   * resolve with a transport when the responder connects. Backed by a102   * native plugin on Capacitor; not used in the extension build.103   *104   * The promise resolves once a peer connection has been accepted. If no105   * peer arrives before the chirp window expires, reject so the orchestrator106   * can surface a `handshake_failed` phase. Close the server in `.close()`.107   */108  openPairingServer?(): Promise<{109    transport: PairingTransport;110    peerIp: string;111  }>;112  /** Override the chirp exchange's `AudioIO` for tests. */113  openAudio?(): Promise<Awaited<ReturnType<typeof openBrowserAudioIO>>>;114  /** Override the audio-exchange timeout — defaults to 90s per the brief. */115  chirpTimeoutMs?: number;116  onProgress(p: PairingProgress): void;117}118119export type PairingRole = "initiator" | "responder";120121export class ChirpPairingFailure extends Error {122  readonly phase: PairingProgress["phase"];123  constructor(phase: PairingProgress["phase"], message: string) {124    super(message);125    this.phase = phase;126  }127}128129export async function runChirpPairing(130  deps: ChirpPairingDeps,131  role: PairingRole = "responder",132): Promise<PairedDevice> {133  deps.onProgress({ phase: "idle" });134135  const localIdentity = await deps.fetchLocalIdentity();136137  // Each role only runs on the matching platform — the extension is always138  // responder (it scans the LAN), Android/iOS/desktop are always initiator139  // (they open the WS server). Mismatches indicate a wiring bug upstream.140  if (role === "responder" && localIdentity.deviceType !== "extension") {141    throw new ChirpPairingFailure("handshake_failed", "responder requires extension identity");142  }143  if (role === "initiator" && localIdentity.deviceType === "extension") {144    throw new ChirpPairingFailure("handshake_failed", "initiator requires non-extension identity");145  }146  if (role === "initiator" && !deps.openPairingServer) {147    throw new ChirpPairingFailure("handshake_failed", "initiator requires openPairingServer dep");148  }149150  deps.onProgress({ phase: "listening" });151152  const deviceTypeByte =153    localIdentity.deviceType === "android" ? DEVICE_TYPE_ANDROID :154    localIdentity.deviceType === "ios" ? DEVICE_TYPE_IOS :155    DEVICE_TYPE_EXTENSION;156  const attempt = createPairingAttempt(deviceTypeByte);157158  let remoteChirpPublicKey: Uint8Array;159  try {160    const audio = await (deps.openAudio ?? openBrowserAudioIO)();161    const result = await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });162    remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey;163  } catch (err) {164    if (err instanceof ChirpExchangeError) {165      const phase: PairingProgress["phase"] =166        err.kind === "timeout" ? "timeout" :167        err.kind === "version_mismatch" ? "version_mismatch" :168        err.kind === "mic_denied" ? "mic_denied" :169        "handshake_failed";170      deps.onProgress({ phase, errorMessage: err.message });171      throw new ChirpPairingFailure(phase, err.message);172    }173    throw err;174  }175176  deps.onProgress({ phase: "handshake" });177178  if (role === "responder") {179    return runResponderHandshake(deps, attempt, localIdentity);180  }181  return runInitiatorHandshake(deps, remoteChirpPublicKey, localIdentity);182}183184export interface SourcePostSASDeps {185  confirmSAS(sas: string): Promise<boolean>;186  completePairing(device: PairedDevice): Promise<void>;187  grantAdoptionConsent?(request: AdoptRequest, sas: string): Promise<boolean>;188  fetchAdoptionMaterial?(): Promise<{ rootKeyHex: string; vaultId: string; license?: string }>;189  activatePeerLicense?(license: string): Promise<void>;190  onProgress(p: PairingProgress): void;191}192193export async function routeSourcePostSASFrame(194  postNoise: PostNoiseFrame,195  localIdentity: LocalIdentity,196  deps: SourcePostSASDeps,197): Promise<PairedDevice | null> {198  if (isAdoptRequest(postNoise.frame)) {199    const sasConfirmed = await deps.confirmSAS(postNoise.sas);200    if (!sasConfirmed) {201      await closeIfPossible(postNoise.channel);202      throw new ChirpPairingFailure("handshake_failed", "sas_rejected");203    }204    if (!deps.grantAdoptionConsent || !deps.fetchAdoptionMaterial) {205      await closeIfPossible(postNoise.channel);206      throw new ChirpPairingFailure("handshake_failed", "adoption_not_supported");207    }208209    const approved = await deps.grantAdoptionConsent(postNoise.frame, postNoise.sas);210    if (!approved) {211      await closeIfPossible(postNoise.channel);212      throw new ChirpPairingFailure("handshake_failed", "adoption_rejected");213    }214215    const material = await deps.fetchAdoptionMaterial();216    if (!/^[0-9a-f]{64}$/.test(material.rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");217    const rootKeyBytes = hexToBytes(material.rootKeyHex);218    try {219      const expectedVaultId = await computeVaultId(rootKeyBytes);220      if (expectedVaultId !== material.vaultId) throw new Error("ADOPT_VAULT_ID_MISMATCH");221      const adopted = await grantAdoption(postNoise.channel, postNoise.frame, {222        sourceIdentity: localIdentity,223        rootKeyHex: async () => material.rootKeyHex,224        vaultId: async () => material.vaultId,225        license: async () => material.license,226        onConsent: async () => true,227        upsertPairedDevice: deps.completePairing,228      });229      if (adopted) deps.onProgress({ phase: "paired", peerDeviceName: adopted.deviceName });230      return adopted;231    } finally {232      rootKeyBytes.fill(0);233    }234  }235236  if ((postNoise.frame as { type?: unknown } | null)?.type === "PAIR_IDENTITY") {237    const result = await handlePairIdentityFrame(238      postNoise.frame,239      postNoise.channel,240      localIdentity,241      postNoise.sas,242    );243    const paired = await gateOnSAS(deps, result);244    await activatePeerLicenseIfAny(deps, result);245    await deps.completePairing(paired);246    return paired;247  }248249  await closeIfPossible(postNoise.channel);250  throw new ChirpPairingFailure("handshake_failed", "unexpected_pairing_frame");251}252253async function runResponderHandshake(254  deps: ChirpPairingDeps,255  attempt: ReturnType<typeof createPairingAttempt>,256  localIdentity: LocalIdentity,257): Promise<PairedDevice> {258  // The peer is somewhere on the LAN; we don't know its IP yet because259  // the chirp payload deliberately omits it. Scan the /24 for the260  // pairing-mode WebSocket port.261  const subnet = await deps.getLocalSubnet();262  if (!subnet) {263    deps.onProgress({ phase: "handshake_failed", errorMessage: "no_subnet" });264    throw new ChirpPairingFailure("handshake_failed", "could not determine local subnet");265  }266267  const peerEndpoint = await firstResponsivePeer(deps, subnet, CHIRP_PAIRING_PORT);268  if (!peerEndpoint) {269    deps.onProgress({ phase: "handshake_failed", errorMessage: "peer_not_found" });270    throw new ChirpPairingFailure("handshake_failed", "could not reach paired peer on subnet");271  }272273  const transport = await deps.openPairingTransport(peerEndpoint);274  let postNoise: PostNoiseFrame;275  try {276    postNoise = await runPairingResponderOpenFrame(277      attempt.ephemeralPrivateKey,278      attempt.ephemeralPublicKey,279      transport,280    );281  } catch (err) {282    await transport.close();283    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });284    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);285  }286  const paired = await routeSourcePostSASFrame(postNoise, localIdentity, deps);287  await transport.close();288  if (!paired) {289    throw new ChirpPairingFailure("handshake_failed", "adoption_rejected");290  }291  await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", peerEndpoint.ip);292  deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });293  return paired;294}295296/**297 * Surface the SAS for user confirmation and abort the pairing if it is not298 * confirmed to match the peer device. Shared by both roles.299 */300// Exported for the PCH-01-024 regression test that pins the SAS gate as301// MANDATORY (the chirp-handshake.ts security comment depends on this staying true).302export async function gateOnSAS(303  deps: Pick<ChirpPairingDeps, "confirmSAS" | "onProgress">,304  result: PairingHandshakeResult,305): Promise<PairedDevice> {306  let confirmed: boolean;307  try {308    confirmed = await deps.confirmSAS(result.sas);309  } catch (err) {310    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });311    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);312  }313  if (!confirmed) {314    deps.onProgress({ phase: "handshake_failed", errorMessage: "sas_rejected" });315    throw new ChirpPairingFailure(316      "handshake_failed",317      "pairing rejected — the verification codes did not match",318    );319  }320  return result.device;321}322323/**324 * If the (now SAS-confirmed) peer advertised a Pro license, hand it to the325 * background to verify + activate. Best-effort: a bad/duplicate license never326 * fails the pairing — the device link still succeeds.327 */328async function activatePeerLicenseIfAny(329  deps: Pick<ChirpPairingDeps, "activatePeerLicense">,330  result: PairingHandshakeResult,331): Promise<void> {332  if (!result.peerLicense || !deps.activatePeerLicense) return;333  try {334    await deps.activatePeerLicense(result.peerLicense);335  } catch {336    // Invalid signature, already-Pro, or storage error — pairing stands.337  }338}339340async function closeIfPossible(channel: unknown): Promise<void> {341  await (channel as { close?: () => Promise<void> }).close?.();342}343344function hexToBytes(hex: string): Uint8Array {345  const out = new Uint8Array(hex.length / 2);346  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);347  return out;348}349350async function runInitiatorHandshake(351  deps: ChirpPairingDeps,352  remoteChirpPublicKey: Uint8Array,353  localIdentity: LocalIdentity,354): Promise<PairedDevice> {355  // Initiator opened a WebSocket server on CHIRP_PAIRING_PORT before the356  // chirp exchange started; here we wait for the responder (extension)357  // to finish its /24 scan and connect to us.358  let server: { transport: PairingTransport; peerIp: string };359  try {360    server = await deps.openPairingServer!();361  } catch (err) {362    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });363    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);364  }365366  let result: PairingHandshakeResult;367  try {368    result = await runPairingInitiator(369      remoteChirpPublicKey,370      localIdentity,371      server.transport,372    );373  } catch (err) {374    await server.transport.close();375    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });376    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);377  }378  await server.transport.close();379380  // Same out-of-band SAS confirmation as the responder side — the initiator381  // (mobile vault host) must not sync to an unverified peer either.382  const paired = await gateOnSAS(deps, result);383  await activatePeerLicenseIfAny(deps, result);384385  await deps.completePairing(paired);386  await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", server.peerIp);387  deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });388  return paired;389}390391async function firstResponsivePeer(392  deps: ChirpPairingDeps,393  subnet: string,394  port: number,395): Promise<{ ip: string; port: number } | null> {396  // We don't have the peer's long-term key yet (that's what we're about397  // to exchange), so we can't run a Noise XX probe like the steady-state398  // sync path does. The pairing-port server accepts any incoming Noise NK399  // request during its short pairing window; the first WebSocket that400  // accepts our connection is the peer.401  //402  // Per-IP timeout strategy: browser WebSocket constructors against an403  // unbound TCP port can hang for several seconds before erroring, which404  // would stretch a /24 scan to minutes. Each probe is raced against a405  // 250ms timer via Promise.race — if the timer wins, we treat the IP as406  // a miss and close the underlying socket if it lands after the loss so407  // we don't leak connections. With 16-wide batching this bounds the full408  // scan to ~254 * 250ms / 16 ≈ 4 seconds in the worst case.409  const PROBE_TIMEOUT_MS = 250;410  const batch = 16;411  for (let start = 1; start <= 254; start += batch) {412    const end = Math.min(start + batch - 1, 254);413    const ips: string[] = [];414    for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);415416    const results = await Promise.all(417      ips.map(async (ip) => {418        let timer: ReturnType<typeof setTimeout> | undefined;419        const timeout = new Promise<null>((resolve) => {420          timer = setTimeout(() => resolve(null), PROBE_TIMEOUT_MS);421        });422        const probe = (async () => {423          try {424            const t = await deps.openPairingTransport({ ip, port });425            return { transport: t, ip, port };426          } catch {427            return null;428          }429        })();430        const winner = await Promise.race([probe, timeout]);431        if (winner && "transport" in winner) {432          if (timer !== undefined) clearTimeout(timer);433          await winner.transport.close();434          return { ip: winner.ip, port: winner.port };435        }436        // Timer won — make sure a late-arriving open socket is closed so437        // we don't leak a connection past the scan.438        probe.then((late) => {439          if (late) void late.transport.close();440        }).catch(() => { /* already a miss */ });441        return null;442      }),443    );444    const hit = results.find((r): r is { ip: string; port: number } => r !== null);445    if (hit) return hit;446  }447  return null;448}