diff --git a/web/chat.html b/web/chat.html index 4693385..92ae52f 100644 --- a/web/chat.html +++ b/web/chat.html @@ -14,7 +14,18 @@ * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: monospace; background: #fff; color: #000; - padding: 2rem; max-width: 820px; margin: 0 auto; + padding: 2rem; max-width: 1100px; margin: 0 auto; + } + /* two-panel layout: setup | chat, 50:50 on desktop, stacked on phone */ + .app { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.75rem; + align-items: start; + } + .panel { min-width: 0; } + @media (max-width: 760px) { + .app { grid-template-columns: 1fr; } } h1 { font-family: 'chunkfiveregular', serif; @@ -159,22 +170,25 @@

volume modem chatroom  ·  e2e encrypted  ·  webrtc carrier  ·  unturf

- +
+ +
+

identity

- +
-
+
- passphrase mode = group; pubkey mode = 1-to-1 ECDH. + group · or 1-to-1 ECDH
@@ -210,242 +224,74 @@
- +

carrier

- - - - + +
- tx gain: + tx
0%
- rx energy: + rx
—
-

- a 440 Hz 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)

-
+

connect

+
+ + +
+
+
+ not connected + +
+
- disconnected — start audio first, then pick a role below - + peer link: idle +
-
- stuck on the same LAN? host candidates as .local? -

- modern browsers anonymize host IPs as .local mDNS names for - privacy. if a router blocks multicast (common on guest Wi-Fi, sometimes - consumer defaults), peers cannot resolve each other's .local - addresses & host candidates fail. workaround: turn off the obfuscation. -

-

- Firefox: about:config → set - media.peerconnection.ice.obfuscate_host_addresses to false.
- Chromium: chrome://flags/#enable-webrtc-hide-local-ips-with-mdns - → set to Disabled.
- reload the page after toggling, then retry. -

-
- -
-

auto-connect · signaling relay (no copy-paste)

-

- both peers type the same rendezvous code and click connect. SDP is - exchanged automatically through proxy.uncloseai.com — - encrypted with the code first, so the relay only ever sees ciphertext. - this is just the connection bootstrap; pick a room passphrase above for - the chat content itself. -

-
- - -
-
-
- not connected to relay - -
-
- -

— or exchange manually (offline / no relay reachable) —

- -
-

role A · start a new connection

-

step 1: click, then share the link with your peer (Signal, SMS, anywhere). They open it on their device.

-
- - -
- - -

step 4: paste the answer link (or raw SDP) your peer sends back:

- -
- - -
-
- -
-

role B · join an existing connection

-

step 2: paste the offer link (or raw SDP) you received:

- -
- - -
-

step 3: share this answer link back to your 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. +

+ both peers type the same code; SDP is exchanged automatically via the relay + (encrypted with the code). chat content rides the audio carrier.

- -
-

handshake

-
- - not yet measured -
-
- - idle -
-
- negotiated baud: - — -
-

- 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). -

-
+
- -
+
+ +

chat

-
-
-

peers in room:

-
-
none yet
-
-
-
-

log:

-
-
+

peers in room:

+
+
none yet
+
- -
-

about

-

- 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) 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 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 correlation against the encrypted SRTP flow. -

-

- 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. -

