diff --git a/web/chat.html b/web/chat.html index 5e40332..7f34beb 100644 --- a/web/chat.html +++ b/web/chat.html @@ -14,7 +14,7 @@ * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: monospace; background: #fff; color: #000; - padding: 2rem; max-width: 760px; margin: 0 auto; + padding: 2rem; max-width: 820px; margin: 0 auto; } h1 { font-family: 'chunkfiveregular', serif; @@ -50,6 +50,7 @@ } .dot.on { background: #000; } .dot.warn { background: #888; } + .dot.ok { background: #060; border-color: #060; } label, .note { font-size: 0.75rem; color: #555; } .note { margin-top: 0.4rem; line-height: 1.5; } input[type=text], input[type=password], textarea, select { @@ -58,7 +59,7 @@ background: #fff; color: #000; } input[type=text], input[type=password], select { width: 100%; } - textarea { width: 100%; resize: vertical; } + textarea { width: 100%; resize: vertical; min-height: 4.5rem; } .field-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; } .field-row > input, .field-row > textarea { flex: 1; } .key-display { @@ -126,14 +127,16 @@ .mode-toggle > input:checked + label { background: #000; color: #fff; } .mode-panel { display: none; } .mode-panel.active { display: block; } - .field-help { font-size: 0.7rem; color: #777; margin-top: 0.2rem; } + .rtc-role { margin-top: 0.6rem; padding: 0.5rem; border: 1px dashed #999; } + .rtc-role h3 { font-size: 0.85rem; margin-bottom: 0.4rem; font-weight: normal; font-family: monospace; } + .step { font-size: 0.7rem; color: #777; margin: 0.5rem 0 0.2rem; }

zebra report

-

volume modem chatroom  ·  e2e encrypted  ·  no network  ·  - index

+

volume modem chatroom  ·  e2e encrypted  ·  webrtc carrier  ·  + unturf

@@ -166,9 +169,8 @@ no room key

- derives a 256-bit AES-GCM key via PBKDF2-SHA256 (600k iterations) from the - passphrase + the literal string "zebra-report-v1" as salt. Anyone with the - same phrase joins the same room. + PBKDF2-SHA256 (600k iter) of passphrase + literal salt "zebra-report-v1" + → 256-bit AES-GCM group key.

@@ -185,8 +187,6 @@ -

add one peer at a time. each add derives a 1-to-1 shared key - and lets you exchange messages with that peer.

@@ -196,17 +196,68 @@
- mic stays off · transmit only + + +
- live gain: + tx gain:
0%
+
+ rx energy: +
+ +

- a silent stereo carrier is published to PulseAudio. Each frame modulates the - GainNode between MARK (80%) and SPACE (20%) per bit. A local introspector - (see “receive” below) polls PA sink-input state and decodes. + a 440/441 Hz stereo carrier is generated locally. each frame swings the + output gain MARK (0.80) ↔ SPACE (0.20) per bit. the modulated audio is the + outbound WebRTC track for every connected peer. mic stays off. +

+ + + +
+

peer connection (WebRTC)

+
+
+ disconnected — start audio first, then pick a role below +
+ +
+

role A · start a new connection

+

step 1: click, then copy the offer to your peer (any out-of-band channel):

+
+ + +
+ +

step 4: paste your peer's answer here:

+ +
+ + +
+
+ +
+

role B · join an existing connection

+

step 2: paste your peer's offer here:

+ +
+ + +
+

step 3: copy your answer to peer:

+ +
+ +

+ each browser modulates its own outbound audio track (no mic). incoming peer + audio is decoded in-page via AudioWorklet, no native daemon + required. Wireshark sees only encrypted SRTP — chat content lives in + audio amplitude transitions inside that stream.

@@ -218,7 +269,7 @@ not yet measured
- + idle
@@ -226,26 +277,9 @@

- OFFER and READY frames travel at fixed ZEBRA_BAUD_HANDSHAKE=50. - Each peer measures its own scheduling jitter, broadcasts its max baud, and - the room settles on min(all peers). -

- - - -
-

receive (introspector bridge)

-
-
- no introspector — running TX-only - -
-

- expects zebrad at ws://127.0.0.1:7777. The bridge - polls PulseAudio sink-input volumes via PA IPC and pushes decoded frames here. - Without it the page transmits but cannot receive. The - recovered rx.c handles - decoding; zebrad is the WebSocket wrapper around it. + OFFER and READY frames travel at fixed ZEBRA_BAUD_HANDSHAKE = 50. + each peer measures its own scheduling jitter, broadcasts a max baud, room + settles on min(all).

@@ -274,34 +308,33 @@

about

- This page is the “production” zebra-report protocol: - a covert peer-to-peer chatroom whose data path is PulseAudio sink-input - state. Chat content never enters an IP packet — Wireshark sees only the - websocket between this browser tab and the local zebrad bridge, - and even that traffic is post-encryption ciphertext + frame metadata. + The protocol's data path is audio amplitude inside an encrypted WebRTC + SRTP stream. The signaling channel (SDP / ICE) is exchanged once at + connection setup — it contains no chat content. Every subsequent + bit travels as a MARK/SPACE swing in the carrier audio.

threat model · what this protects against · what it doesn't

- Protects against: a network-level observer (Wireshark, IDS, - DPI middlebox, ISP) seeing chat content. None of it crosses the network. + Protects against: a network-level observer (Wireshark, + IDS, DPI) reading chat content. The bits are inside the SRTP audio + payload, which is end-to-end encrypted by WebRTC, then additionally + encrypted at the app layer (AES-GCM via passphrase or ECDH).

Does not protect against: - (1) other same-UID processes that can poll PulseAudio — this is the - entire attack surface zebra-report demonstrates; + (1) other same-UID processes that can poll PulseAudio or record the + monitor source on either peer's machine — that is the + attack surface this whole project documents; (2) an endpoint compromise of either peer's machine; - (3) traffic analysis against the encrypted ciphertext over many sessions; - (4) anyone able to run a microphone in earshot of the speakers (n/a here — - this protocol's bytes don't ride on speaker audio energy). + (3) traffic-analysis correlation against the encrypted SRTP flow.

