chirp-pair.ts

21.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  handlePairIdentityFrame,29  runPairingInitiatorOpenChannel,30  runPairingInitiator,31  runPairingResponderOpenFrame,32  type LocalIdentity,33  type PairingHandshakeResult,34  type PostNoiseFrame,35} from "../shared/chirp-handshake";36import { computeVaultId } from "../shared/sync-identity";37import { grantAdoption, isAdoptRequest, requestAdoption, type AdoptRequest } from "../shared/chirp-adopt";38import {39  DEVICE_TYPE_EXTENSION,40  DEVICE_TYPE_ANDROID,41  DEVICE_TYPE_IOS,42  type DeviceType,43  type PairedDevice,44  type PairingProgress,45} from "../shared/sync-types";4647/**48 * Well-known TCP port used during Phase-1 pairing. Android opens a49 * WebSocket server on this port only while the pairing screen is active;50 * the extension scans the /24 for it after the chirp exchange completes.51 * The persistent sync port (random high port) is announced via the52 * encrypted PAIR_IDENTITY frame and stored on the PairedDevice.53 */54export const CHIRP_PAIRING_PORT = 49142;5556export interface PairingTransport {57  send(msg: Uint8Array): Promise<void>;58  receive(): Promise<Uint8Array>;59  close(): Promise<void>;60}6162export interface ChirpPairingDeps {63  /** Background round-trip — returns the local public identity. */64  fetchLocalIdentity(): Promise<LocalIdentity>;65  /** Background round-trip — persists the paired device. */66  completePairing(paired: PairedDevice): Promise<void>;67  /**68   * Show the Short Authentication String and resolve true only when the user69   * confirms it matches the code displayed on the OTHER device. Pairing is70   * aborted (the peer is NOT persisted) on false. This is the out-of-band71   * mutual-authentication step that defeats a LAN/audio-range attacker who72   * completes the Noise NK handshake in place of the real device — their73   * handshake produces a different SAS than the genuine peer shows, so the74   * user declines (audit HIGH — pairing initiator authentication).75   */76  confirmSAS(sas: string): Promise<boolean>;77  /** Optional — cache the peer's IP for next session's fast path. */78  cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;79  /**80   * Optional — activate a Pro license the peer sent in its identity frame.81   * Called ONLY after the SAS is confirmed. The implementation verifies the82   * signature and ignores it if this device is already Pro. This is how one83   * Pro device grants Pro to the other when pairing (either direction).84   */85  activatePeerLicense?(license: string): Promise<void>;86  /** Source-device adoption consent gate, shown only after SAS confirmation. */87  grantAdoptionConsent?(request: AdoptRequest, sas: string): Promise<boolean>;88  /** Source-device grant material, fetched only after SAS and consent. */89  fetchAdoptionMaterial?(): Promise<{ rootKeyHex: string; vaultId: string; license?: string }>;90  /**91   * RESPONDER ONLY — returns "192.168.1" for a /24; null if not detectable.92   * Used to scan the LAN for the initiator's pairing-port WebSocket server.93   */94  getLocalSubnet(): Promise<string | null>;95  /**96   * RESPONDER ONLY — connect to the initiator's WebSocket server at the97   * given endpoint. Used during the /24 scan and again for the real98   * handshake transport.99   */100  openPairingTransport(endpoint: { ip: string; port: number }): Promise<PairingTransport>;101  /**102   * INITIATOR ONLY — open a WebSocket server on CHIRP_PAIRING_PORT and103   * resolve with a transport when the responder connects. Backed by a104   * native plugin on Capacitor; not used in the extension build.105   *106   * The promise resolves once a peer connection has been accepted. If no107   * peer arrives before the chirp window expires, reject so the orchestrator108   * can surface a `handshake_failed` phase. Close the server in `.close()`.109   */110  openPairingServer?(): Promise<{111    transport: PairingTransport;112    peerIp: string;113  }>;114  /** Override the chirp exchange's `AudioIO` for tests. */115  openAudio?(): Promise<Awaited<ReturnType<typeof openBrowserAudioIO>>>;116  /** Override the audio-exchange timeout — defaults to 90s per the brief. */117  chirpTimeoutMs?: number;118  onProgress(p: PairingProgress): void;119}120121export type PairingRole = "initiator" | "responder";122123export class ChirpPairingFailure extends Error {124  readonly phase: PairingProgress["phase"];125  constructor(phase: PairingProgress["phase"], message: string) {126    super(message);127    this.phase = phase;128  }129}130131export async function runChirpPairing(132  deps: ChirpPairingDeps,133  role: PairingRole = "responder",134): Promise<PairedDevice> {135  deps.onProgress({ phase: "idle" });136137  const localIdentity = await deps.fetchLocalIdentity();138139  // Each role only runs on the matching platform — the extension is always140  // responder (it scans the LAN), Android/iOS/desktop are always initiator141  // (they open the WS server). Mismatches indicate a wiring bug upstream.142  if (role === "responder" && localIdentity.deviceType !== "extension") {143    throw new ChirpPairingFailure("handshake_failed", "responder requires extension identity");144  }145  if (role === "initiator" && localIdentity.deviceType === "extension") {146    throw new ChirpPairingFailure("handshake_failed", "initiator requires non-extension identity");147  }148  if (role === "initiator" && !deps.openPairingServer) {149    throw new ChirpPairingFailure("handshake_failed", "initiator requires openPairingServer dep");150  }151152  deps.onProgress({ phase: "listening" });153154  const deviceTypeByte =155    localIdentity.deviceType === "android" ? DEVICE_TYPE_ANDROID :156    localIdentity.deviceType === "ios" ? DEVICE_TYPE_IOS :157    DEVICE_TYPE_EXTENSION;158  const attempt = createPairingAttempt(deviceTypeByte);159160  let remoteChirpPublicKey: Uint8Array;161  try {162    const audio = await (deps.openAudio ?? openBrowserAudioIO)();163    const result = await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });164    remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey;165  } catch (err) {166    if (err instanceof ChirpExchangeError) {167      const phase: PairingProgress["phase"] =168        err.kind === "timeout" ? "timeout" :169        err.kind === "version_mismatch" ? "version_mismatch" :170        err.kind === "mic_denied" ? "mic_denied" :171        "handshake_failed";172      deps.onProgress({ phase, errorMessage: err.message });173      throw new ChirpPairingFailure(phase, err.message);174    }175    throw err;176  }177178  deps.onProgress({ phase: "handshake" });179180  if (role === "responder") {181    return runResponderHandshake(deps, attempt, localIdentity);182  }183  return runInitiatorHandshake(deps, remoteChirpPublicKey, localIdentity);184}185186export interface ChirpAdoptionReceiverDeps {187  deviceName: string;188  deviceType?: DeviceType;189  generateDeviceId?(): string;190  createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;191  deriveLocalIdentity(): Promise<LocalIdentity>;192  completePairing(paired: PairedDevice): Promise<void>;193  applyLicense?(license: string): Promise<void>;194  confirmSAS(sas: string): Promise<boolean>;195  openPairingServer(): Promise<{ transport: PairingTransport; peerIp: string }>;196  openAudio?(): Promise<Awaited<ReturnType<typeof openBrowserAudioIO>>>;197  chirpTimeoutMs?: number;198  onProgress(p: PairingProgress): void;199}200201export async function runChirpAdoptionReceiver(202  deps: ChirpAdoptionReceiverDeps,203): Promise<PairedDevice> {204  deps.onProgress({ phase: "idle" });205  deps.onProgress({ phase: "listening" });206207  const attempt = createPairingAttempt(deviceTypeByteFor(deps.deviceType ?? "extension"));208  let remoteChirpPublicKey: Uint8Array;209  try {210    const audio = await (deps.openAudio ?? openBrowserAudioIO)();211    const result = await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });212    remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey;213  } catch (err) {214    if (err instanceof ChirpExchangeError) {215      const phase: PairingProgress["phase"] =216        err.kind === "timeout" ? "timeout" :217        err.kind === "version_mismatch" ? "version_mismatch" :218        err.kind === "mic_denied" ? "mic_denied" :219        "handshake_failed";220      deps.onProgress({ phase, errorMessage: err.message });221      throw new ChirpPairingFailure(phase, err.message);222    }223    throw err;224  }225226  deps.onProgress({ phase: "handshake" });227  let server: { transport: PairingTransport; peerIp: string };228  try {229    server = await deps.openPairingServer();230  } catch (err) {231    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });232    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);233  }234235  try {236    const postNoise = await runPairingInitiatorOpenChannel(remoteChirpPublicKey, server.transport);237    const confirmed = await deps.confirmSAS(postNoise.sas);238    if (!confirmed) {239      deps.onProgress({ phase: "handshake_failed", errorMessage: "sas_rejected" });240      throw new ChirpPairingFailure("handshake_failed", "sas_rejected");241    }242    const source = await requestAdoption(postNoise.channel, {243      deviceName: deps.deviceName,244      generateDeviceId: deps.generateDeviceId ?? generateUuid,245      createVaultFromRootKey: deps.createVaultFromRootKey,246      deriveLocalIdentity: deps.deriveLocalIdentity,247      upsertPairedDevice: deps.completePairing,248      applyLicense: deps.applyLicense,249      onError(error) {250        throw new Error(error);251      },252    });253    if (!source) throw new Error("adoption_failed");254    deps.onProgress({ phase: "paired", peerDeviceName: source.deviceName });255    return source;256  } catch (err) {257    if (err instanceof ChirpPairingFailure) throw err;258    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });259    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);260  } finally {261    await server.transport.close().catch(() => undefined);262  }263}264265export interface SourcePostSASDeps {266  confirmSAS(sas: string): Promise<boolean>;267  completePairing(device: PairedDevice): Promise<void>;268  grantAdoptionConsent?(request: AdoptRequest, sas: string): Promise<boolean>;269  fetchAdoptionMaterial?(): Promise<{ rootKeyHex: string; vaultId: string; license?: string }>;270  activatePeerLicense?(license: string): Promise<void>;271  onProgress(p: PairingProgress): void;272}273274export async function routeSourcePostSASFrame(275  postNoise: PostNoiseFrame,276  localIdentity: LocalIdentity,277  deps: SourcePostSASDeps,278): Promise<PairedDevice | null> {279  if (isAdoptRequest(postNoise.frame)) {280    const sasConfirmed = await deps.confirmSAS(postNoise.sas);281    if (!sasConfirmed) {282      await closeIfPossible(postNoise.channel);283      throw new ChirpPairingFailure("handshake_failed", "sas_rejected");284    }285    if (!deps.grantAdoptionConsent || !deps.fetchAdoptionMaterial) {286      await closeIfPossible(postNoise.channel);287      throw new ChirpPairingFailure("handshake_failed", "adoption_not_supported");288    }289290    const approved = await deps.grantAdoptionConsent(postNoise.frame, postNoise.sas);291    if (!approved) {292      await closeIfPossible(postNoise.channel);293      throw new ChirpPairingFailure("handshake_failed", "adoption_rejected");294    }295296    const material = await deps.fetchAdoptionMaterial();297    if (!/^[0-9a-f]{64}$/.test(material.rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");298    const rootKeyBytes = hexToBytes(material.rootKeyHex);299    try {300      const expectedVaultId = await computeVaultId(rootKeyBytes);301      if (expectedVaultId !== material.vaultId) throw new Error("ADOPT_VAULT_ID_MISMATCH");302      const adopted = await grantAdoption(postNoise.channel, postNoise.frame, {303        sourceIdentity: localIdentity,304        rootKeyHex: async () => material.rootKeyHex,305        vaultId: async () => material.vaultId,306        license: async () => material.license,307        onConsent: async () => true,308        upsertPairedDevice: deps.completePairing,309      });310      if (adopted) deps.onProgress({ phase: "paired", peerDeviceName: adopted.deviceName });311      return adopted;312    } finally {313      rootKeyBytes.fill(0);314    }315  }316317  if ((postNoise.frame as { type?: unknown } | null)?.type === "PAIR_IDENTITY") {318    const result = await handlePairIdentityFrame(319      postNoise.frame,320      postNoise.channel,321      localIdentity,322      postNoise.sas,323    );324    const paired = await gateOnSAS(deps, result);325    await activatePeerLicenseIfAny(deps, result);326    await deps.completePairing(paired);327    return paired;328  }329330  await closeIfPossible(postNoise.channel);331  throw new ChirpPairingFailure("handshake_failed", "unexpected_pairing_frame");332}333334async function runResponderHandshake(335  deps: ChirpPairingDeps,336  attempt: ReturnType<typeof createPairingAttempt>,337  localIdentity: LocalIdentity,338): Promise<PairedDevice> {339  // The peer is somewhere on the LAN; we don't know its IP yet because340  // the chirp payload deliberately omits it. Scan the /24 for the341  // pairing-mode WebSocket port.342  const subnet = await deps.getLocalSubnet();343  if (!subnet) {344    deps.onProgress({ phase: "handshake_failed", errorMessage: "no_subnet" });345    throw new ChirpPairingFailure("handshake_failed", "could not determine local subnet");346  }347348  const peerEndpoint = await firstResponsivePeer(deps, subnet, CHIRP_PAIRING_PORT);349  if (!peerEndpoint) {350    deps.onProgress({ phase: "handshake_failed", errorMessage: "peer_not_found" });351    throw new ChirpPairingFailure("handshake_failed", "could not reach paired peer on subnet");352  }353354  const transport = await deps.openPairingTransport(peerEndpoint);355  let postNoise: PostNoiseFrame;356  try {357    postNoise = await runPairingResponderOpenFrame(358      attempt.ephemeralPrivateKey,359      attempt.ephemeralPublicKey,360      transport,361    );362  } catch (err) {363    await transport.close();364    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });365    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);366  }367  const paired = await routeSourcePostSASFrame(postNoise, localIdentity, deps);368  await transport.close();369  if (!paired) {370    throw new ChirpPairingFailure("handshake_failed", "adoption_rejected");371  }372  await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", peerEndpoint.ip);373  deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });374  return paired;375}376377/**378 * Surface the SAS for user confirmation and abort the pairing if it is not379 * confirmed to match the peer device. Shared by both roles.380 */381// Exported for the PCH-01-024 regression test that pins the SAS gate as382// MANDATORY (the chirp-handshake.ts security comment depends on this staying true).383export async function gateOnSAS(384  deps: Pick<ChirpPairingDeps, "confirmSAS" | "onProgress">,385  result: PairingHandshakeResult,386): Promise<PairedDevice> {387  let confirmed: boolean;388  try {389    confirmed = await deps.confirmSAS(result.sas);390  } catch (err) {391    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });392    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);393  }394  if (!confirmed) {395    deps.onProgress({ phase: "handshake_failed", errorMessage: "sas_rejected" });396    throw new ChirpPairingFailure(397      "handshake_failed",398      "pairing rejected — the verification codes did not match",399    );400  }401  return result.device;402}403404/**405 * If the (now SAS-confirmed) peer advertised a Pro license, hand it to the406 * background to verify + activate. Best-effort: a bad/duplicate license never407 * fails the pairing — the device link still succeeds.408 */409async function activatePeerLicenseIfAny(410  deps: Pick<ChirpPairingDeps, "activatePeerLicense">,411  result: PairingHandshakeResult,412): Promise<void> {413  if (!result.peerLicense || !deps.activatePeerLicense) return;414  try {415    await deps.activatePeerLicense(result.peerLicense);416  } catch {417    // Invalid signature, already-Pro, or storage error — pairing stands.418  }419}420421async function closeIfPossible(channel: unknown): Promise<void> {422  await (channel as { close?: () => Promise<void> }).close?.();423}424425function hexToBytes(hex: string): Uint8Array {426  const out = new Uint8Array(hex.length / 2);427  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);428  return out;429}430431function deviceTypeByteFor(type: DeviceType) {432  return type === "android" ? DEVICE_TYPE_ANDROID :433    type === "ios" ? DEVICE_TYPE_IOS :434    DEVICE_TYPE_EXTENSION;435}436437function generateUuid(): string {438  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {439    return crypto.randomUUID();440  }441  const bytes = new Uint8Array(16);442  crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);443  bytes[6] = (bytes[6] & 0x0f) | 0x40;444  bytes[8] = (bytes[8] & 0x3f) | 0x80;445  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");446  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;447}448449async function runInitiatorHandshake(450  deps: ChirpPairingDeps,451  remoteChirpPublicKey: Uint8Array,452  localIdentity: LocalIdentity,453): Promise<PairedDevice> {454  // Initiator opened a WebSocket server on CHIRP_PAIRING_PORT before the455  // chirp exchange started; here we wait for the responder (extension)456  // to finish its /24 scan and connect to us.457  let server: { transport: PairingTransport; peerIp: string };458  try {459    server = await deps.openPairingServer!();460  } catch (err) {461    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });462    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);463  }464465  let result: PairingHandshakeResult;466  try {467    result = await runPairingInitiator(468      remoteChirpPublicKey,469      localIdentity,470      server.transport,471    );472  } catch (err) {473    await server.transport.close();474    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });475    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);476  }477  await server.transport.close();478479  // Same out-of-band SAS confirmation as the responder side — the initiator480  // (mobile vault host) must not sync to an unverified peer either.481  const paired = await gateOnSAS(deps, result);482  await activatePeerLicenseIfAny(deps, result);483484  await deps.completePairing(paired);485  await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", server.peerIp);486  deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });487  return paired;488}489490async function firstResponsivePeer(491  deps: ChirpPairingDeps,492  subnet: string,493  port: number,494): Promise<{ ip: string; port: number } | null> {495  // We don't have the peer's long-term key yet (that's what we're about496  // to exchange), so we can't run a Noise XX probe like the steady-state497  // sync path does. The pairing-port server accepts any incoming Noise NK498  // request during its short pairing window; the first WebSocket that499  // accepts our connection is the peer.500  //501  // Per-IP timeout strategy: browser WebSocket constructors against an502  // unbound TCP port can hang for several seconds before erroring, which503  // would stretch a /24 scan to minutes. Each probe is raced against a504  // 250ms timer via Promise.race — if the timer wins, we treat the IP as505  // a miss and close the underlying socket if it lands after the loss so506  // we don't leak connections. With 16-wide batching this bounds the full507  // scan to ~254 * 250ms / 16 ≈ 4 seconds in the worst case.508  const PROBE_TIMEOUT_MS = 250;509  const batch = 16;510  for (let start = 1; start <= 254; start += batch) {511    const end = Math.min(start + batch - 1, 254);512    const ips: string[] = [];513    for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);514515    const results = await Promise.all(516      ips.map(async (ip) => {517        let timer: ReturnType<typeof setTimeout> | undefined;518        const timeout = new Promise<null>((resolve) => {519          timer = setTimeout(() => resolve(null), PROBE_TIMEOUT_MS);520        });521        const probe = (async () => {522          try {523            const t = await deps.openPairingTransport({ ip, port });524            return { transport: t, ip, port };525          } catch {526            return null;527          }528        })();529        const winner = await Promise.race([probe, timeout]);530        if (winner && "transport" in winner) {531          if (timer !== undefined) clearTimeout(timer);532          await winner.transport.close();533          return { ip: winner.ip, port: winner.port };534        }535        // Timer won — make sure a late-arriving open socket is closed so536        // we don't leak a connection past the scan.537        probe.then((late) => {538          if (late) void late.transport.close();539        }).catch(() => { /* already a miss */ });540        return null;541      }),542    );543    const hit = results.find((r): r is { ip: string; port: number } => r !== null);544    if (hit) return hit;545  }546  return null;547}