# zebra report

how it works · a volume-modem chatroom over webrtc · open the chat · host your own · unturf

Zebra report is a two-person chatroom where your words never travel as network packets. Each message is encoded into the loudness of an audio tone, carried inside an encrypted WebRTC voice stream, and decoded back into text on the far side. A network observer sees an ordinary encrypted call. The chat lives in the amplitude.

This page walks through the whole machine: how two browsers find each other with no copy-paste, how data rides on volume, why a speech codec nearly killed it, and the error correction that finally made it solid.

▶ open the chat

## 1 · the idea: data in amplitude, not packets

The original zebra-report channel modulates a browser tab's PulseAudio volume: step the volume through values at a fixed clock rate and the other side reads those steps back as data, exactly like a dial-up modem. The payload lives in a side channel that the network stack never sees.

The browser version generates a pure 440 Hz tone locally (no microphone) and swings its output gain per clock tick. That modulated tone becomes the outbound WebRTC audio track. Wireshark sees only encrypted SRTP; the words are in the amplitude envelope inside it.

## 2 · bootstrapping with no copy-paste

WebRTC needs the two peers to swap connection descriptions (SDP) before media flows. The usual way is to copy a blob of text from one device to the other and back. We replaced that with a tiny signaling relay:

A: rendezvous code ──▶ wss://cors-proxy.uncloseai.com/zebra-signal ◀── code :B (relay pairs the two, forwards encrypted SDP) both type the same code · no link · no QR · no paste

Both peers type the same rendezvous code. The page derives an opaque room id and an encryption key from that code, connects to the relay, and the relay forwards the offer/answer between the two. The SDP is encrypted with the code before it leaves the page, so the relay only ever sees ciphertext and an opaque room id. It carries setup only; chat content never touches it.

## 3 · the carrier and the modem

At connection time each browser starts a 440 Hz tone and modulates its gain. Rather than a plain on/off (one bit per tick), the modem is multi-level: each clock tick holds the volume at one of N levels, so a symbol carries log₂(N) bits — 4 levels = 2 bits, 16 levels = 4 bits per tick.

Each byte is framed by a minimum-level start symbol and a maximum-level stop symbol. The receiver locks onto that high-to-low edge, then re-derives its own low and high amplitude reference from the start and stop of every byte — so the level mapping self-calibrates continuously and tolerates drift in the channel's gain.

## 4 · the codec trap

The first cross-machine attempts decoded nothing. The cause was the audio codec. WebRTC defaults to Opus, a perceptual speech codec that re-quantizes audio in 20 ms frames and effectively normalizes loudness. That is precisely the information our modem rides on, so Opus erased it.

Two fixes restored a clean channel:

- Pin the codec to G.711 (`setCodecPreferences`). G.711 is memoryless companding — it preserves the amplitude envelope sample by sample, like the PulseAudio path, instead of smearing it.
- Use a single 440 Hz tone. The carrier had used 440 Hz left / 441 Hz right for a stereo mode, but G.711 is mono, so the two tones downmixed into a 1 Hz beat — a slow sinusoidal swell that drowned the levels. One frequency, no beat.

## 5 · making it reliable

Even on a clean channel, a wireless link is lossy. Three layers make the chat dependable:

- Framing + CRC. Every frame carries a CRC32; a corrupted frame is detected and dropped rather than shown as garbage.
- Delivery ACKs + outbox. A sent message is held in an outbox — not echoed into your log — until the far side acknowledges it (the ACK echoes the frame's CRC as a message id). Only then does it move into the log, marked delivered. The receiver de-dupes on CRC, so a lost ACK never double-shows a message.
- Retransmit with backoff. An un-acknowledged message is resent, with exponential backoff so a bad stretch spaces out retries instead of hammering the channel.

## 6 · error correction (the eureka)

The last problem was the worst kind: messages mostly worked, then a frame would arrive one bit wrong, fail its CRC, get dropped, and the sender would retry forever. The channel was good enough to almost work.

The fix is forward error correction. Each data byte is carried as a Hamming(12,8) codeword — 8 data bits plus 4 parity bits — inside one byte-frame. Any single flipped bit is located and corrected on the far side before the CRC check, so a marginal channel self-heals instead of dropping the frame.

Hamming corrects single-bit errors, but a misread volume level can flip two bits at once. So the levels are Gray-coded: adjacent amplitude levels differ by exactly one bit. The most common error — reading a level as its neighbour — becomes a single-bit flip, which is exactly what Hamming repairs. Together they turned a channel that gave up into one that delivers.

## 7 · encryption & threat model

Chat content is end-to-end encrypted at the application layer, on top of WebRTC's own SRTP encryption. Two modes:

- Passphrase — everyone types the same room phrase; a 256-bit AES-GCM key is derived with PBKDF2-SHA256 (600k iterations).
- Pubkey — 1-to-1 ECDH (P-256) between two public keys.

The encryption happens before anything becomes volume, so the carrier never holds the readable text — it holds scrambled bytes. Here is the journey of `"hi"` in passphrase mode:

1. Plaintext. `"hi"` is two bytes of ASCII: `0x68 0x69`.
2. AES-256-GCM scrambles it. A fresh random 12-byte IV (nonce) is generated and the two bytes are encrypted with the passphrase-derived key, producing `IV (12)` + `ciphertext (2)` + `auth tag (16)` = 30 bytes. The ciphertext is the same length as the plaintext (GCM is a stream mode) and looks like random noise: GCM XORs a key+IV keystream into your bytes, turning `0x68 0x69` into two unpredictable ones. Because the IV is random every time, sending `"hi"` twice yields completely different ciphertext, so no patterns leak.
3. Then it is framed, error-corrected, and modulated. A frame header + CRC wrap the 30 bytes (~43 bytes); each byte becomes a 12-bit Hamming codeword; each codeword becomes Gray-coded N-level volume steps on the 440 Hz carrier.

"hi" ─▶ AES-256-GCM ─▶ IV + ciphertext + tag (30 bytes, looks random) ─▶ frame + CRC (~43 B) ─▶ Hamming(12,8) codewords ─▶ volume levels

By the time it is volume, the readable `h`,`i` is gone three layers up. Someone reading the volume locally (the same-UID PulseAudio attack this project documents) demodulates the amplitude back to bytes and gets 30 bytes of random-looking ciphertext, not `"hi"`. Without the passphrase the encryption is irreversible, and the auth tag means tampering fails outright — flip one bit and decryption rejects it.

And it is doubled up: that is the application layer (AES-GCM). WebRTC independently wraps the whole audio stream in DTLS-SRTP, so the carrier is encrypted again in transit. Two separate keys, two separate layers — the readable `"hi"` exists only in RAM on the two endpoints, never on the wire and never in the volume.

Protects against a network observer reading chat content. Does not protect against another process on either endpoint recording the local audio, or an endpoint compromise — that local audio trust boundary is the surface this project documents.

## 8 · testing without two phones

The modem and protocol are validated by a Node test suite that runs the real shipped code — no browser and no second device required (`make test-web`). It covers the CRC and frame codec, the Hamming and Gray logic, a full multi-level modem roundtrip through a simulated noisy channel, and recovery of off-by-one symbol errors. Over three thousand assertions guard every change to the wire format.

Type a rendezvous code on two devices and watch text cross on nothing but the loudness of a tone.

▶ open the chat

zebra report · volume modem chatroom · unturf