chirp-pairing.ts

10.2 KB · raw

1// Audio pairing driver — orchestrates the two-chirp exchange that2// bootstraps trust between two devices in the same room.3//4// Both devices simultaneously:5//   - play their chirp on a 4-second loop6//   - listen to the microphone, attempting to decode an incoming chirp7//8// Once each side has decoded the OTHER side's PEAC chirp, the pairing9// transitions to its post-chirp phase (Noise NK over WebSocket — driven10// by the coordinator in `chirp-pairing-handshake.ts`, not by this module).11//12// This module is deliberately transport-agnostic at its boundaries: it13// owns the Web Audio + getUserMedia layer and nothing else. The chirp14// produced here is bit-identical with what the Android `ggwave-android`15// JNI binding produces — the ggwave-fastest ultrasonic protocol is the16// shared envelope.17//18// Replay protection note: the `sessionNonce` in the outgoing chirp is19// distinct on every pairing attempt (`createPairingAttempt` rolls a fresh20// 16-byte random nonce); a captured chirp replayed on retry produces a21// nonce mismatch on the second-leg key exchange and is rejected.2223import { generateKeyPair } from "@stablelib/x25519";24import { decodeChirpPayload, ChirpDecodeError } from "./chirp-payload";25import { createChirpCodec } from "./ggwave";26import {27  CHIRP_PROTOCOL_VERSION,28  type ChirpPayload,29  type DeviceTypeByte,30  CHIRP_PAYLOAD_LENGTH,31} from "./sync-types";32import { encodeChirpPayload } from "./chirp-payload";3334const CHIRP_LOOP_INTERVAL_MS = 4_000;35const DEFAULT_TIMEOUT_MS = 90_000;36const LISTEN_FRAME_SAMPLES = 4096;3738export interface PairingAttempt {39  ephemeralPrivateKey: Uint8Array; // X25519 — never leaves this module40  ephemeralPublicKey: Uint8Array;41  sessionNonce: Uint8Array; // 16 bytes42  outgoingPayload: ChirpPayload;43}4445export function createPairingAttempt(deviceType: DeviceTypeByte): PairingAttempt {46  const kp = generateKeyPair();47  const nonce = new Uint8Array(16);48  cryptoFill(nonce);49  return {50    ephemeralPrivateKey: kp.secretKey,51    ephemeralPublicKey: kp.publicKey,52    sessionNonce: nonce,53    outgoingPayload: {54      version: CHIRP_PROTOCOL_VERSION,55      deviceType,56      ephemeralPublicKey: kp.publicKey,57      sessionNonce: nonce,58    },59  };60}6162function cryptoFill(buf: Uint8Array): void {63  if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {64    crypto.getRandomValues(buf as Uint8Array<ArrayBuffer>);65    return;66  }67  // SAFETY: every supported runtime (MV3 service worker, popup page,68  // Capacitor WebView) exposes globalThis.crypto.getRandomValues. The69  // fallback is therefore only ever hit in a broken environment, and70  // failing closed is the right behaviour.71  throw new Error("crypto.getRandomValues unavailable");72}7374export interface ChirpExchangeResult {75  remoteChirp: ChirpPayload;76}7778export interface ChirpExchangeOptions {79  attempt: PairingAttempt;80  timeoutMs?: number;81  /** Notified each time we abandon a malformed/non-PEAC frame. */82  onDiscard?: (reason: ChirpDecodeError["kind"]) => void;83  /** Notified each time we replay our own chirp. */84  onChirpPlayed?: (count: number) => void;85}8687/**88 * Audio I/O abstraction. Wired to real Web Audio + getUserMedia in89 * `runChirpExchange`; tests pass a deterministic fake so the play/listen90 * choreography can be exercised in jsdom.91 */92export interface AudioIO {93  sampleRate: number;94  play(waveform: Float32Array): Promise<void>;95  pullSamples(): Promise<Float32Array>;96  close(): Promise<void>;97}9899/**100 * Run the two-chirp exchange against a provided `AudioIO`. Returns once we101 * have decoded a valid PEAC chirp from a peer; throws on timeout, mic102 * failure, or a version-mismatch chirp from a too-new peer.103 *104 * The function continues chirping while it listens — the two devices105 * always overlap; ggwave's frequency sub-bands inside the ultrasonic106 * range let both sides transmit and receive simultaneously.107 */108export async function runChirpExchange(109  io: AudioIO,110  opts: ChirpExchangeOptions,111): Promise<ChirpExchangeResult> {112  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;113  const codec = await createChirpCodec({ sampleRate: io.sampleRate });114  const ourBytes = encodeChirpPayload(opts.attempt.outgoingPayload);115  const ourWaveform = codec.encode(ourBytes);116117  const deadline = Date.now() + timeoutMs;118  let playCount = 0;119  let stopped = false;120  let wakePlayLoop: (() => void) | null = null;121122  function abortPlayLoop() {123    stopped = true;124    if (wakePlayLoop) wakePlayLoop();125  }126127  function interruptibleSleep(ms: number): Promise<void> {128    return new Promise<void>((resolve) => {129      const t = setTimeout(resolve, ms);130      wakePlayLoop = () => { clearTimeout(t); resolve(); };131    });132  }133134  const playLoop = (async () => {135    while (!stopped && Date.now() < deadline) {136      try {137        await io.play(ourWaveform);138        playCount++;139        opts.onChirpPlayed?.(playCount);140      } catch {141        // A play failure (audio device contention, tab backgrounded) is142        // not fatal — we keep listening; the peer may already be in range.143      }144      if (stopped) break;145      const remaining = deadline - Date.now();146      const wait = Math.min(CHIRP_LOOP_INTERVAL_MS, Math.max(0, remaining));147      if (wait <= 0) break;148      await interruptibleSleep(wait);149    }150  })();151152  try {153    while (Date.now() < deadline && !stopped) {154      const samples = await io.pullSamples();155      if (samples.length === 0) {156        await sleep(50);157        continue;158      }159      const decoded = codec.decode(samples);160      if (!decoded) continue;161      if (decoded.length !== CHIRP_PAYLOAD_LENGTH) {162        opts.onDiscard?.("length");163        continue;164      }165      try {166        const payload = decodeChirpPayload(decoded);167        // Ignore an echo of our own chirp — happens routinely in tight168        // ambient feedback. If the bytes match, drop and keep listening.169        if (sameBytes(payload.ephemeralPublicKey, opts.attempt.ephemeralPublicKey)) {170          continue;171        }172        if (payload.version !== CHIRP_PROTOCOL_VERSION) {173          throw new ChirpExchangeError("version_mismatch");174        }175        stopped = true;176        return { remoteChirp: payload };177      } catch (err) {178        if (err instanceof ChirpExchangeError) throw err;179        if (err instanceof ChirpDecodeError) {180          if (err.kind === "version") {181            // A well-formed PEAC chirp with a protocol byte we don't speak182            // is a real version mismatch — bubble it up so the UI can show183            // the "Update Peach on both devices" copy. CRC validates first,184            // so this isn't fired by a random RF glitch.185            throw new ChirpExchangeError("version_mismatch");186          }187          opts.onDiscard?.(err.kind);188          continue;189        }190        throw err;191      }192    }193    throw new ChirpExchangeError("timeout");194  } finally {195    abortPlayLoop();196    codec.free();197    await playLoop.catch(() => undefined);198    await io.close().catch(() => undefined);199  }200}201202export class ChirpExchangeError extends Error {203  readonly kind: "timeout" | "version_mismatch" | "mic_denied" | "audio_unavailable";204  constructor(kind: ChirpExchangeError["kind"], message?: string) {205    super(message ?? kind);206    this.kind = kind;207  }208}209210function sameBytes(a: Uint8Array, b: Uint8Array): boolean {211  if (a.length !== b.length) return false;212  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;213  return true;214}215216function sleep(ms: number): Promise<void> {217  return new Promise((res) => setTimeout(res, ms));218}219220/**221 * Build a `AudioIO` backed by Web Audio + getUserMedia. Allocates an222 * AudioContext (caller must invoke from a user gesture — the click that223 * launched pairing — or playback is suspended on Chrome and Safari).224 *225 * `pullSamples` returns the latest accumulated mono buffer from the mic226 * and resets the internal accumulator. Calling repeatedly yields227 * non-overlapping slices of recent audio; ggwave's marker-based decoder228 * tolerates this chunking.229 */230export async function openBrowserAudioIO(): Promise<AudioIO> {231  if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {232    throw new ChirpExchangeError("audio_unavailable", "getUserMedia not available");233  }234  const ctx: AudioContext = new (globalThis.AudioContext ??235    (globalThis as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();236  let stream: MediaStream;237  try {238    stream = await navigator.mediaDevices.getUserMedia({239      audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },240    });241  } catch (err) {242    await ctx.close();243    throw new ChirpExchangeError("mic_denied", (err as Error).message);244  }245  const source = ctx.createMediaStreamSource(stream);246247  // ScriptProcessorNode is deprecated but is the only widely available248  // pull-style API in MV3 service workers and the Firefox popup; AudioWorklet249  // would be cleaner but requires a separate module file. The buffer size250  // 4096 corresponds to ~90ms at 48kHz — short enough that pairing feels251  // responsive, long enough that ggwave's FSK windows align.252  const processor = ctx.createScriptProcessor(LISTEN_FRAME_SAMPLES, 1, 1);253  let accumulator: Float32Array = new Float32Array(0);254255  processor.onaudioprocess = (ev) => {256    const input = ev.inputBuffer.getChannelData(0);257    const next = new Float32Array(accumulator.length + input.length);258    next.set(accumulator, 0);259    next.set(input, accumulator.length);260    accumulator = next;261  };262263  source.connect(processor);264  processor.connect(ctx.destination);265266  return {267    sampleRate: ctx.sampleRate,268    async play(waveform: Float32Array): Promise<void> {269      const buffer = ctx.createBuffer(1, waveform.length, ctx.sampleRate);270      buffer.copyToChannel(waveform as Float32Array<ArrayBuffer>, 0, 0);271      const src = ctx.createBufferSource();272      src.buffer = buffer;273      src.connect(ctx.destination);274      await new Promise<void>((resolve) => {275        src.onended = () => resolve();276        src.start();277      });278    },279    async pullSamples(): Promise<Float32Array> {280      const out = accumulator;281      accumulator = new Float32Array(0);282      return out;283    },284    async close(): Promise<void> {285      processor.disconnect();286      source.disconnect();287      for (const track of stream.getTracks()) track.stop();288      await ctx.close();289    },290  };291}