98614d0cb031

add audio chirp exchange driver and post-chirp Noise NK + long-term identity exchange

time
2026-05-18 02:16 UTC · 5 months ago
machine
koh2
parent
5cb96ec333eb
4 files src/shared/chirp-handshake.test.ts+96 −0src/shared/chirp-handshake.ts+179 −0src/shared/chirp-pairing.test.ts+191 −0src/shared/chirp-pairing.ts+291 −0
src/shared/chirp-handshake.test.ts+96 −0
@@ -0,0 +1,96 @@1+import { describe, expect, it } from "vitest";2+import { generateKeyPair } from "@stablelib/x25519";3+import {4+  memoryTransportPair,5+  runPairingInitiator,6+  runPairingResponder,7+  type LocalIdentity,8+} from "./chirp-handshake";9+import { deriveSyncIdentityKeypair, pubkeyToHex } from "./sync-identity";10+11+const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 5 + 3) & 0xff));12+13+function identityFor(deviceId: string, deviceName: string, type: LocalIdentity["deviceType"]): LocalIdentity {14+  const kp = deriveSyncIdentityKeypair(MASTER, deviceId);15+  return { deviceId, deviceName, deviceType: type, longTermPublicKeyHex: pubkeyToHex(kp.publicKey) };16+}17+18+describe("chirp pairing handshake (Noise NK + identity exchange)", () => {19+  it("exchanges long-term sync identities between initiator and responder", async () => {20+    // Responder owns the ephemeral that the initiator targets.21+    const respEphemeral = generateKeyPair();22+23+    const initIdent = identityFor("android-001", "June's Pixel 9", "android");24+    const respIdent = identityFor("ext-001", "Firefox on MacBook", "extension");25+26+    const [t1, t2] = memoryTransportPair();27+    const respPromise = runPairingResponder(28+      respEphemeral.secretKey,29+      respEphemeral.publicKey,30+      respIdent,31+      t2,32+    );33+    const initPromise = runPairingInitiator(respEphemeral.publicKey, initIdent, t1);34+35+    const [respLearned, initLearned] = await Promise.all([respPromise, initPromise]);36+    expect(initLearned.deviceId).toBe("ext-001");37+    expect(initLearned.deviceName).toBe("Firefox on MacBook");38+    expect(initLearned.deviceType).toBe("extension");39+    expect(initLearned.longTermPublicKeyHex).toBe(respIdent.longTermPublicKeyHex);40+41+    expect(respLearned.deviceId).toBe("android-001");42+    expect(respLearned.deviceName).toBe("June's Pixel 9");43+    expect(respLearned.deviceType).toBe("android");44+    expect(respLearned.longTermPublicKeyHex).toBe(initIdent.longTermPublicKeyHex);45+  });46+47+  it("rejects a malformed identity frame", async () => {48+    const respEphemeral = generateKeyPair();49+    const respIdent = identityFor("ext-002", "Firefox", "extension");50+    const [t1, t2] = memoryTransportPair();51+52+    // Initiator sends garbage instead of a PAIR_IDENTITY frame53+    const respPromise = runPairingResponder(54+      respEphemeral.secretKey,55+      respEphemeral.publicKey,56+      respIdent,57+      t2,58+    );59+    const evilInitiator = (async () => {60+      const { noiseNKInitiator } = await import("./noise-js");61+      const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);62+      await t1.send(session.encrypt(new TextEncoder().encode("{}"))); // not a PAIR_IDENTITY63+    })();64+65+    await expect(respPromise).rejects.toThrow(/PAIR_IDENTITY/);66+    await evilInitiator.catch(() => undefined);67+  });68+69+  it("rejects an identity with a malformed long_term_public_key_hex", async () => {70+    const respEphemeral = generateKeyPair();71+    const respIdent = identityFor("ext-003", "Firefox", "extension");72+    const [t1, t2] = memoryTransportPair();73+74+    const respPromise = runPairingResponder(75+      respEphemeral.secretKey,76+      respEphemeral.publicKey,77+      respIdent,78+      t2,79+    );80+    const evilInitiator = (async () => {81+      const { noiseNKInitiator } = await import("./noise-js");82+      const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);83+      const frame = JSON.stringify({84+        type: "PAIR_IDENTITY",85+        device_id: "evil",86+        device_name: "evil",87+        device_type: "android",88+        long_term_public_key_hex: "deadbeef", // too short89+      });90+      await t1.send(session.encrypt(new TextEncoder().encode(frame)));91+    })();92+93+    await expect(respPromise).rejects.toThrow(/32-byte hex/);94+    await evilInitiator.catch(() => undefined);95+  });96+});

view file

src/shared/chirp-handshake.ts+179 −0
@@ -0,0 +1,179 @@1+// Post-chirp pairing handshake.2+//3+// After both devices have exchanged ephemeral X25519 keys via the audio4+// chirp, the actual trust transfer happens over an authenticated, encrypted5+// channel built on top of those ephemerals. The handshake:6+//7+//   1. Both sides instantiate Noise NK using the chirp-derived ephemerals.8+//      The Android device drives the initiator side; the extension drives9+//      the responder side. Treating the chirp ephemerals as the NK "static"10+//      key works because the chirp transports the ephemeral with checksum11+//      integrity; an attacker who could substitute keys would have to do12+//      so in the audio band, in real time, in the same room.13+//14+//   2. Over the resulting Noise transport, each side sends ONE JSON frame15+//      with its `device_id`, `device_name`, `device_type`, and16+//      `long_term_public_key_hex`. Both sides receive the counterpart's17+//      frame and persist it in the paired device registry.18+//19+//   3. The Noise NK session and the chirp ephemerals are discarded —20+//      future WebSocket sessions use Noise XX with the long-term static21+//      keys exchanged in step 2.22+//23+// The transport for the Noise NK round is provided by the caller. In the24+// real pairing flow it is a WebSocket (Android-as-server, extension-as-25+// client). Tests pass an in-memory transport.26+27+import { noiseNKInitiator, noiseNKResponder } from "./noise-js";28+import type { DeviceType, PairedDevice } from "./sync-types";29+30+export interface PairingTransport {31+  send(msg: Uint8Array): Promise<void>;32+  receive(): Promise<Uint8Array>;33+}34+35+export interface LocalIdentity {36+  deviceId: string;37+  deviceName: string;38+  deviceType: DeviceType;39+  longTermPublicKeyHex: string;40+}41+42+interface PairingFrame {43+  type: "PAIR_IDENTITY";44+  device_id: string;45+  device_name: string;46+  device_type: DeviceType;47+  long_term_public_key_hex: string;48+}49+50+function encodeFrame(frame: PairingFrame): Uint8Array {51+  return new TextEncoder().encode(JSON.stringify(frame));52+}53+54+function decodeFrame(bytes: Uint8Array): PairingFrame {55+  const parsed = JSON.parse(new TextDecoder().decode(bytes));56+  if (parsed?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");57+  if (typeof parsed.device_id !== "string" || !parsed.device_id) throw new Error("device_id missing");58+  if (typeof parsed.device_name !== "string") throw new Error("device_name missing");59+  if (typeof parsed.device_type !== "string") throw new Error("device_type missing");60+  if (typeof parsed.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing");61+  // Long-term pubkey is a hex-encoded 32-byte X25519 key.62+  if (!/^[0-9a-f]{64}$/.test(parsed.long_term_public_key_hex)) {63+    throw new Error("long_term_public_key_hex is not 32-byte hex");64+  }65+  return parsed as PairingFrame;66+}67+68+/**69+ * Run the post-chirp pairing handshake from the INITIATOR side (Android).70+ *71+ * `remoteEphemeralPublicKey` is the responder's ephemeral pubkey, which we72+ * learned from the inbound chirp. The Noise NK transport message 1 will73+ * encrypt to that key; the responder must hold the matching private key.74+ */75+export async function runPairingInitiator(76+  remoteEphemeralPublicKey: Uint8Array,77+  ourIdentity: LocalIdentity,78+  transport: PairingTransport,79+): Promise<PairedDevice> {80+  const session = await noiseNKInitiator(81+    remoteEphemeralPublicKey,82+    transport.send,83+    transport.receive,84+  );85+86+  // Send our identity, then receive theirs.87+  const ourFrame = encodeFrame({88+    type: "PAIR_IDENTITY",89+    device_id: ourIdentity.deviceId,90+    device_name: ourIdentity.deviceName,91+    device_type: ourIdentity.deviceType,92+    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,93+  });94+  await transport.send(session.encrypt(ourFrame));95+96+  const theirCipher = await transport.receive();97+  const theirPlain = session.decrypt(theirCipher);98+  const theirFrame = decodeFrame(theirPlain);99+  return frameToPairedDevice(theirFrame);100+}101+102+/**103+ * Run the post-chirp pairing handshake from the RESPONDER side (extension).104+ *105+ * `ourEphemeralPrivateKey` / `ourEphemeralPublicKey` come from the chirp106+ * the responder broadcast; the initiator's chirp told them which key to107+ * authenticate against.108+ */109+export async function runPairingResponder(110+  ourEphemeralPrivateKey: Uint8Array,111+  ourEphemeralPublicKey: Uint8Array,112+  ourIdentity: LocalIdentity,113+  transport: PairingTransport,114+): Promise<PairedDevice> {115+  const session = await noiseNKResponder(116+    ourEphemeralPrivateKey,117+    ourEphemeralPublicKey,118+    transport.send,119+    transport.receive,120+  );121+122+  // Receive theirs first, then send ours — the initiator sends first.123+  const theirCipher = await transport.receive();124+  const theirPlain = session.decrypt(theirCipher);125+  const theirFrame = decodeFrame(theirPlain);126+127+  const ourFrame = encodeFrame({128+    type: "PAIR_IDENTITY",129+    device_id: ourIdentity.deviceId,130+    device_name: ourIdentity.deviceName,131+    device_type: ourIdentity.deviceType,132+    long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,133+  });134+  await transport.send(session.encrypt(ourFrame));135+136+  return frameToPairedDevice(theirFrame);137+}138+139+function frameToPairedDevice(frame: PairingFrame): PairedDevice {140+  return {141+    deviceId: frame.device_id,142+    deviceName: frame.device_name,143+    deviceType: frame.device_type,144+    longTermPublicKeyHex: frame.long_term_public_key_hex,145+    pairedAt: Date.now(),146+  };147+}148+149+/**150+ * Test-helper: an in-memory pair of transports that connect to each other.151+ * Use one half for the initiator and the other for the responder.152+ */153+export function memoryTransportPair(): [PairingTransport, PairingTransport] {154+  const a: Uint8Array[] = [];155+  const b: Uint8Array[] = [];156+  const aReaders: Array<(b: Uint8Array) => void> = [];157+  const bReaders: Array<(b: Uint8Array) => void> = [];158+159+  function makeTransport(160+    inbox: Uint8Array[],161+    outbox: Uint8Array[],162+    inboxReaders: Array<(b: Uint8Array) => void>,163+    outboxReaders: Array<(b: Uint8Array) => void>,164+  ): PairingTransport {165+    return {166+      async send(msg: Uint8Array) {167+        const r = outboxReaders.shift();168+        if (r) r(msg);169+        else outbox.push(msg);170+      },171+      receive() {172+        const msg = inbox.shift();173+        if (msg) return Promise.resolve(msg);174+        return new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));175+      },176+    };177+  }178+  return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)];179+}

view file

src/shared/chirp-pairing.test.ts+191 −0
@@ -0,0 +1,191 @@1+import { afterEach, beforeEach, describe, expect, it } from "vitest";2+import { CHIRP_PAYLOAD_LENGTH, DEVICE_TYPE_ANDROID, DEVICE_TYPE_EXTENSION } from "./sync-types";3+import { encodeChirpPayload } from "./chirp-payload";4+import {5+  ChirpExchangeError,6+  createPairingAttempt,7+  runChirpExchange,8+  type AudioIO,9+} from "./chirp-pairing";10+import { __setGgwaveModuleForTest } from "./ggwave";11+12+function fakeGgwaveThatPassesThroughBytes() {13+  // Synthesize a ggwave that round-trips: encode → push raw bytes into the14+  // waveform, decode → return any waveform whose first 64 samples decode as15+  // a payload. This keeps the chirp-pairing logic in scope without needing16+  // a real FSK codec.17+  return {18+    ProtocolId: { GGWAVE_PROTOCOL_ULTRASOUND_FASTEST: 5 },19+    GGWAVE_OPERATING_MODE_RX: 1,20+    GGWAVE_OPERATING_MODE_TX: 2,21+    GGWAVE_OPERATING_MODE_RX_AND_TX: 3,22+    getDefaultParameters() {23+      return { sampleRateInp: 48000, sampleRateOut: 48000, samplesPerFrame: 1024, soundMarkerThreshold: 3, payloadLength: -1, operatingMode: 0 };24+    },25+    init() { return 1; },26+    free() { /* no-op */ },27+    encode(_instance: number, data: Uint8Array | string, _proto: number, _vol: number) {28+      const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;29+      // Pack bytes as float samples in the first N positions; the rest30+      // padded with sentinel value 99 so a partial read doesn't match.31+      const out = new Float32Array(Math.max(bytes.length, 128));32+      for (let i = 0; i < bytes.length; i++) out[i] = bytes[i] / 255;33+      for (let i = bytes.length; i < out.length; i++) out[i] = 99;34+      return out;35+    },36+    decode(_instance: number, samples: Float32Array | Int16Array) {37+      if (samples.length < CHIRP_PAYLOAD_LENGTH) return null;38+      // Read first 64 samples back as bytes if they look like our encoding.39+      // Sentinel 99 means "no chirp here".40+      const buf = new Uint8Array(CHIRP_PAYLOAD_LENGTH);41+      for (let i = 0; i < CHIRP_PAYLOAD_LENGTH; i++) {42+        const v = (samples as Float32Array)[i];43+        if (v === 99) return null;44+        buf[i] = Math.round(v * 255);45+      }46+      return buf;47+    },48+  };49+}50+51+function fakeIO(): AudioIO & {52+  injectIncoming(samples: Float32Array): void;53+  playedCount(): number;54+  closed(): boolean;55+} {56+  let pending = new Float32Array(0);57+  let playCount = 0;58+  let isClosed = false;59+  return {60+    sampleRate: 48000,61+    async play(_waveform: Float32Array) {62+      playCount++;63+    },64+    async pullSamples() {65+      const out = pending;66+      pending = new Float32Array(0);67+      return out;68+    },69+    async close() {70+      isClosed = true;71+    },72+    injectIncoming(samples: Float32Array) {73+      const next = new Float32Array(pending.length + samples.length);74+      next.set(pending, 0);75+      next.set(samples, pending.length);76+      pending = next;77+    },78+    playedCount: () => playCount,79+    closed: () => isClosed,80+  };81+}82+83+describe("createPairingAttempt", () => {84+  it("rolls fresh ephemerals and a 16-byte nonce each call", () => {85+    const a = createPairingAttempt(DEVICE_TYPE_EXTENSION);86+    const b = createPairingAttempt(DEVICE_TYPE_EXTENSION);87+    expect(a.ephemeralPublicKey).not.toEqual(b.ephemeralPublicKey);88+    expect(a.sessionNonce.length).toBe(16);89+    expect(a.outgoingPayload.ephemeralPublicKey).toEqual(a.ephemeralPublicKey);90+    expect(a.outgoingPayload.deviceType).toBe(DEVICE_TYPE_EXTENSION);91+  });92+});93+94+describe("runChirpExchange", () => {95+  beforeEach(() => __setGgwaveModuleForTest(fakeGgwaveThatPassesThroughBytes()));96+  afterEach(() => __setGgwaveModuleForTest(null));97+98+  it("returns the remote payload when a valid chirp is received", async () => {99+    const io = fakeIO();100+    const ours = createPairingAttempt(DEVICE_TYPE_EXTENSION);101+    const theirs = createPairingAttempt(DEVICE_TYPE_ANDROID);102+    const theirBytes = encodeChirpPayload(theirs.outgoingPayload);103+    // Convert the 64 bytes into the encoded waveform shape our fake produces.104+    const theirWave = new Float32Array(128);105+    for (let i = 0; i < theirBytes.length; i++) theirWave[i] = theirBytes[i] / 255;106+    for (let i = theirBytes.length; i < theirWave.length; i++) theirWave[i] = 99;107+    io.injectIncoming(theirWave);108+109+    const result = await runChirpExchange(io, { attempt: ours, timeoutMs: 2000 });110+    expect(result.remoteChirp.ephemeralPublicKey).toEqual(theirs.ephemeralPublicKey);111+    expect(result.remoteChirp.sessionNonce).toEqual(theirs.sessionNonce);112+    expect(result.remoteChirp.deviceType).toBe(DEVICE_TYPE_ANDROID);113+    expect(io.closed()).toBe(true);114+  });115+116+  it("ignores echo of our own chirp", async () => {117+    const io = fakeIO();118+    const ours = createPairingAttempt(DEVICE_TYPE_EXTENSION);119+    const ourBytes = encodeChirpPayload(ours.outgoingPayload);120+    const ourWave = new Float32Array(128);121+    for (let i = 0; i < ourBytes.length; i++) ourWave[i] = ourBytes[i] / 255;122+    for (let i = ourBytes.length; i < ourWave.length; i++) ourWave[i] = 99;123+    // Inject our own chirp — should be ignored124+    io.injectIncoming(ourWave);125+126+    // After 200ms inject a real peer chirp127+    const theirs = createPairingAttempt(DEVICE_TYPE_ANDROID);128+    const theirBytes = encodeChirpPayload(theirs.outgoingPayload);129+    const theirWave = new Float32Array(128);130+    for (let i = 0; i < theirBytes.length; i++) theirWave[i] = theirBytes[i] / 255;131+    for (let i = theirBytes.length; i < theirWave.length; i++) theirWave[i] = 99;132+    setTimeout(() => io.injectIncoming(theirWave), 150);133+134+    const result = await runChirpExchange(io, { attempt: ours, timeoutMs: 3000 });135+    expect(result.remoteChirp.ephemeralPublicKey).toEqual(theirs.ephemeralPublicKey);136+  });137+138+  it("times out after the configured deadline when no chirp arrives", async () => {139+    const io = fakeIO();140+    const ours = createPairingAttempt(DEVICE_TYPE_EXTENSION);141+    await expect(runChirpExchange(io, { attempt: ours, timeoutMs: 200 })).rejects.toMatchObject({142+      kind: "timeout",143+    });144+    expect(io.closed()).toBe(true);145+  });146+147+  it("raises version_mismatch on a peer with a different protocol version", async () => {148+    const io = fakeIO();149+    const ours = createPairingAttempt(DEVICE_TYPE_EXTENSION);150+    const theirs = createPairingAttempt(DEVICE_TYPE_ANDROID);151+    // Mint a chirp with a future protocol version, then re-stamp the CRC152+    const tweaked = { ...theirs.outgoingPayload, version: 0x02 };153+    const bytes = encodeChirpPayload({154+      ...tweaked,155+      // encodeChirpPayload writes a fresh CRC32 over the new bytes156+    });157+    const wave = new Float32Array(128);158+    for (let i = 0; i < bytes.length; i++) wave[i] = bytes[i] / 255;159+    for (let i = bytes.length; i < wave.length; i++) wave[i] = 99;160+    io.injectIncoming(wave);161+162+    await expect(runChirpExchange(io, { attempt: ours, timeoutMs: 2000 })).rejects.toMatchObject({163+      kind: "version_mismatch",164+    });165+  });166+167+  it("ignores noise chunks (no chirp detected) and keeps listening", async () => {168+    const io = fakeIO();169+    const ours = createPairingAttempt(DEVICE_TYPE_EXTENSION);170+    // Push pure noise — fake decoder returns null because samples have value 99171+    io.injectIncoming(new Float32Array(256).fill(99));172+173+    const theirs = createPairingAttempt(DEVICE_TYPE_ANDROID);174+    const theirBytes = encodeChirpPayload(theirs.outgoingPayload);175+    const theirWave = new Float32Array(128);176+    for (let i = 0; i < theirBytes.length; i++) theirWave[i] = theirBytes[i] / 255;177+    for (let i = theirBytes.length; i < theirWave.length; i++) theirWave[i] = 99;178+    setTimeout(() => io.injectIncoming(theirWave), 100);179+180+    const result = await runChirpExchange(io, { attempt: ours, timeoutMs: 3000 });181+    expect(result.remoteChirp.ephemeralPublicKey).toEqual(theirs.ephemeralPublicKey);182+  });183+});184+185+describe("ChirpExchangeError", () => {186+  it("carries a kind tag", () => {187+    const err = new ChirpExchangeError("timeout");188+    expect(err.kind).toBe("timeout");189+    expect(err).toBeInstanceOf(Error);190+  });191+});

view file

src/shared/chirp-pairing.ts+291 −0
@@ -0,0 +1,291 @@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.22+23+import { generateKeyPair } from "@stablelib/x25519";24+import { decodeChirpPayload, ChirpDecodeError } from "./chirp-payload";25+import { createChirpCodec } from "./ggwave";26+import {27+  CHIRP_PROTOCOL_VERSION,28+  type ChirpPayload,29+  type DeviceTypeByte,30+  CHIRP_PAYLOAD_LENGTH,31+} from "./sync-types";32+import { encodeChirpPayload } from "./chirp-payload";33+34+const CHIRP_LOOP_INTERVAL_MS = 4_000;35+const DEFAULT_TIMEOUT_MS = 90_000;36+const LISTEN_FRAME_SAMPLES = 4096;37+38+export interface PairingAttempt {39+  ephemeralPrivateKey: Uint8Array; // X25519 — never leaves this module40+  ephemeralPublicKey: Uint8Array;41+  sessionNonce: Uint8Array; // 16 bytes42+  outgoingPayload: ChirpPayload;43+}44+45+export 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+}61+62+function 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+}73+74+export interface ChirpExchangeResult {75+  remoteChirp: ChirpPayload;76+}77+78+export 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+}86+87+/**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+ */92+export interface AudioIO {93+  sampleRate: number;94+  play(waveform: Float32Array): Promise<void>;95+  pullSamples(): Promise<Float32Array>;96+  close(): Promise<void>;97+}98+99+/**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+ */108+export 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);116+117+  const deadline = Date.now() + timeoutMs;118+  let playCount = 0;119+  let stopped = false;120+  let wakePlayLoop: (() => void) | null = null;121+122+  function abortPlayLoop() {123+    stopped = true;124+    if (wakePlayLoop) wakePlayLoop();125+  }126+127+  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+  }133+134+  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+  })();151+152+  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+}201+202+export 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+}209+210+function 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+}215+216+function sleep(ms: number): Promise<void> {217+  return new Promise((res) => setTimeout(res, ms));218+}219+220+/**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+ */230+export 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);246+247+  // 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);254+255+  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+  };262+263+  source.connect(processor);264+  processor.connect(ctx.destination);265+266+  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+}

view file