www.unturf.com/zebra-report/how-it-works.html
2026-06-03 20:57:18 -04:00

369 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>zebra report — how it works</title>
<style>
@font-face {
font-family: 'chunkfiveregular';
src: url('fonts/chunkfive-regular-webfont.woff2') format('woff2'),
url('fonts/chunkfive-regular-webfont.woff') format('woff');
font-weight: normal; font-style: normal;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: monospace; background: #fff; color: #000;
padding: 2rem; max-width: 820px; margin: 0 auto; line-height: 1.6;
}
h1 {
font-family: 'chunkfiveregular', serif;
font-size: 3rem; font-weight: normal;
letter-spacing: 0.02em; line-height: 1; margin-bottom: 0.2rem;
}
.sub {
font-size: 0.75rem; color: #555; margin-bottom: 2.5rem;
letter-spacing: 0.05em; text-transform: uppercase;
}
.sub a { color: #555; }
h2 {
font-family: 'chunkfiveregular', serif;
font-size: 1.3rem; font-weight: normal;
border-bottom: 1px solid #000;
padding-bottom: 0.3rem; margin: 2.4rem 0 0.9rem;
}
p { margin-bottom: 0.9rem; }
ul, ol { margin: 0 0 0.9rem 1.4rem; }
li { margin-bottom: 0.35rem; }
code {
background: #f0f0f0; padding: 0 0.2rem;
font-size: 0.85em; border: 1px solid #ddd;
}
.lead { font-size: 1.05rem; }
.note { font-size: 0.8rem; color: #555; }
.cta {
display: inline-block; margin: 0.4rem 0;
background: #000; color: #fff; border: 1px solid #000;
padding: 0.5rem 1.2rem; text-decoration: none; font-size: 0.9rem;
}
.cta:hover { background: #333; }
.diagram {
border: 1px solid #000; background: #fafafa;
padding: 1rem; font-size: 0.72rem; line-height: 1.45;
overflow-x: auto; white-space: pre; margin: 1rem 0;
}
hr { border: none; border-top: 1px solid #ddd; margin: 2.5rem 0; }
.foot { font-size: 0.75rem; color: #777; margin-top: 2rem; }
@media (max-width: 600px) {
body { padding: 1.25rem; line-height: 1.55; }
h1 { font-size: 2.2rem; }
h2 { font-size: 1.1rem; margin: 1.8rem 0 0.7rem; }
.lead { font-size: 0.95rem; }
p, li { overflow-wrap: anywhere; }
}
/* ---- shared dark mode: pure black canvas, dim text, low light ---- */
html.theme-dark { background: #000; }
html.theme-dark body { background: #000; color: #ccc; }
html.theme-dark a { color: #9cf; }
html.theme-dark .sub, html.theme-dark .sub a, html.theme-dark .note,
html.theme-dark .foot, html.theme-dark label,
html.theme-dark .status-line { color: #888; }
html.theme-dark h2 { border-bottom-color: #333; }
html.theme-dark code, html.theme-dark .key-display {
background: #1a1a1a; border-color: #333; color: #ccc;
}
html.theme-dark .key-display:hover { background: #222; }
html.theme-dark button {
background: #000; color: #ccc; border-color: #444;
}
html.theme-dark button:hover:not(:disabled) { background: #1a1a1a; }
html.theme-dark button.invert, html.theme-dark .cta {
background: #ccc; color: #000; border-color: #ccc;
}
html.theme-dark button.invert:hover:not(:disabled),
html.theme-dark .cta:hover { background: #999; }
html.theme-dark input[type=text], html.theme-dark input[type=password],
html.theme-dark textarea, html.theme-dark select {
background: #0a0a0a; color: #ccc; border-color: #333;
}
html.theme-dark .dot { background: #000; border-color: #444; }
html.theme-dark .dot.on { background: #ccc; border-color: #ccc; }
html.theme-dark .dot.ok { background: #0a0; border-color: #0a0; }
html.theme-dark .meter { background: #000; border-color: #444; }
html.theme-dark .meter-fill { background: #ccc; }
html.theme-dark .status-line.ok { color: #6c6; }
html.theme-dark .status-line.err { color: #f66; }
html.theme-dark .log, html.theme-dark .diagram {
background: #050505; border-color: #333; color: #ccc;
}
html.theme-dark .log-line .ts { color: #666; }
html.theme-dark .log-line.sys { color: #888; font-style: italic; }
html.theme-dark .log-line.err { color: #f66; }
html.theme-dark hr { border-top-color: #222; }
html.theme-dark footer { color: #555 !important; }
html.theme-dark footer span { color: #555 !important; }
html.theme-dark footer a { color: #777 !important; }
.theme-toggle {
position: fixed; top: 0.6rem; right: 0.8rem; z-index: 9999;
background: #fff; color: #000; border: 1px solid #000;
padding: 0.2rem 0.55rem; font-family: monospace; font-size: 0.7rem;
cursor: pointer; line-height: 1;
}
.theme-toggle:hover { background: #f0f0f0; }
html.theme-dark .theme-toggle { background: #000; color: #ccc; border-color: #444; }
html.theme-dark .theme-toggle:hover { background: #1a1a1a; }
</style>
<script>
/* shared zebra dark-mode pre-paint script — default IS dark, only an
* explicit 'light' choice opts out so first-time visitors land dark
* without a white flash */
try {
var pref = localStorage.getItem('zebra-theme-v1');
if (!pref) pref = localStorage.getItem('zebra-spaces-theme-v1'); // legacy
if (pref !== 'light'){
document.documentElement.classList.add('theme-dark');
}
} catch(_){ document.documentElement.classList.add('theme-dark'); }
</script>
</head>
<body>
<button id="btn-theme" class="theme-toggle" type="button" aria-label="toggle dark mode">light</button>
<h1>zebra report</h1>
<p class="sub">how it works &nbsp;·&nbsp; a volume-modem chatroom over webrtc &nbsp;·&nbsp;
<a href="./">open the chat</a> &nbsp;·&nbsp;
<a href="host-your-own.html">host your own</a> &nbsp;·&nbsp; <a href="/">unturf</a></p>
<p class="lead">
Zebra report is a two-person chatroom where your words never travel as network
packets. Each message is encoded into the <em>loudness</em> 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.
</p>
<p>
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.
</p>
<a class="cta" href="./">&#9654; open the chat</a>
<h2>1 · the idea: data in amplitude, not packets</h2>
<p>
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.
</p>
<p>
The browser version generates a pure 440&nbsp;Hz tone locally (no microphone)
and swings its <em>output gain</em> 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.
</p>
<h2>2 · bootstrapping with no copy-paste</h2>
<p>
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 <strong>signaling relay</strong>:
</p>
<div class="diagram">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</div>
<p>
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
<em>before</em> 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.
</p>
<h2>3 · the carrier and the modem</h2>
<p>
At connection time each browser starts a 440&nbsp;Hz tone and modulates its
gain. Rather than a plain on/off (one bit per tick), the modem is
<strong>multi-level</strong>: each clock tick holds the volume at one of N
levels, so a symbol carries log&#8322;(N) bits &mdash; 4 levels = 2 bits, 16
levels = 4 bits per tick.
</p>
<p>
Each byte is framed by a minimum-level <em>start</em> symbol and a
maximum-level <em>stop</em> 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 <em>every byte</em> &mdash; so the level mapping self-calibrates
continuously and tolerates drift in the channel's gain.
</p>
<h2>4 · the codec trap</h2>
<p>
The first cross-machine attempts decoded nothing. The cause was the audio
codec. WebRTC defaults to <strong>Opus</strong>, a perceptual speech codec that
re-quantizes audio in 20&nbsp;ms frames and effectively normalizes loudness.
That is precisely the information our modem rides on, so Opus erased it.
</p>
<p>
Two fixes restored a clean channel:
</p>
<ul>
<li><strong>Pin the codec to G.711</strong> (<code>setCodecPreferences</code>).
G.711 is memoryless companding &mdash; it preserves the amplitude envelope
sample by sample, like the PulseAudio path, instead of smearing it.</li>
<li><strong>Use a single 440&nbsp;Hz tone.</strong> The carrier had used
440&nbsp;Hz left / 441&nbsp;Hz right for a stereo mode, but G.711 is mono, so
the two tones downmixed into a 1&nbsp;Hz <em>beat</em> &mdash; a slow
sinusoidal swell that drowned the levels. One frequency, no beat.</li>
</ul>
<h2>5 · making it reliable</h2>
<p>
Even on a clean channel, a wireless link is lossy. Three layers make the chat
dependable:
</p>
<ul>
<li><strong>Framing + CRC.</strong> Every frame carries a CRC32; a corrupted
frame is detected and dropped rather than shown as garbage.</li>
<li><strong>Delivery ACKs + outbox.</strong> A sent message is held in an
outbox &mdash; not echoed into your log &mdash; 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.</li>
<li><strong>Retransmit with backoff.</strong> An un-acknowledged message is
resent, with exponential backoff so a bad stretch spaces out retries instead
of hammering the channel.</li>
</ul>
<h2>6 · error correction (the eureka)</h2>
<p>
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 <em>almost</em> work.
</p>
<p>
The fix is forward error correction. Each data byte is carried as a
<strong>Hamming(12,8)</strong> codeword &mdash; 8 data bits plus 4 parity bits
&mdash; inside one byte-frame. Any single flipped bit is located and corrected
on the far side <em>before</em> the CRC check, so a marginal channel self-heals
instead of dropping the frame.
</p>
<p>
Hamming corrects single-bit errors, but a misread volume level can flip two
bits at once. So the levels are <strong>Gray-coded</strong>: adjacent amplitude
levels differ by exactly one bit. The most common error &mdash; reading a level
as its neighbour &mdash; becomes a single-bit flip, which is exactly what
Hamming repairs. Together they turned a channel that gave up into one that
delivers.
</p>
<h2>7 · encryption &amp; threat model</h2>
<p>
Chat content is end-to-end encrypted at the application layer, on top of
WebRTC's own SRTP encryption. Two modes:
</p>
<ul>
<li><strong>Passphrase</strong> &mdash; everyone types the same room phrase;
a 256-bit AES-GCM key is derived with PBKDF2-SHA256 (600k iterations).</li>
<li><strong>Pubkey</strong> &mdash; 1-to-1 ECDH (P-256) between two public
keys.</li>
</ul>
<p>
The encryption happens <em>before</em> anything becomes volume, so the carrier
never holds the readable text &mdash; it holds scrambled bytes. Here is the
journey of <code>"hi"</code> in passphrase mode:
</p>
<ol>
<li><strong>Plaintext.</strong> <code>"hi"</code> is two bytes of ASCII:
<code>0x68 0x69</code>.</li>
<li><strong>AES-256-GCM scrambles it.</strong> A fresh random 12-byte IV
(nonce) is generated and the two bytes are encrypted with the
passphrase-derived key, producing <code>IV (12)</code> +
<code>ciphertext (2)</code> + <code>auth tag (16)</code> =
<strong>30 bytes</strong>. 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 <code>0x68 0x69</code> into two
unpredictable ones. Because the IV is random every time, sending
<code>"hi"</code> twice yields completely different ciphertext, so no
patterns leak.</li>
<li><strong>Then it is framed, error-corrected, and modulated.</strong> 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&nbsp;Hz carrier.</li>
</ol>
<div class="diagram">"hi" ─▶ AES-256-GCM ─▶ IV + ciphertext + tag (30 bytes, looks random)
─▶ frame + CRC (~43 B) ─▶ Hamming(12,8) codewords ─▶ volume levels</div>
<p>
By the time it is volume, the readable <code>h</code>,<code>i</code> 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 <code>"hi"</code>. Without the
passphrase the encryption is irreversible, and the auth tag means tampering
fails outright &mdash; flip one bit and decryption rejects it.
</p>
<p>
And it is doubled up: that is the <strong>application layer</strong> (AES-GCM).
WebRTC independently wraps the whole audio stream in <strong>DTLS-SRTP</strong>,
so the carrier is encrypted again in transit. Two separate keys, two separate
layers &mdash; the readable <code>"hi"</code> exists only in RAM on the two
endpoints, never on the wire and never in the volume.
</p>
<p class="note">
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 &mdash; that local audio trust boundary is the surface this
project documents.
</p>
<h2>8 · testing without two phones</h2>
<p>
The modem and protocol are validated by a Node test suite that runs the real
shipped code &mdash; no browser and no second device required
(<code>make test-web</code>). 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.
</p>
<hr>
<p>Type a rendezvous code on two devices and watch text cross on nothing but the
loudness of a tone.</p>
<a class="cta" href="./">&#9654; open the chat</a>
<p class="foot">
zebra report &middot; volume modem chatroom &middot;
<a href="/" style="color:#777">unturf</a>
</p>
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span style="color:#777">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-04</span><br>
md5 <span class="stamp-md5">9870bd4e4dae3439f3238f6c7bfbb0e1</span><br>
sha256 <span class="stamp-sha">7bf54074bc06b6a9378d75cfc678aadf626c2c2e921149db5610a7e0ed86ef6a</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify it against these hashes; point it at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
</footer>
<script>
/* shared zebra dark-mode toggle handler */
(function(){
var btn = document.getElementById('btn-theme'); if (!btn) return;
function applyLabel(){
btn.textContent = document.documentElement.classList.contains('theme-dark') ? 'light' : 'dark';
}
applyLabel();
btn.addEventListener('click', function(){
var root = document.documentElement;
var goDark = !root.classList.contains('theme-dark');
root.classList.toggle('theme-dark', goDark);
try { localStorage.setItem('zebra-theme-v1', goDark ? 'dark' : 'light'); } catch(_){}
applyLabel();
});
})();
</script>
</body>
</html>