// Audio pairing driver — orchestrates the two-chirp exchange that // bootstraps trust between two devices in the same room. // // Both devices simultaneously: // - play their chirp on a 4-second loop // - listen to the microphone, attempting to decode an incoming chirp // // Once each side has decoded the OTHER side's PEAC chirp, the pairing // transitions to its post-chirp phase (Noise NK over WebSocket — driven // by the coordinator in `chirp-pairing-handshake.ts`, not by this module). // // This module is deliberately transport-agnostic at its boundaries: it // owns the Web Audio + getUserMedia layer and nothing else. The chirp // produced here is bit-identical with what the Android `ggwave-android` // JNI binding produces — the ggwave-fastest ultrasonic protocol is the // shared envelope. // // Replay protection note: the `sessionNonce` in the outgoing chirp is // distinct on every pairing attempt (`createPairingAttempt` rolls a fresh // 16-byte random nonce); a captured chirp replayed on retry produces a // nonce mismatch on the second-leg key exchange and is rejected. import { generateKeyPair } from "@stablelib/x25519"; import { decodeChirpPayload, ChirpDecodeError } from "./chirp-payload"; import { createChirpCodec } from "./ggwave"; import { CHIRP_PROTOCOL_VERSION, type ChirpPayload, type DeviceTypeByte, CHIRP_PAYLOAD_LENGTH, } from "./sync-types"; import { encodeChirpPayload } from "./chirp-payload"; const CHIRP_LOOP_INTERVAL_MS = 4_000; const DEFAULT_TIMEOUT_MS = 90_000; const LISTEN_FRAME_SAMPLES = 4096; export interface PairingAttempt { ephemeralPrivateKey: Uint8Array; // X25519 — never leaves this module ephemeralPublicKey: Uint8Array; sessionNonce: Uint8Array; // 16 bytes outgoingPayload: ChirpPayload; } export function createPairingAttempt(deviceType: DeviceTypeByte): PairingAttempt { const kp = generateKeyPair(); const nonce = new Uint8Array(16); cryptoFill(nonce); return { ephemeralPrivateKey: kp.secretKey, ephemeralPublicKey: kp.publicKey, sessionNonce: nonce, outgoingPayload: { version: CHIRP_PROTOCOL_VERSION, deviceType, ephemeralPublicKey: kp.publicKey, sessionNonce: nonce, }, }; } function cryptoFill(buf: Uint8Array): void { if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { crypto.getRandomValues(buf as Uint8Array); return; } // SAFETY: every supported runtime (MV3 service worker, popup page, // Capacitor WebView) exposes globalThis.crypto.getRandomValues. The // fallback is therefore only ever hit in a broken environment, and // failing closed is the right behaviour. throw new Error("crypto.getRandomValues unavailable"); } export interface ChirpExchangeResult { remoteChirp: ChirpPayload; } export interface ChirpExchangeOptions { attempt: PairingAttempt; timeoutMs?: number; /** Notified each time we abandon a malformed/non-PEAC frame. */ onDiscard?: (reason: ChirpDecodeError["kind"]) => void; /** Notified each time we replay our own chirp. */ onChirpPlayed?: (count: number) => void; } /** * Audio I/O abstraction. Wired to real Web Audio + getUserMedia in * `runChirpExchange`; tests pass a deterministic fake so the play/listen * choreography can be exercised in jsdom. */ export interface AudioIO { sampleRate: number; play(waveform: Float32Array): Promise; pullSamples(): Promise; close(): Promise; } /** * Run the two-chirp exchange against a provided `AudioIO`. Returns once we * have decoded a valid PEAC chirp from a peer; throws on timeout, mic * failure, or a version-mismatch chirp from a too-new peer. * * The function continues chirping while it listens — the two devices * always overlap; ggwave's frequency sub-bands inside the ultrasonic * range let both sides transmit and receive simultaneously. */ export async function runChirpExchange( io: AudioIO, opts: ChirpExchangeOptions, ): Promise { const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; const codec = await createChirpCodec({ sampleRate: io.sampleRate }); const ourBytes = encodeChirpPayload(opts.attempt.outgoingPayload); const ourWaveform = codec.encode(ourBytes); const deadline = Date.now() + timeoutMs; let playCount = 0; let stopped = false; let wakePlayLoop: (() => void) | null = null; function abortPlayLoop() { stopped = true; if (wakePlayLoop) wakePlayLoop(); } function interruptibleSleep(ms: number): Promise { return new Promise((resolve) => { const t = setTimeout(resolve, ms); wakePlayLoop = () => { clearTimeout(t); resolve(); }; }); } const playLoop = (async () => { while (!stopped && Date.now() < deadline) { try { await io.play(ourWaveform); playCount++; opts.onChirpPlayed?.(playCount); } catch { // A play failure (audio device contention, tab backgrounded) is // not fatal — we keep listening; the peer may already be in range. } if (stopped) break; const remaining = deadline - Date.now(); const wait = Math.min(CHIRP_LOOP_INTERVAL_MS, Math.max(0, remaining)); if (wait <= 0) break; await interruptibleSleep(wait); } })(); try { while (Date.now() < deadline && !stopped) { const samples = await io.pullSamples(); if (samples.length === 0) { await sleep(50); continue; } const decoded = codec.decode(samples); if (!decoded) continue; if (decoded.length !== CHIRP_PAYLOAD_LENGTH) { opts.onDiscard?.("length"); continue; } try { const payload = decodeChirpPayload(decoded); // Ignore an echo of our own chirp — happens routinely in tight // ambient feedback. If the bytes match, drop and keep listening. if (sameBytes(payload.ephemeralPublicKey, opts.attempt.ephemeralPublicKey)) { continue; } if (payload.version !== CHIRP_PROTOCOL_VERSION) { throw new ChirpExchangeError("version_mismatch"); } stopped = true; return { remoteChirp: payload }; } catch (err) { if (err instanceof ChirpExchangeError) throw err; if (err instanceof ChirpDecodeError) { if (err.kind === "version") { // A well-formed PEAC chirp with a protocol byte we don't speak // is a real version mismatch — bubble it up so the UI can show // the "Update Peach on both devices" copy. CRC validates first, // so this isn't fired by a random RF glitch. throw new ChirpExchangeError("version_mismatch"); } opts.onDiscard?.(err.kind); continue; } throw err; } } throw new ChirpExchangeError("timeout"); } finally { abortPlayLoop(); codec.free(); await playLoop.catch(() => undefined); await io.close().catch(() => undefined); } } export class ChirpExchangeError extends Error { readonly kind: "timeout" | "version_mismatch" | "mic_denied" | "audio_unavailable"; constructor(kind: ChirpExchangeError["kind"], message?: string) { super(message ?? kind); this.kind = kind; } } function sameBytes(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; } function sleep(ms: number): Promise { return new Promise((res) => setTimeout(res, ms)); } /** * Build a `AudioIO` backed by Web Audio + getUserMedia. Allocates an * AudioContext (caller must invoke from a user gesture — the click that * launched pairing — or playback is suspended on Chrome and Safari). * * `pullSamples` returns the latest accumulated mono buffer from the mic * and resets the internal accumulator. Calling repeatedly yields * non-overlapping slices of recent audio; ggwave's marker-based decoder * tolerates this chunking. */ export async function openBrowserAudioIO(): Promise { if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) { throw new ChirpExchangeError("audio_unavailable", "getUserMedia not available"); } const ctx: AudioContext = new (globalThis.AudioContext ?? (globalThis as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)(); let stream: MediaStream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false }, }); } catch (err) { await ctx.close(); throw new ChirpExchangeError("mic_denied", (err as Error).message); } const source = ctx.createMediaStreamSource(stream); // ScriptProcessorNode is deprecated but is the only widely available // pull-style API in MV3 service workers and the Firefox popup; AudioWorklet // would be cleaner but requires a separate module file. The buffer size // 4096 corresponds to ~90ms at 48kHz — short enough that pairing feels // responsive, long enough that ggwave's FSK windows align. const processor = ctx.createScriptProcessor(LISTEN_FRAME_SAMPLES, 1, 1); let accumulator: Float32Array = new Float32Array(0); processor.onaudioprocess = (ev) => { const input = ev.inputBuffer.getChannelData(0); const next = new Float32Array(accumulator.length + input.length); next.set(accumulator, 0); next.set(input, accumulator.length); accumulator = next; }; source.connect(processor); processor.connect(ctx.destination); return { sampleRate: ctx.sampleRate, async play(waveform: Float32Array): Promise { const buffer = ctx.createBuffer(1, waveform.length, ctx.sampleRate); buffer.copyToChannel(waveform as Float32Array, 0, 0); const src = ctx.createBufferSource(); src.buffer = buffer; src.connect(ctx.destination); await new Promise((resolve) => { src.onended = () => resolve(); src.start(); }); }, async pullSamples(): Promise { const out = accumulator; accumulator = new Float32Array(0); return out; }, async close(): Promise { processor.disconnect(); source.disconnect(); for (const track of stream.getTracks()) track.stop(); await ctx.close(); }, }; }