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
↓ peach-browser-98614d0c.tar.gzatlas at this save
files changed4 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 −0src/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+});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+}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+});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+}