Both peers type the same rendezvous code and connect automatically through
proxy.uncloseai.com — no manual offer/answer relay. The code derives an
opaque room ID and an AES key that wraps the SDP before it leaves the page,
so the relay sees only ciphertext. The existing share-link/QR exchange stays
as the offline fallback.
Also caps waitForIceGathering with a timeout: some environments never fire
the ICE 'complete' transition even after all candidates are gathered, which
previously hung offer/answer creation forever (affected the manual path too).
manual copy-paste of raw SDP was v1. this adds:
1. share-link encoding: SDP description → JSON → deflate-raw (native
CompressionStream) → base64url → URL hash. typical 2 KB SDP fits
in ~700 chars after compression. fits in any text channel.
URL format: .../zebra-report/#o=<deflated-base64> (offer)
.../zebra-report/#a=<deflated-base64> (answer)
2. QR rendering: same URL rendered as 180x180 QR using inlined
davidshimjs qrcodejs (MIT, 28 KB). offers a visual scan path
(phone scanners) without an in-browser decoder library yet.
3. auto-fill on link open: page reads location.hash on load.
#o=... → autofills remote-offer textarea, prompts user to
complete identity + audio + click "create answer"
#a=... → autofills remote-answer textarea, prompts "accept"
hash is cleared from address bar after parse so a reload doesn't
double-trigger.
4. parsers tolerate either input form: full URL with #o=/#a= hash,
or raw JSON SDP. peer A can paste back a URL or a textarea dump,
same handler.
5. tucked the raw SDP textareas behind a "show raw SDP" toggle so
the default UI is just the URL + QR. expert users still get the
raw bytes when needed.
UX flow (cross-internet, two peers):
A: enter room → start audio → "create offer" → "copy link"
→ send link to B via Signal/SMS/anywhere
B: open link → page auto-fills offer → enter room → start audio
→ "create answer" → "copy link" → send back to A
A: paste link into the answer textarea → "accept answer"
→ SRTP candidates pair → chat begins.
deferred: in-browser camera scanning of QR (needs jsQR or similar
~60 KB inlined; not on disk currently). v3.
file: 1509 lines, ~85 KB. JS syntax-clean (qrcode lib + main IIFE),
HTML balanced. CompressionStream + DecompressionStream available
in all current browsers (Chrome 80+, Firefox 113+, Safari 16.4+).
with coturn live on proxy.uncloseai.com (proxy.unturf.com:22ccf93),
ICE now offers a relay candidate path on every connection. peers
behind symmetric NAT or strict firewalls fall back to TURN instead
of failing the connection.
static creds zebra:7a4a2b1c8d9e6f5a — public by design (a static
page can't hide them); rate-limited by coturn quotas, not secrecy.
google stun stays as fallback if our stun is unreachable.
addresses two issues in the WebRTC same-LAN test:
1. "Cannot set remote answer in state stable" on retry
the offerer's pc enters stable after the first successful accept-
answer; subsequent clicks fail. add a reset-connection button that
close()s the current pc, clears all SDP textareas + status lines,
detaches inbound rx decoders, & restores the disconnected UI.
user can now retry without reloading the page.
2. silent NAT/mDNS diagnostic gap
browsers anonymize host IPs as <uuid>.local mDNS names. routers
with multicast-blocking client isolation (common on guest Wi-Fi)
silently prevent host-candidate pairing on the same LAN. add:
* a details/summary section under the rtc dot documenting the
Firefox + Chromium config knobs to disable host obfuscation;
* pc.onicecandidate logging that prints candidate type
(host/srflx/relay) + an explicit '(mdns-anonymized)' tag
for .local foundations, so the user can see WHAT is being
gathered without opening devtools;
* an err logLine on connectionState=failed pointing at the
reset button.
unchanged: protocol, frame codec, crypto, audio path, signaling
flow. UI surface gains one button + one collapsed details panel.
major architectural rework. previously chat.html modulated its own
GainNode and depended on a native zebrad daemon polling local PA
to close the receive loop. that worked single-machine multi-tab
but had no cross-laptop path without bridging external audio (voice
call, virtual sink, etc.).
new architecture: each peer's modulated audio is the outbound track
of a WebRTC peer connection. each peer decodes the inbound RTC
track in-page via an AudioWorklet energy detector + the same UART
state machine zebrad uses. cross-laptop works because WebRTC carries
the modulated audio between machines. native daemon not required.
outbound: oscillators (440/441 Hz) → modulation gain (MARK/SPACE)
→ channel merger → MediaStreamDestination → RTCPeer-
Connection.addTrack(). also → optional local monitor
gain (muted by default; toggleable for debugging).
inbound: RTCPeerConnection.ontrack → MediaStreamSource
→ AudioWorklet (per-quantum peak detection, 128 samples)
→ main thread postMessage → UartDecoder → FrameAssembler
→ onRxFrame (same handler as before).
signaling: v1 uses manual SDP paste via two textareas. role A
("start a new connection") creates an offer, waits for
gathered ICE, exposes JSON-serialized localDescription
for copy. role B ("join") pastes that, generates an
answer, gets pasted back to A. STUN: stun.l.google.com.
v2 will add a tiny signaling server for auto-pair.
threat: SRTP carries the modulated audio. Wireshark sees only
encrypted RTP packets; chat content lives in audio
amplitude transitions inside the encrypted payload.
additional app-layer AES-GCM (passphrase mode PBKDF2,
pubkey mode ECDH P-256) preserved unchanged.
zebrad daemon (src/zebrad.c) stays in the repo as the V2 disclosure
demo (a same-UID host process polling PulseAudio sink-input volumes
of an arbitrary same-UID tab) but is no longer required for chat.
preserved unchanged from prior chat.html:
* crypto modes (passphrase + pubkey), mode toggle
* sender id (random per-tab in passphrase mode; SHA-256(pubkey)[0:4] in pubkey mode)
* self-echo filter via sid match
* frame codec (OFFER/READY at 50 baud, DATA/HELLO with CRC-32)
* benchmark + handshake state machine
* peer list, chat log
* threat model + mitigation panel
* BroadcastChannel dev loopback via ?loopback=1
file size: 1258 lines, ~46 KB. JS syntax-clean (node --check).
HTML balanced.
zebrad reads @DEFAULT_MONITOR@ which mixes every PA stream on
a machine, so each browser tab hears its own carrier echoed
back. without a filter, every outbound message would render
twice in the chat log (once as 'me' at send time, once as
'peer' on echo arrival).
filter: compare frame.sid (carried in DATA + HELLO) against
our own senderIdBytes(). if equal, drop frame before any UI
work. loopback mode unaffected since BroadcastChannel never
echoes to sender by spec.
senderIdBytes returns a stable per-tab id:
* passphrase mode: random 4 bytes cached for tab lifetime
* pubkey mode: first 4 of SHA-256(pubkey), stable across reloads
single C file (~620 LOC), no third-party deps beyond libpulse.
closes a receive loop for web/chat.html: a browser tab cannot read
another tab's PulseAudio state, so each peer runs zebrad locally
to forward decoded frames to ws://127.0.0.1:7777.
components inline:
* SHA-1 (RFC 3174) + base64 encode — for the WebSocket handshake;
verified against the canonical RFC 6455 example
(key dGhlIHNhbXBsZSBub25jZQ== → accept s3pPLMBiTxaQ9kYGzzhZRbK+xOo=)
* CRC-32 (IEEE 802.3) — verifies DATA + HELLO frame payloads
* UART symbol decoder with adaptive threshold:
running peak with 2-second half-life decay, threshold = 50% of peak,
falling-edge start detect, sample at 1.5..8.5 × sps from edge
* frame assembler: magic-sync ZB, type dispatch OFFER/READY/DATA/HELLO,
overflow + corrupt-length guards, resync on bad checksum
* embedded WebSocket server: TCP listener on 127.0.0.1:7777,
handshake responder, binary frames only (opcode 0x82), no masking
(server→client), any inbound data closes the client (browser
auto-reconnects); max 16 concurrent clients
* pulseaudio: pa_mainloop integration, record stream on @DEFAULT_MONITOR@
by default, PA_SAMPLE_FLOAT32LE mono @ 8 kHz, 10 ms fragsize, peak
energy extracted from |abs(sample)|
adaptive baud: starts at ZEBRA_BAUD_HANDSHAKE (50). On valid READY frame
locks in negotiated baud from payload. Matches the chat.html send path.
binds 127.0.0.1 only — never reachable from the LAN, even though the
chat.html page itself may be served over HTTPS from a public domain.
smoke tested: PA connect + stream ready + WS handshake (RFC example
verified) + clean shutdown. -Wall -Wextra clean.
Makefile: zebrad now part of `make all`; clean target updated.
README: new section documenting it.
without zebrad running, the page is TX-only: tabs see their own
outbound frames but nothing crosses. for UX validation while the
introspector daemon doesn't exist yet, gate a BroadcastChannel
fallback on the ?loopback=1 query param.
behavior:
* with ?loopback=1: after each txFrame completes its modulation,
the raw frame bytes are postMessage'd on a same-origin
BroadcastChannel('zebra-report-loopback'). other tabs of the
same browser subscribe & feed received bytes into onRxFrame
exactly as zebrad would. BroadcastChannel suppresses echo to
sender by spec, so the originating tab does not see itself.
* without ?loopback=1: behavior unchanged. real PA transport
stays the production path.
a yellow banner at the top of the page warns when loopback is
active: "frames cross via BroadcastChannel between same-browser
tabs, NOT via PulseAudio". no risk of mistaking dev mode for
production.
single self-contained page implementing the recovered zebra-report
protocol as a peer-to-peer chatroom whose data path is PulseAudio
sink-input state — chat content never enters an IP packet.
architecture:
page (TX) modulates audio output via Web Audio GainNode
between MARK (0.80) and SPACE (0.20). silent stereo
carrier 440/441 Hz published to PulseAudio.
zebrad separate program (companion, not in this commit):
polls PA sink-input volumes, decodes frames,
forwards to ws://127.0.0.1:7777 — the page connects
and renders received messages.
RX path the page is TX-only without zebrad running; with
it, full bidirectional chat.
crypto modes (toggle in UI):
passphrase PBKDF2-SHA256 (600k iter) → AES-GCM-256 group key.
everyone with the same phrase joins the room.
pubkey ECDH P-256 keypair per browser → AES-GCM-256
pairwise. peers added by pasting their pubkey.
both modes use Web Crypto API. no external crypto deps.
frame protocol (matches include/zebra.h):
OFFER magic(ZB) + 0x01 + baud_le(2) + xor(1) — 6B
READY magic(ZB) + 0x02 + baud_le(2) + xor(1) — 6B
HELLO magic + 0x04 + sid(4) + maxBaud_le(2)
+ handleLen(1) + handle(n) + crc32_le(4)
DATA magic + 0x03 + sid(4) + len_le(2)
+ payload(n) + crc32_le(4)
handshake:
1. user clicks "benchmark self" — page schedules 200 gain
events at 0.5ms intervals, measures avg, divides by 2 for
safety, clamps to [ZEBRA_BAUD_MIN, ZEBRA_BAUD_MAX].
2. user clicks "send OFFER" — sends OFFER frame at the
fixed handshake baud (50). also broadcasts HELLO.
3. peer's introspector decodes; their page replies with READY
at their max baud, plus their HELLO.
4. negotiated baud = min(my max, every peer's max).
5. data frames at negotiated baud.
UI sections:
identity handle + mode toggle
room passphrase OR pubkey, depending on mode
carrier start audio, live gain meter
handshake benchmark, OFFER button, baud display
receive ws://127.0.0.1:7777 status + connect
chat peer list, log, message box
about threat model, mitigation, whitepaper link
defensive framing prominent: the protocol is documented as a
research artifact demonstrating PA-IPC's open same-UID volume
read access. "what this does not protect against" enumerated.
mitigation panel cites foxhop.net/linux-audio-ipc-attack-surface.
known v1 tradeoffs:
- no CSMA: simultaneous TX from two peers will collide.
- pubkey mode broadcasts one frame per peer per message.
- GainNode scheduling has OS-level jitter — actual sustained
baud will be lower than the optimistic benchmark.
- base carrier is audible at 440/441 Hz; can be lowered or
moved ultrasonic in a follow-up.
reuses visual identity from web/index.html (chunkfive font,
monospace, black/white, no framework, no build step).
file: 1062 lines, 40 KB, JS syntax-clean (node --check), HTML
balanced.
aligned with `make clean` — ignores compiled binaries (tx/rx/chat/bt/
carrier, test/{unit,integration,functional}), object files, and the
generated blog output under web/blog/.
recovered code verified at this commit:
make all : 5 binaries built, no -Wall/-Wextra warnings
test/unit : 84/84 passed
test/integration : 17/17 passed (benchmark: 836 baud measured)
test/functional : 8/10 passed
- t_loopback_fixed_baud (50 baud) ok — "ZEBRA" tx->rx round-trip
- t_autoneg_pipeline FAIL: autoneg handshake succeeds; data
transmission at benchmarked baud (1994) times out. cause is
environmental — RX polling under loadavg 2.0 can't sustain
sub-millisecond symbol timing on a non-RT kernel. not a defect
in the recovered code; same path passes at fixed 50 baud.
quality verification:
- confirmed all 8 unapplied edits in phase 1 also failed in their
original sessions (tool-result is_error=1, "String to replace
not found"). Our reconstruction is faithful to live execution.
gaps closed:
- include/modem.h: re-ran replay with cat>>heredoc handling in
chronological position. BATTLE TOADS dual-channel stereo UART
block (4495 bytes, 102 lines) now appended at correct point in
timeline. Two previously-failing edits now apply against the
post-append baseline. unapplied edits dropped 8 -> 6.
- web/fonts/: chunkfive-regular-webfont.{woff,woff2} restored
from live source /home/fox/git/www.unturf.com/css/chunkfive/.
HTML+CSS @font-face references now resolve.
remaining unapplied edits (6) confirmed legitimate live-session
failures, not reconstruction artifacts.
internal references audit:
- all #include directives resolve within recovered tree
- all font url() references resolve to recovered web/fonts/
- no other Bash file-creation ops target zebra-report
final tree: 22 files, ~160KB.