- Mitigation for defenders: require PulseAudio clients to - hold a capability before reading peer sink-input volumes. The current - design grants any same-UID process unrestricted read access — that's - the published defect this whole project documents. See - - foxhop.net/linux-audio-ipc-attack-surface for the full whitepaper. + Mitigation for defenders: require a capability for + same-UID PulseAudio reads. The current Linux audio IPC trust model + grants every same-UID process unrestricted access — the defect + whitepaper documents this.

@@ -315,29 +348,29 @@ /* ============================================================== * * constants (mirror include/zebra.h) * * ============================================================== */ -const ZEBRA_VOL_MARK = 0.80; /* logic-1 / idle / stop bit */ -const ZEBRA_VOL_SPACE = 0.20; /* logic-0 / start bit */ -const ZEBRA_VOL_THRESHOLD = 0.50; /* decision boundary */ +const ZEBRA_VOL_MARK = 0.80; +const ZEBRA_VOL_SPACE = 0.20; const ZEBRA_BAUD_HANDSHAKE = 50; const ZEBRA_BAUD_MIN = 1; const ZEBRA_BAUD_MAX = 100000; const ZEBRA_BAUD_DEFAULT = 10; -const HS_MAGIC = [0x5A, 0x42]; /* 'Z' 'B' */ +const HS_MAGIC = [0x5A, 0x42]; const T_OFFER = 0x01; const T_READY = 0x02; -const T_DATA = 0x03; /* extension: data frame */ -const T_HELLO = 0x04; /* extension: peer announcement */ +const T_DATA = 0x03; +const T_HELLO = 0x04; const HS_FRAME_LEN = 6; -const WS_URL = 'ws://127.0.0.1:7777'; const PBKDF2_SALT = new TextEncoder().encode('zebra-report-v1'); const PBKDF2_ITER = 600000; -/* dev loopback: bypass PulseAudio, ride BroadcastChannel between tabs. - * gated on ?loopback=1. real PA transport stays the production path. */ const LOOPBACK = new URLSearchParams(location.search).has('loopback'); +const RTC_CONFIG = { + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] +}; + /* ============================================================== * * tiny utils * * ============================================================== */ @@ -350,11 +383,10 @@ function b64(buf) { function unb64(s) { return Uint8Array.from(atob(s), c => c.charCodeAt(0)); } function hex(buf) { const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf); - return Array.from(u8).map(b => b.toString(16).padStart(2,'0')).join(''); + return Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join(''); } function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } -/* CRC-32 (IEEE 802.3 polynomial 0xEDB88320, init 0xFFFFFFFF, xorout 0xFFFFFFFF) */ const CRC32_TABLE = (() => { const t = new Uint32Array(256); for (let i = 0; i < 256; i++) { @@ -369,6 +401,10 @@ function crc32(bytes) { for (let i = 0; i < bytes.length; i++) c = CRC32_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; } +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, ch => + ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch])); +} /* ============================================================== * * logging * @@ -384,95 +420,71 @@ function logLine(kind, html) { } /* ============================================================== * - * crypto: passphrase mode (PBKDF2 → AES-GCM) * + * crypto * * ============================================================== */ -let groupKey = null; /* in passphrase mode */ -let myPriv = null; /* in pubkey mode */ -let myPubB64 = null; /* in pubkey mode */ -let peers = new Map(); /* senderId(hex) → { handle, sharedKey, lastSeen, maxBaud } */ +let groupKey = null; /* passphrase mode */ +let myPriv = null; /* pubkey mode */ +let myPubB64 = null; /* pubkey mode */ +let peers = new Map(); /* sid(hex) → { handle, sharedKey, maxBaud } */ async function deriveGroupKey(phrase) { const baseKey = await crypto.subtle.importKey( - 'raw', new TextEncoder().encode(phrase), - 'PBKDF2', false, ['deriveKey'] - ); + 'raw', new TextEncoder().encode(phrase), 'PBKDF2', false, ['deriveKey']); return crypto.subtle.deriveKey( { name: 'PBKDF2', salt: PBKDF2_SALT, iterations: PBKDF2_ITER, hash: 'SHA-256' }, - baseKey, - { name: 'AES-GCM', length: 256 }, - false, ['encrypt', 'decrypt'] - ); + baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); } -/* ============================================================== * - * crypto: pubkey mode (ECDH P-256 → AES-GCM) * - * ============================================================== */ const LS_PRIV = 'zebra_chat_privkey'; const LS_PUB = 'zebra_chat_pubkey'; const LS_HANDLE = 'zebra_chat_handle'; async function genKeypair() { const kp = await crypto.subtle.generateKey( - { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits'] - ); + { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits']); const privJwk = await crypto.subtle.exportKey('jwk', kp.privateKey); const pubRaw = await crypto.subtle.exportKey('raw', kp.publicKey); localStorage.setItem(LS_PRIV, JSON.stringify(privJwk)); localStorage.setItem(LS_PUB, b64(pubRaw)); return { privateKey: kp.privateKey, pubB64: b64(pubRaw) }; } - async function loadOrGenKeypair() { - const privStr = localStorage.getItem(LS_PRIV); - const pubStr = localStorage.getItem(LS_PUB); - if (privStr && pubStr) { + const p = localStorage.getItem(LS_PRIV), pub = localStorage.getItem(LS_PUB); + if (p && pub) { try { const privateKey = await crypto.subtle.importKey( - 'jwk', JSON.parse(privStr), - { name: 'ECDH', namedCurve: 'P-256' }, - true, ['deriveKey', 'deriveBits'] - ); - return { privateKey, pubB64: pubStr }; + 'jwk', JSON.parse(p), { name: 'ECDH', namedCurve: 'P-256' }, + true, ['deriveKey', 'deriveBits']); + return { privateKey, pubB64: pub }; } catch (_) {} } return genKeypair(); } - async function deriveShared(myPrivKey, peerPubB64) { const peerPub = await crypto.subtle.importKey( - 'raw', unb64(peerPubB64), - { name: 'ECDH', namedCurve: 'P-256' }, - false, [] - ); + 'raw', unb64(peerPubB64), { name: 'ECDH', namedCurve: 'P-256' }, false, []); return crypto.subtle.deriveKey( - { name: 'ECDH', public: peerPub }, - myPrivKey, - { name: 'AES-GCM', length: 256 }, - false, ['encrypt', 'decrypt'] - ); + { name: 'ECDH', public: peerPub }, myPrivKey, + { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); } - -async function aesEncrypt(key, plaintextStr) { +async function aesEncrypt(key, str) { const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = new Uint8Array(await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, key, new TextEncoder().encode(plaintextStr) - )); + { name: 'AES-GCM', iv }, key, new TextEncoder().encode(str))); const out = new Uint8Array(12 + ct.length); out.set(iv); out.set(ct, 12); return out; } async function aesDecrypt(key, bytes) { - const iv = bytes.slice(0, 12); - const ct = bytes.slice(12); + const iv = bytes.slice(0, 12), ct = bytes.slice(12); const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct); return new TextDecoder().decode(pt); } /* ============================================================== * - * sender id: 4-byte digest of identity material * + * sender id * * ============================================================== */ async function senderIdBytes() { - /* in passphrase mode: random-per-tab; in pubkey mode: first 4 of SHA-256(pub) */ if (currentMode() === 'pubkey' && myPubB64) { const h = await crypto.subtle.digest('SHA-256', unb64(myPubB64)); return new Uint8Array(h).slice(0, 4); @@ -489,21 +501,17 @@ async function senderIdBytes() { function xorChecksum(bytes) { let x = 0; for (const b of bytes) x ^= b; return x & 0xFF; } - function buildHandshakeFrame(type, baudVal) { - /* magic(2) + type(1) + baud_le(2) + xor(1) = 6 bytes */ const b = new Uint8Array(HS_FRAME_LEN); b[0] = HS_MAGIC[0]; b[1] = HS_MAGIC[1]; b[2] = type; b[3] = baudVal & 0xFF; b[4] = (baudVal >> 8) & 0xFF; b[5] = xorChecksum(b.slice(0, 5)); return b; } - async function buildDataFrame(payloadBytes) { - /* magic(2) + T_DATA + sender(4) + len_le(2) + payload(n) + crc32_le(4) */ const sid = await senderIdBytes(); const len = payloadBytes.length; - const head = new Uint8Array(2 + 1 + 4 + 2); + const head = new Uint8Array(9); head[0] = HS_MAGIC[0]; head[1] = HS_MAGIC[1]; head[2] = T_DATA; head.set(sid, 3); head[7] = len & 0xFF; head[8] = (len >> 8) & 0xFF; @@ -512,18 +520,14 @@ async function buildDataFrame(payloadBytes) { const crc = crc32(body); const out = new Uint8Array(body.length + 4); out.set(body); - out[body.length+0] = crc & 0xFF; - out[body.length+1] = (crc >> 8) & 0xFF; - out[body.length+2] = (crc >> 16) & 0xFF; - out[body.length+3] = (crc >> 24) & 0xFF; + out[body.length+0] = crc & 0xFF; out[body.length+1] = (crc >> 8) & 0xFF; + out[body.length+2] = (crc >> 16) & 0xFF; out[body.length+3] = (crc >> 24) & 0xFF; return out; } - async function buildHelloFrame(handle, maxBaud) { - /* magic(2) + T_HELLO + sender(4) + maxBaud_le(2) + handleLen(1) + handle(n) + crc(4) */ const sid = await senderIdBytes(); const hb = new TextEncoder().encode(handle.slice(0, 40)); - const body = new Uint8Array(3 + 4 + 2 + 1 + hb.length); + const body = new Uint8Array(10 + hb.length); body[0] = HS_MAGIC[0]; body[1] = HS_MAGIC[1]; body[2] = T_HELLO; body.set(sid, 3); body[7] = maxBaud & 0xFF; body[8] = (maxBaud >> 8) & 0xFF; @@ -532,13 +536,10 @@ async function buildHelloFrame(handle, maxBaud) { const crc = crc32(body); const out = new Uint8Array(body.length + 4); out.set(body); - out[body.length+0] = crc & 0xFF; - out[body.length+1] = (crc >> 8) & 0xFF; - out[body.length+2] = (crc >> 16) & 0xFF; - out[body.length+3] = (crc >> 24) & 0xFF; + out[body.length+0] = crc & 0xFF; out[body.length+1] = (crc >> 8) & 0xFF; + out[body.length+2] = (crc >> 16) & 0xFF; out[body.length+3] = (crc >> 24) & 0xFF; return out; } - function parseFrame(bytes) { if (bytes.length < 3) return null; if (bytes[0] !== HS_MAGIC[0] || bytes[1] !== HS_MAGIC[1]) return null; @@ -556,9 +557,8 @@ function parseFrame(bytes) { if (bytes.length < 9 + len + 4) return null; const payload = bytes.slice(9, 9 + len); const frameNoCrc = bytes.slice(0, 9 + len); - const got = bytes[9+len] | (bytes[9+len+1] << 8) | (bytes[9+len+2] << 16) | (bytes[9+len+3] << 24); - const calc = crc32(frameNoCrc); - if ((got >>> 0) !== (calc >>> 0)) return { type, error: 'crc' }; + const got = bytes[9+len] | (bytes[9+len+1] << 8) | (bytes[9+len+2] << 16) | (bytes[9+len+3] << 24); + if ((got >>> 0) !== crc32(frameNoCrc)) return { type, error: 'crc' }; return { type, sid, payload }; } if (type === T_HELLO) { @@ -569,49 +569,26 @@ function parseFrame(bytes) { if (bytes.length < 10 + hlen + 4) return null; const handle = new TextDecoder().decode(bytes.slice(10, 10 + hlen)); const frameNoCrc = bytes.slice(0, 10 + hlen); - const got = bytes[10+hlen] | (bytes[10+hlen+1] << 8) | (bytes[10+hlen+2] << 16) | (bytes[10+hlen+3] << 24); - const calc = crc32(frameNoCrc); - if ((got >>> 0) !== (calc >>> 0)) return { type, error: 'crc' }; + const got = bytes[10+hlen] | (bytes[10+hlen+1] << 8) | (bytes[10+hlen+2] << 16) | (bytes[10+hlen+3] << 24); + if ((got >>> 0) !== crc32(frameNoCrc)) return { type, error: 'crc' }; return { type, sid, maxBaud, handle }; } return null; } /* ============================================================== * - * audio carrier + gain modulator * + * audio carrier + outbound RTC stream * * ============================================================== */ -let audioCtx = null; +let audioCtx = null; +let oscL = null, oscR = null; let gainL = null, gainR = null; +let merger = null; +let monitorGain = null; /* local audible (off by default) */ +let txStreamDest = null; /* outbound media track for RTC */ +let outboundStream = null; let carrierOn = false; let txBusy = false; -let txQueue = []; /* { bytes, baud } */ - -function _scheduleGain(value, atTime) { - gainL.gain.setValueAtTime(value, atTime); - gainR.gain.setValueAtTime(value, atTime); -} - -async function startCarrier() { - if (carrierOn) return; - audioCtx = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'playback' }); - if (audioCtx.state === 'suspended') await audioCtx.resume(); - const oscL = audioCtx.createOscillator(); - const oscR = audioCtx.createOscillator(); - oscL.type = 'sine'; oscR.type = 'sine'; - oscL.frequency.value = 440; oscR.frequency.value = 441; - gainL = audioCtx.createGain(); - gainR = audioCtx.createGain(); - gainL.gain.value = ZEBRA_VOL_MARK; - gainR.gain.value = ZEBRA_VOL_MARK; - const merger = audioCtx.createChannelMerger(2); - oscL.connect(gainL); gainL.connect(merger, 0, 0); - oscR.connect(gainR); gainR.connect(merger, 0, 1); - merger.connect(audioCtx.destination); - oscL.start(); oscR.start(); - carrierOn = true; - /* live gain meter */ - _meterTick(); -} +let txQueue = []; const meterFill = $('meter-tx'); const gainLabel = $('gain-label'); @@ -623,8 +600,210 @@ function _meterTick() { requestAnimationFrame(_meterTick); } -/* transmit a byte array via volume modulation. - * frame layout per bit: SPACE(start) + 8 data bits LSB first + MARK(stop). */ +function _scheduleGain(v, t) { + gainL.gain.setValueAtTime(v, t); + gainR.gain.setValueAtTime(v, t); +} + +async function startCarrier() { + if (carrierOn) return; + audioCtx = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'interactive' }); + if (audioCtx.state === 'suspended') await audioCtx.resume(); + oscL = audioCtx.createOscillator(); oscR = audioCtx.createOscillator(); + oscL.type = 'sine'; oscL.frequency.value = 440; + oscR.type = 'sine'; oscR.frequency.value = 441; + gainL = audioCtx.createGain(); gainR = audioCtx.createGain(); + gainL.gain.value = ZEBRA_VOL_MARK; + gainR.gain.value = ZEBRA_VOL_MARK; + merger = audioCtx.createChannelMerger(2); + oscL.connect(gainL); gainL.connect(merger, 0, 0); + oscR.connect(gainR); gainR.connect(merger, 0, 1); + + /* outbound: route to RTC media stream destination */ + txStreamDest = audioCtx.createMediaStreamDestination(); + merger.connect(txStreamDest); + outboundStream = txStreamDest.stream; + + /* local monitor (muted by default) */ + monitorGain = audioCtx.createGain(); + monitorGain.gain.value = 0; + merger.connect(monitorGain); + monitorGain.connect(audioCtx.destination); + + oscL.start(); oscR.start(); + carrierOn = true; + _meterTick(); + await setupWorkletDecoder(); +} + +$('monitor-toggle').addEventListener('change', (e) => { + if (monitorGain) monitorGain.gain.value = e.target.checked ? 0.04 : 0; +}); + +/* ============================================================== * + * AudioWorklet: per-quantum peak detector * + * ============================================================== */ +const WORKLET_CODE = ` +class EnergyDetector extends AudioWorkletProcessor { + process(inputs) { + const input = inputs[0]; + if (!input || !input.length || !input[0]) return true; + const ch = input[0]; + let peak = 0; + for (let i = 0; i < ch.length; i++) { + const a = Math.abs(ch[i]); + if (a > peak) peak = a; + } + this.port.postMessage(peak); + return true; + } +} +registerProcessor('energy-detector', EnergyDetector); +`; + +async function setupWorkletDecoder() { + const url = URL.createObjectURL(new Blob([WORKLET_CODE], { type: 'application/javascript' })); + try { + await audioCtx.audioWorklet.addModule(url); + } catch (e) { + logLine('err', 'audio worklet add failed: ' + e.message); + return; + } +} + +/* one decoder per inbound RTC track (peer) */ +const rxDecoders = new Map(); /* trackId → { uart, asm, node } */ + +class UartDecoder { + constructor(baud, sampleRate) { + this.sampleRate = sampleRate; + this.setBaud(baud); + this.state = 'hunt'; + this.byte = 0; this.bitIdx = 0; this.curSample = 0; + this.prevAbove = true; + this.peak = 0; + this.peakDecay = Math.pow(0.5, 1.0 / (2.0 * sampleRate)); /* 2s half-life */ + } + setBaud(b) { this.baud = b; this.sps = this.sampleRate / b; } + push(e) { + this.peak *= this.peakDecay; + if (e > this.peak) this.peak = e; + if (this.peak < 0.02) { this.state = 'hunt'; this.prevAbove = true; return null; } + const thr = this.peak * 0.50; + const above = e >= thr; + if (this.state === 'hunt') { + if (this.prevAbove && !above) { + this.state = 'sample'; this.curSample = 1; this.byte = 0; this.bitIdx = 0; + } + } else { + this.curSample++; + const center = (1.5 + this.bitIdx) * this.sps; + if (this.curSample >= center) { + const bit = above ? 1 : 0; + this.byte |= (bit << this.bitIdx); + this.bitIdx++; + if (this.bitIdx === 8) { + const out = this.byte; + this.state = 'hunt'; this.prevAbove = true; + return out; + } + } + } + this.prevAbove = above; + return null; + } +} + +class FrameAssembler { + constructor() { this.buf = []; } + push(byte) { + this.buf.push(byte); + while (this.buf.length >= 2 && (this.buf[0] !== HS_MAGIC[0] || this.buf[1] !== HS_MAGIC[1])) { + this.buf.shift(); + } + if (this.buf.length < 3) return null; + const t = this.buf[2]; + if (t === T_OFFER || t === T_READY) { + if (this.buf.length < 6) return null; + const frame = new Uint8Array(this.buf.slice(0, 6)); + this.buf.splice(0, 6); + return frame; + } + if (t === T_DATA) { + if (this.buf.length < 9) return null; + const ln = this.buf[7] | (this.buf[8] << 8); + if (ln > 512) { this.buf.splice(0, 2); return null; } + const need = 9 + ln + 4; + if (this.buf.length < need) return null; + const frame = new Uint8Array(this.buf.slice(0, need)); + this.buf.splice(0, need); + return frame; + } + if (t === T_HELLO) { + if (this.buf.length < 10) return null; + const hlen = this.buf[9]; + if (hlen > 64) { this.buf.splice(0, 2); return null; } + const need = 10 + hlen + 4; + if (this.buf.length < need) return null; + const frame = new Uint8Array(this.buf.slice(0, need)); + this.buf.splice(0, need); + return frame; + } + this.buf.splice(0, 2); + return null; + } +} + +const rxMeterFill = $('meter-rx'); +const rxLabel = $('rx-label'); +let lastRxPeak = 0; +function _rxMeterTick() { + rxMeterFill.style.width = Math.round(lastRxPeak * 100) + '%'; + rxLabel.textContent = lastRxPeak > 0.001 + ? Math.round(lastRxPeak * 100) + '%' : '—'; + requestAnimationFrame(_rxMeterTick); +} +requestAnimationFrame(_rxMeterTick); + +async function attachInboundTrack(stream, trackId) { + if (!audioCtx) return; + /* must attach to a (muted) audio element for the WebRTC stack to deliver */ + const a = new Audio(); + a.srcObject = stream; + a.muted = true; + a.autoplay = true; + try { await a.play(); } catch (_) {} + + const src = audioCtx.createMediaStreamSource(stream); + const node = new AudioWorkletNode(audioCtx, 'energy-detector'); + src.connect(node); + /* effective decoder rate: sampleRate / 128 (one peak per audio quantum) */ + const effRate = audioCtx.sampleRate / 128; + const uart = new UartDecoder(ZEBRA_BAUD_HANDSHAKE, effRate); + const asm = new FrameAssembler(); + node.port.onmessage = (ev) => { + const peak = ev.data; + lastRxPeak = peak; + const byte = uart.push(peak); + if (byte === null) return; + const frame = asm.push(byte); + if (frame) onRxFrame(frame); + }; + rxDecoders.set(trackId, { uart, asm, node, audio: a }); + logLine('sys', 'inbound track attached — decoder live'); +} + +function detachInboundTrack(trackId) { + const d = rxDecoders.get(trackId); + if (!d) return; + try { d.node.disconnect(); } catch (_) {} + try { d.audio.pause(); d.audio.srcObject = null; } catch (_) {} + rxDecoders.delete(trackId); +} + +/* ============================================================== * + * transmit * + * ============================================================== */ async function txFrame(bytes, baud) { if (!carrierOn) throw new Error('carrier not started'); txQueue.push({ bytes, baud }); @@ -636,9 +815,7 @@ async function txFrame(bytes, baud) { await _txOne(job.bytes, job.baud); if (loopChan) { try { - const ab = job.bytes.buffer.slice( - job.bytes.byteOffset, - job.bytes.byteOffset + job.bytes.byteLength); + const ab = job.bytes.buffer.slice(job.bytes.byteOffset, job.bytes.byteOffset + job.bytes.byteLength); loopChan.postMessage(ab); } catch (_) {} } @@ -650,49 +827,31 @@ async function txFrame(bytes, baud) { async function _txOne(bytes, baud) { const period = 1.0 / baud; - let t = audioCtx.currentTime + 0.02; /* small lead-in */ - /* MARK preamble — 10 symbols at this baud */ - for (let i = 0; i < 10; i++) { + let t = audioCtx.currentTime + 0.02; + for (let i = 0; i < 10; i++) { _scheduleGain(ZEBRA_VOL_MARK, t); t += period; } + for (const byte of bytes) { + _scheduleGain(ZEBRA_VOL_SPACE, t); t += period; + for (let bit = 0; bit < 8; bit++) { + _scheduleGain(((byte >> bit) & 1) ? ZEBRA_VOL_MARK : ZEBRA_VOL_SPACE, t); + t += period; + } _scheduleGain(ZEBRA_VOL_MARK, t); t += period; } - for (const byte of bytes) { - _scheduleGain(ZEBRA_VOL_SPACE, t); t += period; /* start bit */ - for (let bit = 0; bit < 8; bit++) { - const v = ((byte >> bit) & 1) ? ZEBRA_VOL_MARK : ZEBRA_VOL_SPACE; - _scheduleGain(v, t); t += period; - } - _scheduleGain(ZEBRA_VOL_MARK, t); t += period; /* stop bit */ - } - /* trailer */ for (let i = 0; i < 5; i++) { _scheduleGain(ZEBRA_VOL_MARK, t); t += period; } - /* return to idle */ _scheduleGain(ZEBRA_VOL_MARK, t); - /* sleep until scheduled time has elapsed (best-effort) */ - const waitMs = Math.max(0, (t - audioCtx.currentTime) * 1000) + 30; - await sleep(waitMs); + await sleep(Math.max(0, (t - audioCtx.currentTime) * 1000) + 30); } -/* ============================================================== * - * benchmark: how fast can WE schedule gain changes? * - * ============================================================== */ async function benchmarkSelf() { if (!carrierOn) await startCarrier(); const N = 200; const t0 = performance.now(); let t = audioCtx.currentTime; - for (let i = 0; i < N; i++) { - _scheduleGain(i & 1 ? ZEBRA_VOL_MARK : ZEBRA_VOL_SPACE, t); - t += 0.0005; - } - /* let it drain */ + for (let i = 0; i < N; i++) { _scheduleGain(i & 1 ? ZEBRA_VOL_MARK : ZEBRA_VOL_SPACE, t); t += 0.0005; } await sleep(Math.max(0, (t - audioCtx.currentTime) * 1000)); const t1 = performance.now(); - /* settle back to MARK */ _scheduleGain(ZEBRA_VOL_MARK, audioCtx.currentTime + 0.01); - const elapsedMs = t1 - t0; - const avgUs = (elapsedMs * 1000) / N; - /* practical baud: cap at 1 / (avg_seconds_per_symbol). - * keep generous safety margin (2x) for jitter. */ + const avgUs = (t1 - t0) * 1000 / N; const rawBaud = 1e6 / avgUs; let baud = Math.floor(rawBaud / 2); baud = Math.max(ZEBRA_BAUD_MIN, Math.min(ZEBRA_BAUD_MAX, baud)); @@ -704,154 +863,87 @@ async function benchmarkSelf() { * ============================================================== */ let myMaxBaud = null; let negotiatedBaud = null; -const hsState = $('hs-status'); const baudLabel = $('baud-label'); - function recomputeGroupBaud() { let m = myMaxBaud || ZEBRA_BAUD_DEFAULT; - for (const p of peers.values()) { - if (p.maxBaud && p.maxBaud < m) m = p.maxBaud; - } + for (const p of peers.values()) if (p.maxBaud && p.maxBaud < m) m = p.maxBaud; negotiatedBaud = m; baudLabel.textContent = m + ' baud'; } /* ============================================================== * - * introspector bridge (RX path) + dev loopback * + * loopback (same-browser dev shortcut, ?loopback=1) * * ============================================================== */ -let ws = null; -let wsReconnectTimer = null; let loopChan = null; if (LOOPBACK) { loopChan = new BroadcastChannel('zebra-report-loopback'); loopChan.onmessage = (ev) => { - const buf = ev.data instanceof ArrayBuffer - ? new Uint8Array(ev.data) : new Uint8Array(ev.data); - onRxFrame(buf); - }; -} -const dotRx = $('dot-rx'); -const rxStatus = $('rx-status'); - -function rxConnect(manual) { - if (ws) try { ws.close(); } catch (_) {} - try { - ws = new WebSocket(WS_URL); - } catch (e) { - rxStatus.textContent = 'cannot construct websocket: ' + e.message; - return; - } - ws.binaryType = 'arraybuffer'; - rxStatus.textContent = 'connecting…'; - dotRx.className = 'dot warn'; - ws.onopen = () => { - rxStatus.textContent = 'connected to zebrad — RX live'; - dotRx.className = 'dot on'; - }; - ws.onclose = (ev) => { - rxStatus.textContent = 'disconnected (' + ev.code + ') — ' + - (manual ? 'press connect to retry' : 'retrying…'); - dotRx.className = 'dot'; - if (!manual) wsReconnectTimer = setTimeout(() => rxConnect(false), 5000); - }; - ws.onerror = () => { - rxStatus.textContent = 'no introspector at ' + WS_URL + ' — running TX-only'; - }; - ws.onmessage = (ev) => { - /* zebrad pushes decoded frames as raw binary bytes */ - const buf = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : null; - if (!buf) return; + const buf = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : new Uint8Array(ev.data); onRxFrame(buf); }; } +/* ============================================================== * + * frame handler * + * ============================================================== */ async function onRxFrame(bytes) { const f = parseFrame(bytes); if (!f) return; - if (f.error) { - logLine('err', `rx: malformed frame (type=${f.type}, ${f.error})`); - return; - } - /* same-machine PA mix echoes our own carrier back to us. drop any frame - * whose sender id matches our own so we don't double-render. loopback - * mode is unaffected because BroadcastChannel never echoes to sender. */ + if (f.error) { logLine('err', `rx: malformed (${f.type}, ${f.error})`); return; } + /* drop self-echoes (own sid) */ if (f.sid) { const mine = await senderIdBytes(); - let isSelf = true; - for (let i = 0; i < 4; i++) if (f.sid[i] !== mine[i]) { isSelf = false; break; } - if (isSelf) return; + let self = true; + for (let i = 0; i < 4; i++) if (f.sid[i] !== mine[i]) { self = false; break; } + if (self) return; } if (f.type === T_OFFER) { logLine('sys', `rx: OFFER from peer — ${f.baud} baud`); - /* respond with READY at handshake baud */ await txFrame(buildHandshakeFrame(T_READY, myMaxBaud || ZEBRA_BAUD_DEFAULT), ZEBRA_BAUD_HANDSHAKE); - /* peer baud noted; will recompute on HELLO */ return; } if (f.type === T_READY) { logLine('sys', `rx: READY from peer — ${f.baud} baud`); - /* peer told us their max baud; treat it as their hello-baud */ - /* defer further until HELLO arrives carrying handle + sid */ return; } if (f.type === T_HELLO) { const sid = hex(f.sid); - const existed = peers.has(sid); + const seen = peers.has(sid); let entry = peers.get(sid) || {}; - entry.handle = f.handle; - entry.maxBaud = f.maxBaud; - entry.lastSeen = Date.now(); + entry.handle = f.handle; entry.maxBaud = f.maxBaud; peers.set(sid, entry); - recomputeGroupBaud(); - renderPeerList(); - logLine('sys', existed ? - `peer ${escapeHtml(f.handle)} updated (baud ${f.maxBaud})` : - `peer ${escapeHtml(f.handle)} joined (baud ${f.maxBaud}, sid ${sid.slice(0,8)})`); - /* on first contact, send our own HELLO too so they learn us */ - if (!existed) await announceHello(); + recomputeGroupBaud(); renderPeerList(); + logLine('sys', seen + ? `peer ${escapeHtml(f.handle)} updated (baud ${f.maxBaud})` + : `peer ${escapeHtml(f.handle)} joined (baud ${f.maxBaud}, sid ${sid.slice(0,8)})`); + if (!seen) await announceHello(); return; } if (f.type === T_DATA) { const sid = hex(f.sid); let pt = null; if (currentMode() === 'passphrase') { - if (!groupKey) { logLine('err', 'rx: data frame but no room key'); return; } - try { pt = await aesDecrypt(groupKey, f.payload); } - catch (e) { logLine('err', `rx: decrypt failed (${e.message})`); return; } + if (!groupKey) { logLine('err', 'rx: data but no room key'); return; } + try { pt = await aesDecrypt(groupKey, f.payload); } catch (e) { logLine('err', `rx: decrypt failed (${e.message})`); return; } } else { - /* pubkey mode: try each peer's shared key */ - let entry = peers.get(sid); + const entry = peers.get(sid); if (entry && entry.sharedKey) { - try { pt = await aesDecrypt(entry.sharedKey, f.payload); } - catch (_) { pt = null; } - } - if (pt === null) { - logLine('err', `rx: data from sid=${sid.slice(0,8)} (no key for this peer)`); - return; + try { pt = await aesDecrypt(entry.sharedKey, f.payload); } catch (_) {} } + if (pt === null) { logLine('err', `rx: data from ${sid.slice(0,8)} (no key)`); return; } } const handle = (peers.get(sid) || {}).handle || sid.slice(0,8); logLine('peer', `${escapeHtml(handle)}: ${escapeHtml(pt)}`); - return; } } -function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, ch => ({ - '&':'&','<':'<','>':'>','"':'"',"'":''' - }[ch])); -} - /* ============================================================== * * peer list render * * ============================================================== */ function renderPeerList() { const el = $('peer-list'); el.innerHTML = ''; - if (peers.size === 0) { - el.innerHTML = '
none yet
'; - return; - } + if (peers.size === 0) { el.innerHTML = '
none yet
'; return; } for (const [sid, p] of peers) { const row = document.createElement('div'); row.className = 'peer'; @@ -861,17 +953,108 @@ function renderPeerList() { } } -/* ============================================================== * - * announce: HELLO frame at handshake baud * - * ============================================================== */ async function announceHello() { const handle = $('handle-in').value.trim() || 'anon'; if (!myMaxBaud) myMaxBaud = ZEBRA_BAUD_DEFAULT; const hello = await buildHelloFrame(handle, myMaxBaud); await txFrame(hello, ZEBRA_BAUD_HANDSHAKE); - logLine('sys', `tx: HELLO as ${escapeHtml(handle)} (max ${myMaxBaud} baud)`); + logLine('sys', `tx: HELLO as ${escapeHtml(handle)} (${myMaxBaud} baud)`); } +/* ============================================================== * + * WebRTC * + * ============================================================== */ +let pc = null; +const dotRtc = $('dot-rtc'); +const rtcStatus = $('rtc-status'); + +function ensurePC() { + if (pc) return pc; + if (!carrierOn) throw new Error('start carrier first'); + pc = new RTCPeerConnection(RTC_CONFIG); + for (const tr of outboundStream.getTracks()) pc.addTrack(tr, outboundStream); + pc.ontrack = (ev) => { + const stream = ev.streams[0] || new MediaStream([ev.track]); + attachInboundTrack(stream, ev.track.id); + }; + pc.onconnectionstatechange = () => { + const s = pc.connectionState; + rtcStatus.textContent = 'connection: ' + s; + if (s === 'connected') { + dotRtc.className = 'dot ok'; + logLine('sys', 'webrtc connected'); + announceHello().catch(() => {}); + } else if (s === 'failed' || s === 'closed' || s === 'disconnected') { + dotRtc.className = 'dot'; + } + }; + pc.oniceconnectionstatechange = () => { + rtcStatus.textContent = 'ice: ' + pc.iceConnectionState; + }; + return pc; +} + +function waitForIceGathering(p) { + return new Promise(resolve => { + if (p.iceGatheringState === 'complete') return resolve(); + const h = () => { + if (p.iceGatheringState === 'complete') { + p.removeEventListener('icegatheringstatechange', h); + resolve(); + } + }; + p.addEventListener('icegatheringstatechange', h); + }); +} + +$('btn-create-offer').addEventListener('click', async () => { + $('offer-status').textContent = 'gathering ICE…'; + try { + ensurePC(); + const off = await pc.createOffer(); + await pc.setLocalDescription(off); + await waitForIceGathering(pc); + $('local-offer').value = JSON.stringify(pc.localDescription); + $('offer-status').textContent = 'offer ready — copy & send to peer'; + $('offer-status').className = 'status-line ok'; + } catch (e) { + $('offer-status').textContent = 'failed: ' + e.message; + $('offer-status').className = 'status-line err'; + } +}); + +$('btn-create-answer').addEventListener('click', async () => { + $('ans-status').textContent = 'parsing offer…'; + try { + const off = JSON.parse($('remote-offer').value); + ensurePC(); + await pc.setRemoteDescription(off); + const ans = await pc.createAnswer(); + await pc.setLocalDescription(ans); + await waitForIceGathering(pc); + $('local-answer').value = JSON.stringify(pc.localDescription); + $('ans-status').textContent = 'answer ready — copy & send to peer'; + $('ans-status').className = 'status-line ok'; + } catch (e) { + $('ans-status').textContent = 'failed: ' + e.message; + $('ans-status').className = 'status-line err'; + } +}); + +$('btn-accept-answer').addEventListener('click', async () => { + $('answer-status').textContent = 'parsing answer…'; + try { + const ans = JSON.parse($('remote-answer').value); + if (!pc) throw new Error('no pending offer'); + await pc.setRemoteDescription(ans); + $('answer-status').textContent = 'answer accepted — waiting for ICE/connection'; + $('answer-status').className = 'status-line ok'; + } catch (e) { + $('answer-status').textContent = 'failed: ' + e.message; + $('answer-status').className = 'status-line err'; + } +}); + /* ============================================================== * * mode toggle * * ============================================================== */ @@ -885,14 +1068,11 @@ function showModePanel() { $('sec-pubkey').classList.toggle('active', m === 'pubkey'); } for (const r of document.querySelectorAll('input[name="mode"]')) { - r.addEventListener('change', () => { - showModePanel(); - updateSendButton(); - }); + r.addEventListener('change', () => { showModePanel(); updateSendButton(); }); } /* ============================================================== * - * UI: identity * + * identity / passphrase / pubkey UI * * ============================================================== */ const handleIn = $('handle-in'); handleIn.value = localStorage.getItem(LS_HANDLE) || ''; @@ -900,9 +1080,6 @@ handleIn.addEventListener('change', () => { localStorage.setItem(LS_HANDLE, handleIn.value.trim()); }); -/* ============================================================== * - * UI: passphrase mode * - * ============================================================== */ $('btn-pp-join').addEventListener('click', async () => { const p = $('passphrase-in').value; if (!p) { $('pp-status').textContent = 'enter a passphrase first'; return; } @@ -911,19 +1088,15 @@ $('btn-pp-join').addEventListener('click', async () => { groupKey = await deriveGroupKey(p); $('pp-status').textContent = 'room key ready'; $('pp-status').className = 'status-line ok'; - logLine('sys', 'room key derived from passphrase'); + logLine('sys', 'room key derived'); updateSendButton(); - /* announce ourselves */ - if (carrierOn) await announceHello(); + if (carrierOn && pc && pc.connectionState === 'connected') await announceHello(); } catch (e) { $('pp-status').textContent = 'derive failed: ' + e.message; $('pp-status').className = 'status-line err'; } }); -/* ============================================================== * - * UI: pubkey mode * - * ============================================================== */ async function initPubkey() { const kp = await loadOrGenKeypair(); myPriv = kp.privateKey; myPubB64 = kp.pubB64; @@ -937,18 +1110,15 @@ $('pub-display').addEventListener('click', () => { setTimeout(() => { $('key-status').textContent = ''; }, 1500); }); }); - $('btn-regen').addEventListener('click', async () => { - if (!confirm('regenerate keys? existing pubkey-mode peers will need to be re-added.')) return; + if (!confirm('regenerate keys?')) return; const kp = await genKeypair(); myPriv = kp.privateKey; myPubB64 = kp.pubB64; $('pub-display').textContent = myPubB64; - /* drop pubkey-mode peer shared keys; passphrase mode unaffected */ for (const p of peers.values()) delete p.sharedKey; $('key-status').textContent = 'new keys'; $('key-status').className = 'status-line ok'; setTimeout(() => { $('key-status').textContent = ''; }, 2000); }); - $('btn-pk-add').addEventListener('click', async () => { const peerB64 = $('peer-key-in').value.trim(); if (!peerB64) return; @@ -964,14 +1134,13 @@ $('btn-pk-add').addEventListener('click', async () => { $('peer-key-in').value = ''; logLine('sys', `pubkey peer added: ${sid.slice(0,8)}`); updateSendButton(); - if (carrierOn) await announceHello(); } catch (e) { logLine('err', 'add peer failed: ' + e.message); } }); /* ============================================================== * - * UI: carrier * + * carrier UI * * ============================================================== */ $('btn-audio').addEventListener('click', async () => { if (carrierOn) return; @@ -980,14 +1149,14 @@ $('btn-audio').addEventListener('click', async () => { $('dot-audio').className = 'dot on'; $('audio-status').textContent = 'online'; $('btn-audio').disabled = true; - logLine('sys', 'carrier started — silent stereo at 440/441 Hz, base gain MARK'); + logLine('sys', 'carrier started — modulated stereo at 440/441 Hz'); } catch (e) { logLine('err', 'carrier failed: ' + e.message); } }); /* ============================================================== * - * UI: handshake * + * handshake UI * * ============================================================== */ $('btn-bench').addEventListener('click', async () => { $('bench-status').textContent = 'measuring…'; @@ -1000,7 +1169,7 @@ $('btn-bench').addEventListener('click', async () => { recomputeGroupBaud(); logLine('sys', `benchmark: ${r.baud} baud max`); } catch (e) { - $('bench-status').textContent = 'benchmark failed: ' + e.message; + $('bench-status').textContent = 'failed: ' + e.message; } }); @@ -1010,7 +1179,6 @@ $('btn-offer').addEventListener('click', async () => { try { await txFrame(buildHandshakeFrame(T_OFFER, myMaxBaud), ZEBRA_BAUD_HANDSHAKE); $('hs-status').textContent = 'OFFER sent — waiting for READY'; - /* also announce ourselves */ await announceHello(); } catch (e) { $('hs-status').textContent = 'tx failed: ' + e.message; @@ -1018,20 +1186,12 @@ $('btn-offer').addEventListener('click', async () => { }); /* ============================================================== * - * UI: rx connect * - * ============================================================== */ -$('btn-rx-connect').addEventListener('click', () => { - if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; } - rxConnect(true); -}); - -/* ============================================================== * - * UI: chat send * + * send UI * * ============================================================== */ function updateSendButton() { const mode = currentMode(); - const haveKey = mode === 'passphrase' ? !!groupKey : - Array.from(peers.values()).some(p => p.sharedKey); + const haveKey = mode === 'passphrase' ? !!groupKey + : Array.from(peers.values()).some(p => p.sharedKey); $('btn-send').disabled = !(carrierOn && haveKey); } @@ -1045,16 +1205,13 @@ async function sendCurrentMsg() { if (mode === 'passphrase') { if (!groupKey) { logLine('err', 'no room key'); return; } const ct = await aesEncrypt(groupKey, msg); - const frame = await buildDataFrame(ct); - await txFrame(frame, baud); + await txFrame(await buildDataFrame(ct), baud); } else { - /* pubkey: send one frame per peer, encrypted to that peer */ let sent = 0; - for (const [sid, p] of peers) { + for (const p of peers.values()) { if (!p.sharedKey) continue; const ct = await aesEncrypt(p.sharedKey, msg); - const frame = await buildDataFrame(ct); - await txFrame(frame, baud); + await txFrame(await buildDataFrame(ct), baud); sent++; } if (sent === 0) { logLine('err', 'no pubkey peers ready'); return; } @@ -1077,12 +1234,8 @@ $('msg-in').addEventListener('keydown', (e) => { * ============================================================== */ showModePanel(); updateSendButton(); - setInterval(updateSendButton, 1000); -/* try to find the introspector; quiet failure */ -rxConnect(false); - if (LOOPBACK) { const banner = document.createElement('div'); banner.style.cssText = @@ -1091,13 +1244,13 @@ if (LOOPBACK) { 'margin-bottom:1.2rem;letter-spacing:0.03em'; banner.textContent = 'dev loopback active — frames cross via BroadcastChannel between ' + - 'same-browser tabs, NOT via PulseAudio. drop ?loopback=1 from the URL for production.'; + 'same-browser tabs, NOT via WebRTC. drop ?loopback=1 for real cross-machine path.'; document.body.insertBefore(banner, document.body.firstChild); logLine('sys', 'loopback mode: frames will echo to other tabs of this same browser.'); } -logLine('sys', 'ready — start the carrier, derive a room key, then send.'); -logLine('sys', 'no chat content ever enters an IP packet. wireshark blind.'); +logLine('sys', 'ready — start carrier, set up a WebRTC peer connection, then chat.'); +logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry plaintext.'); })();