-
-
+
- - - - + @@ -543,62 +389,6 @@ function escapeHtml(s) { ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch])); } -/* ============================================================== * - * share-link helpers: deflate-raw + base64-url-safe * - * ============================================================== */ -function b64UrlSafe(u8) { - return b64(u8).replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,''); -} -function unb64UrlSafe(s) { - s = s.replace(/-/g,'+').replace(/_/g,'/'); - while (s.length % 4) s += '='; - return unb64(s); -} -async function compressForUrl(text) { - const bytes = new TextEncoder().encode(text); - const cs = new CompressionStream('deflate-raw'); - const w = cs.writable.getWriter(); - w.write(bytes); w.close(); - const out = new Uint8Array(await new Response(cs.readable).arrayBuffer()); - return b64UrlSafe(out); -} -async function decompressFromUrl(b64s) { - const bytes = unb64UrlSafe(b64s); - const ds = new DecompressionStream('deflate-raw'); - const w = ds.writable.getWriter(); - w.write(bytes); w.close(); - const out = await new Response(ds.readable).arrayBuffer(); - return new TextDecoder().decode(out); -} -async function makeShareUrl(sdpDesc, kind /* 'o' | 'a' */) { - const c = await compressForUrl(JSON.stringify(sdpDesc)); - return `${location.origin}${location.pathname}#${kind}=${c}`; -} -/* Accepts a full URL with #o=/#a= hash, raw SDP JSON, or already-extracted base64. - * Returns { kind, sdpJSON } or throws. */ -async function parseShareInput(text) { - const t = (text || '').trim(); - if (!t) throw new Error('empty'); - const m = t.match(/[#?&]([oa])=([A-Za-z0-9_-]+)/); - if (m) { - const sdpJSON = await decompressFromUrl(m[2]); - return { kind: m[1], sdpJSON }; - } - const obj = JSON.parse(t); - if (obj.type === 'offer') return { kind: 'o', sdpJSON: t }; - if (obj.type === 'answer') return { kind: 'a', sdpJSON: t }; - throw new Error('not an offer or answer'); -} -function renderQR(containerId, text) { - const el = $(containerId); - if (!el) return; - el.innerHTML = ''; - try { - new QRCode(el, { text, width: 320, height: 320, correctLevel: QRCode.CorrectLevel.L }); - } catch (e) { - el.textContent = 'QR too long (' + text.length + ' chars). use the copy link button.'; - } -} /* ============================================================== * * logging * @@ -1110,33 +900,15 @@ async function _txOne(bytes, baud) { await sleep(Math.max(0, (t - audioCtx.currentTime) * 1000) + 30); } -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; } - await sleep(Math.max(0, (t - audioCtx.currentTime) * 1000)); - const t1 = performance.now(); - _scheduleGain(ZEBRA_VOL_MARK, audioCtx.currentTime + 0.01); - 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)); - return { baud, avgUs }; -} - /* ============================================================== * * handshake state * * ============================================================== */ let myMaxBaud = null; let negotiatedBaud = null; -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; negotiatedBaud = m; - baudLabel.textContent = m + ' baud'; } /* ============================================================== * @@ -1367,14 +1139,7 @@ function resetConnection() { try { d.audio.pause(); d.audio.srcObject = null; } catch (_) {} } rxDecoders.clear(); - $('local-offer').value = ''; - $('local-answer').value = ''; - $('remote-offer').value = ''; - $('remote-answer').value = ''; - for (const id of ['offer-status','answer-status','ans-status']) { - $(id).textContent = ''; $(id).className = 'status-line'; - } - rtcStatus.textContent = 'disconnected — pick a role below'; + rtcStatus.textContent = 'peer link: idle'; dotRtc.className = 'dot warn'; logLine('sys', 'connection reset — ready to retry'); } @@ -1403,95 +1168,6 @@ function waitForIceGathering(p, timeoutMs = 6000) { }); } -$('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); - const sdpJSON = JSON.stringify(pc.localDescription); - $('local-offer').value = sdpJSON; - const url = await makeShareUrl(pc.localDescription, 'o'); - $('share-offer-url').value = url; - $('share-offer-box').style.display = ''; - renderQR('qr-offer', url); - $('offer-status').textContent = 'offer ready — share the link or QR with your 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 parsed = await parseShareInput($('remote-offer').value); - if (parsed.kind !== 'o') throw new Error('expected an offer, got an answer'); - const off = JSON.parse(parsed.sdpJSON); - ensurePC(); - await pc.setRemoteDescription(off); - const ans = await pc.createAnswer(); - await pc.setLocalDescription(ans); - await waitForIceGathering(pc); - const sdpJSON = JSON.stringify(pc.localDescription); - $('local-answer').value = sdpJSON; - const url = await makeShareUrl(pc.localDescription, 'a'); - $('share-answer-url').value = url; - $('share-answer-box').style.display = ''; - renderQR('qr-answer', url); - $('ans-status').textContent = 'answer ready — share the link or QR back to your 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 parsed = await parseShareInput($('remote-answer').value); - if (parsed.kind !== 'a') throw new Error('expected an answer, got an offer'); - const ans = JSON.parse(parsed.sdpJSON); - 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'; - } -}); - -/* copy-link buttons */ -function attachCopy(btnId, inputId) { - $(btnId).addEventListener('click', () => { - const v = $(inputId).value; - if (!v) return; - navigator.clipboard.writeText(v).then(() => { - const orig = $(btnId).textContent; - $(btnId).textContent = 'copied'; - setTimeout(() => { $(btnId).textContent = orig; }, 1500); - }); - }); -} -attachCopy('btn-copy-offer-url', 'share-offer-url'); -attachCopy('btn-copy-answer-url', 'share-answer-url'); - -/* show/hide raw SDP toggle */ -function attachToggleRaw(btnId, taId) { - $(btnId).addEventListener('click', () => { - const ta = $(taId); - const show = ta.style.display === 'none'; - ta.style.display = show ? '' : 'none'; - $(btnId).textContent = show ? 'hide raw SDP' : 'show raw SDP'; - }); -} -attachToggleRaw('btn-toggle-offer-raw', 'local-offer'); -attachToggleRaw('btn-toggle-answer-raw', 'local-answer'); - /* ============================================================== * * signaling relay — auto-connect, no copy-paste * * * @@ -1733,36 +1409,6 @@ $('btn-audio').addEventListener('click', async () => { } }); -/* ============================================================== * - * handshake UI * - * ============================================================== */ -$('btn-bench').addEventListener('click', async () => { - $('bench-status').textContent = 'measuring…'; - try { - if (!carrierOn) await startCarrier(); - const r = await benchmarkSelf(); - myMaxBaud = r.baud; - $('bench-status').textContent = `${r.baud} baud (avg ${r.avgUs.toFixed(1)} µs/symbol)`; - $('btn-offer').disabled = false; - recomputeGroupBaud(); - logLine('sys', `benchmark: ${r.baud} baud max`); - } catch (e) { - $('bench-status').textContent = 'failed: ' + e.message; - } -}); - -$('btn-offer').addEventListener('click', async () => { - if (!myMaxBaud) return; - $('hs-status').textContent = 'sending OFFER…'; - try { - await txFrame(buildHandshakeFrame(T_OFFER, myMaxBaud), ZEBRA_BAUD_HANDSHAKE); - $('hs-status').textContent = 'OFFER sent — waiting for READY'; - await announceHello(); - } catch (e) { - $('hs-status').textContent = 'tx failed: ' + e.message; - } -}); - /* ============================================================== * * send UI * * ============================================================== */ @@ -1834,36 +1480,9 @@ if (LOOPBACK) { logLine('sys', 'loopback mode: frames will echo to other tabs of this same browser.'); } -logLine('sys', 'ready — start carrier, set up a WebRTC peer connection, then chat.'); +logLine('sys', 'ready — set a room passphrase, type a rendezvous code, connect, then chat.'); logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry plaintext.'); -/* ============================================================== * - * page-load hash auto-handle: pre-fill offer when joining via link* - * ============================================================== */ -async function handleInboundLink() { - const m = location.hash.match(/^#([oa])=([A-Za-z0-9_-]+)$/); - if (!m) return; - try { - const sdpJSON = await decompressFromUrl(m[2]); - if (m[1] === 'o') { - $('remote-offer').value = sdpJSON; - logLine('sys', 'offer link detected — autofilled. enter passphrase + start audio, then click "create answer".'); - $('ans-status').textContent = 'offer pre-filled — complete identity + carrier, then create answer'; - $('remote-offer').scrollIntoView({ behavior: 'smooth', block: 'center' }); - } else { - $('remote-answer').value = sdpJSON; - logLine('sys', 'answer link detected — autofilled. click "accept answer" once your offer is still pending.'); - $('answer-status').textContent = 'answer pre-filled — click "accept answer"'; - } - /* clean hash so a reload doesn't re-trigger */ - history.replaceState(null, '', location.pathname); - } catch (e) { - logLine('err', 'inbound link parse failed: ' + e.message); - } -} -handleInboundLink(); -window.addEventListener('hashchange', handleInboundLink); - })();