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
1105 lines
41 KiB
HTML
1105 lines
41 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 — chat</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: 760px; margin: 0 auto;
|
|
}
|
|
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;
|
|
}
|
|
section { margin-bottom: 2rem; }
|
|
h2 {
|
|
font-family: 'chunkfiveregular', serif;
|
|
font-size: 1.1rem; font-weight: normal;
|
|
border-bottom: 1px solid #000;
|
|
padding-bottom: 0.25rem; margin-bottom: 0.8rem;
|
|
}
|
|
button {
|
|
background: #fff; color: #000; border: 1px solid #000;
|
|
padding: 0.4rem 1.2rem; font-family: monospace; font-size: 0.85rem;
|
|
cursor: pointer;
|
|
}
|
|
button:hover:not(:disabled) { background: #f0f0f0; }
|
|
button:disabled { opacity: 0.3; cursor: default; }
|
|
button.invert { background: #000; color: #fff; }
|
|
button.invert:hover:not(:disabled) { background: #333; }
|
|
.row { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; flex-wrap: wrap; }
|
|
.dot {
|
|
width: 9px; height: 9px; border-radius: 50%;
|
|
border: 1px solid #000; background: #fff;
|
|
flex-shrink: 0; transition: background 0.15s;
|
|
}
|
|
.dot.on { background: #000; }
|
|
.dot.warn { background: #888; }
|
|
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 {
|
|
font-family: monospace; font-size: 0.85rem;
|
|
border: 1px solid #000; padding: 0.4rem;
|
|
background: #fff; color: #000;
|
|
}
|
|
input[type=text], input[type=password], select { width: 100%; }
|
|
textarea { width: 100%; resize: vertical; }
|
|
.field-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; }
|
|
.field-row > input, .field-row > textarea { flex: 1; }
|
|
.key-display {
|
|
border: 1px solid #ccc; padding: 0.5rem;
|
|
font-family: monospace; font-size: 0.7rem;
|
|
word-break: break-all; cursor: pointer;
|
|
user-select: all; background: #f8f8f8;
|
|
line-height: 1.4;
|
|
}
|
|
.key-display:hover { background: #efefef; }
|
|
.status-line { font-size: 0.7rem; color: #555; }
|
|
.status-line.ok { color: #060; }
|
|
.status-line.err { color: #b00; }
|
|
.meter {
|
|
height: 12px; border: 1px solid #000; background: #fff;
|
|
position: relative; overflow: hidden;
|
|
}
|
|
.meter-fill {
|
|
height: 100%; background: #000; width: 0%;
|
|
transition: width 0.05s linear;
|
|
}
|
|
.log {
|
|
border: 1px solid #000; height: 280px; overflow-y: auto;
|
|
padding: 0.5rem; font-size: 0.78rem; line-height: 1.5;
|
|
background: #fafafa;
|
|
}
|
|
.log-line { margin-bottom: 0.25rem; word-wrap: break-word; }
|
|
.log-line .ts { color: #888; }
|
|
.log-line .from { font-weight: bold; }
|
|
.log-line.sys { color: #555; font-style: italic; }
|
|
.log-line.me { color: #000; }
|
|
.log-line.peer { color: #050; }
|
|
.log-line.err { color: #b00; }
|
|
.peer-list {
|
|
font-size: 0.75rem; color: #444;
|
|
border: 1px solid #ccc; padding: 0.5rem;
|
|
background: #f8f8f8;
|
|
}
|
|
.peer-list .peer { margin-bottom: 0.15rem; }
|
|
.peer-list .peer .h { font-weight: bold; }
|
|
.peer-list .empty { color: #999; font-style: italic; }
|
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
|
@media (max-width: 600px) {
|
|
body { padding: 1rem; }
|
|
.grid-2 { grid-template-columns: 1fr; }
|
|
}
|
|
code {
|
|
background: #f0f0f0; padding: 0 0.2rem;
|
|
font-size: 0.78rem; border: 1px solid #ddd;
|
|
}
|
|
details > summary {
|
|
cursor: pointer; font-size: 0.8rem;
|
|
padding: 0.3rem 0; user-select: none;
|
|
}
|
|
details[open] > summary { margin-bottom: 0.5rem; }
|
|
.mode-toggle {
|
|
display: inline-flex; border: 1px solid #000;
|
|
font-size: 0.78rem; user-select: none;
|
|
}
|
|
.mode-toggle > label {
|
|
padding: 0.35rem 0.8rem; cursor: pointer;
|
|
color: #000;
|
|
}
|
|
.mode-toggle > input { display: none; }
|
|
.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; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h1>zebra report</h1>
|
|
<p class="sub">volume modem chatroom · e2e encrypted · no network ·
|
|
<a href="./" style="color:#555">index</a></p>
|
|
|
|
<!-- ============================================================ -->
|
|
<section>
|
|
<h2>identity</h2>
|
|
<div class="row">
|
|
<label for="handle-in">handle:</label>
|
|
<input type="text" id="handle-in" style="max-width:14rem" placeholder="your handle">
|
|
<span class="status-line" id="id-status"></span>
|
|
</div>
|
|
<div class="row" style="margin-top:0.4rem">
|
|
<span class="mode-toggle">
|
|
<input type="radio" name="mode" value="passphrase" id="m-pp" checked>
|
|
<label for="m-pp">passphrase</label>
|
|
<input type="radio" name="mode" value="pubkey" id="m-pk">
|
|
<label for="m-pk">pubkey</label>
|
|
</span>
|
|
<span class="note">passphrase mode = group; pubkey mode = 1-to-1 ECDH.</span>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================ -->
|
|
<section id="sec-passphrase" class="mode-panel active">
|
|
<h2>room (passphrase mode)</h2>
|
|
<div class="row">
|
|
<input type="password" id="passphrase-in" placeholder="room passphrase — everyone in the room types the same one"
|
|
style="flex:1; min-width:0">
|
|
</div>
|
|
<div class="row">
|
|
<button id="btn-pp-join" class="invert">enter room</button>
|
|
<span class="status-line" id="pp-status">no room key</span>
|
|
</div>
|
|
<p class="note">
|
|
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.
|
|
</p>
|
|
</section>
|
|
|
|
<!-- ============================================================ -->
|
|
<section id="sec-pubkey" class="mode-panel">
|
|
<h2>room (pubkey mode)</h2>
|
|
<p class="note" style="margin-bottom:0.5rem">your public key (click to copy):</p>
|
|
<div class="key-display" id="pub-display">generating…</div>
|
|
<div class="row" style="margin-top:0.5rem">
|
|
<button id="btn-regen">regenerate</button>
|
|
<span class="status-line" id="key-status"></span>
|
|
</div>
|
|
<div class="field-row" style="margin-top:0.5rem">
|
|
<input type="text" id="peer-key-in" placeholder="paste a peer's public key…">
|
|
<button id="btn-pk-add">add peer</button>
|
|
</div>
|
|
<p class="note">add one peer at a time. each add derives a 1-to-1 shared key
|
|
and lets you exchange messages with that peer.</p>
|
|
</section>
|
|
|
|
<!-- ============================================================ -->
|
|
<section>
|
|
<h2>carrier</h2>
|
|
<div class="row">
|
|
<div class="dot" id="dot-audio"></div>
|
|
<button id="btn-audio">start audio</button>
|
|
<label id="audio-status">offline</label>
|
|
<span class="note" style="margin-left:auto">mic stays off · transmit only</span>
|
|
</div>
|
|
<div class="row" style="margin-top:0.4rem">
|
|
<span class="note" style="min-width:6rem">live gain:</span>
|
|
<div class="meter" style="flex:1"><div class="meter-fill" id="meter-tx"></div></div>
|
|
<span class="note" id="gain-label" style="min-width:3rem; text-align:right">0%</span>
|
|
</div>
|
|
<p class="note">
|
|
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.
|
|
</p>
|
|
</section>
|
|
|
|
<!-- ============================================================ -->
|
|
<section>
|
|
<h2>handshake</h2>
|
|
<div class="row">
|
|
<button id="btn-bench">benchmark self</button>
|
|
<span class="status-line" id="bench-status">not yet measured</span>
|
|
</div>
|
|
<div class="row">
|
|
<button id="btn-offer" disabled>send OFFER</button>
|
|
<span class="status-line" id="hs-status">idle</span>
|
|
</div>
|
|
<div class="row">
|
|
<span class="note" style="min-width:8rem">negotiated baud:</span>
|
|
<span id="baud-label" style="font-weight:bold">—</span>
|
|
</div>
|
|
<p class="note">
|
|
OFFER and READY frames travel at fixed <code>ZEBRA_BAUD_HANDSHAKE=50</code>.
|
|
Each peer measures its own scheduling jitter, broadcasts its max baud, and
|
|
the room settles on <code>min(all peers)</code>.
|
|
</p>
|
|
</section>
|
|
|
|
<!-- ============================================================ -->
|
|
<section>
|
|
<h2>receive (introspector bridge)</h2>
|
|
<div class="row">
|
|
<div class="dot warn" id="dot-rx"></div>
|
|
<span id="rx-status" class="note">no introspector — running TX-only</span>
|
|
<button id="btn-rx-connect" style="margin-left:auto">connect</button>
|
|
</div>
|
|
<p class="note">
|
|
expects <code>zebrad</code> at <code>ws://127.0.0.1:7777</code>. The bridge
|
|
polls PulseAudio sink-input volumes via PA IPC and pushes decoded frames here.
|
|
Without it the page transmits but cannot receive. The
|
|
<a href="./" style="color:#555">recovered <code>rx.c</code></a> handles
|
|
decoding; <code>zebrad</code> is the WebSocket wrapper around it.
|
|
</p>
|
|
</section>
|
|
|
|
<!-- ============================================================ -->
|
|
<section>
|
|
<h2>chat</h2>
|
|
<div class="grid-2">
|
|
<div>
|
|
<p class="note" style="margin-bottom:0.3rem">peers in room:</p>
|
|
<div class="peer-list" id="peer-list">
|
|
<div class="empty">none yet</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p class="note" style="margin-bottom:0.3rem">log:</p>
|
|
<div class="log" id="log"></div>
|
|
</div>
|
|
</div>
|
|
<div class="field-row" style="margin-top:0.6rem">
|
|
<input type="text" id="msg-in" placeholder="type a message and press enter">
|
|
<button id="btn-send" disabled>send</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================ -->
|
|
<section>
|
|
<h2>about</h2>
|
|
<p class="note">
|
|
This page is the “production” <code>zebra-report</code> protocol:
|
|
a covert peer-to-peer chatroom whose <em>data path</em> is PulseAudio sink-input
|
|
state. Chat content never enters an IP packet — Wireshark sees only the
|
|
websocket between this browser tab and the local <code>zebrad</code> bridge,
|
|
and even that traffic is post-encryption ciphertext + frame metadata.
|
|
</p>
|
|
<details style="margin-top:0.5rem">
|
|
<summary>threat model · what this protects against · what it doesn't</summary>
|
|
<p class="note" style="margin-top:0.4rem">
|
|
<strong>Protects against:</strong> a network-level observer (Wireshark, IDS,
|
|
DPI middlebox, ISP) seeing chat content. None of it crosses the network.
|
|
</p>
|
|
<p class="note" style="margin-top:0.4rem">
|
|
<strong>Does not protect against:</strong>
|
|
(1) other same-UID processes that can poll PulseAudio — this is the
|
|
<em>entire</em> attack surface zebra-report demonstrates;
|
|
(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).
|
|
</p>
|
|
<p class="note" style="margin-top:0.4rem">
|
|
<strong>Mitigation for defenders:</strong> 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
|
|
<a href="https://foxhop.net/linux-audio-ipc-attack-surface.html" style="color:#555">
|
|
foxhop.net/linux-audio-ipc-attack-surface</a> for the full whitepaper.
|
|
</p>
|
|
</details>
|
|
</section>
|
|
|
|
<!-- ============================================================ -->
|
|
<!-- JS -->
|
|
<!-- ============================================================ -->
|
|
<script>
|
|
(async () => {
|
|
|
|
/* ============================================================== *
|
|
* 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_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 T_OFFER = 0x01;
|
|
const T_READY = 0x02;
|
|
const T_DATA = 0x03; /* extension: data frame */
|
|
const T_HELLO = 0x04; /* extension: peer announcement */
|
|
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');
|
|
|
|
/* ============================================================== *
|
|
* tiny utils *
|
|
* ============================================================== */
|
|
const $ = (id) => document.getElementById(id);
|
|
function b64(buf) {
|
|
const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
|
let s = ''; for (const b of u8) s += String.fromCharCode(b);
|
|
return btoa(s);
|
|
}
|
|
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('');
|
|
}
|
|
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++) {
|
|
let c = i;
|
|
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
|
t[i] = c >>> 0;
|
|
}
|
|
return t;
|
|
})();
|
|
function crc32(bytes) {
|
|
let c = 0xFFFFFFFF;
|
|
for (let i = 0; i < bytes.length; i++) c = CRC32_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8);
|
|
return (c ^ 0xFFFFFFFF) >>> 0;
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* logging *
|
|
* ============================================================== */
|
|
const logEl = $('log');
|
|
function logLine(kind, html) {
|
|
const div = document.createElement('div');
|
|
div.className = 'log-line ' + (kind || 'sys');
|
|
const ts = new Date().toLocaleTimeString();
|
|
div.innerHTML = `<span class="ts">${ts}</span> ${html}`;
|
|
logEl.appendChild(div);
|
|
logEl.scrollTop = logEl.scrollHeight;
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* crypto: passphrase mode (PBKDF2 → AES-GCM) *
|
|
* ============================================================== */
|
|
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 } */
|
|
|
|
async function deriveGroupKey(phrase) {
|
|
const baseKey = await crypto.subtle.importKey(
|
|
'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']
|
|
);
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* 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']
|
|
);
|
|
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) {
|
|
try {
|
|
const privateKey = await crypto.subtle.importKey(
|
|
'jwk', JSON.parse(privStr),
|
|
{ name: 'ECDH', namedCurve: 'P-256' },
|
|
true, ['deriveKey', 'deriveBits']
|
|
);
|
|
return { privateKey, pubB64: pubStr };
|
|
} catch (_) {}
|
|
}
|
|
return genKeypair();
|
|
}
|
|
|
|
async function deriveShared(myPrivKey, peerPubB64) {
|
|
const peerPub = await crypto.subtle.importKey(
|
|
'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']
|
|
);
|
|
}
|
|
|
|
async function aesEncrypt(key, plaintextStr) {
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
const ct = new Uint8Array(await crypto.subtle.encrypt(
|
|
{ name: 'AES-GCM', iv }, key, new TextEncoder().encode(plaintextStr)
|
|
));
|
|
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 pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
|
|
return new TextDecoder().decode(pt);
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* sender id: 4-byte digest of identity material *
|
|
* ============================================================== */
|
|
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);
|
|
}
|
|
if (!senderIdBytes._cached) {
|
|
senderIdBytes._cached = crypto.getRandomValues(new Uint8Array(4));
|
|
}
|
|
return senderIdBytes._cached;
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* frame codec *
|
|
* ============================================================== */
|
|
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);
|
|
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;
|
|
const body = new Uint8Array(head.length + len);
|
|
body.set(head); body.set(payloadBytes, head.length);
|
|
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;
|
|
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);
|
|
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;
|
|
body[9] = hb.length;
|
|
body.set(hb, 10);
|
|
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;
|
|
return out;
|
|
}
|
|
|
|
function parseFrame(bytes) {
|
|
if (bytes.length < 3) return null;
|
|
if (bytes[0] !== HS_MAGIC[0] || bytes[1] !== HS_MAGIC[1]) return null;
|
|
const type = bytes[2];
|
|
if (type === T_OFFER || type === T_READY) {
|
|
if (bytes.length < HS_FRAME_LEN) return null;
|
|
if (xorChecksum(bytes.slice(0, 5)) !== bytes[5]) return { type, error: 'xor' };
|
|
const baud = bytes[3] | (bytes[4] << 8);
|
|
return { type, baud };
|
|
}
|
|
if (type === T_DATA) {
|
|
if (bytes.length < 13) return null;
|
|
const sid = bytes.slice(3, 7);
|
|
const len = bytes[7] | (bytes[8] << 8);
|
|
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' };
|
|
return { type, sid, payload };
|
|
}
|
|
if (type === T_HELLO) {
|
|
if (bytes.length < 10) return null;
|
|
const sid = bytes.slice(3, 7);
|
|
const maxBaud = bytes[7] | (bytes[8] << 8);
|
|
const hlen = bytes[9];
|
|
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' };
|
|
return { type, sid, maxBaud, handle };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* audio carrier + gain modulator *
|
|
* ============================================================== */
|
|
let audioCtx = null;
|
|
let gainL = null, gainR = 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();
|
|
}
|
|
|
|
const meterFill = $('meter-tx');
|
|
const gainLabel = $('gain-label');
|
|
function _meterTick() {
|
|
if (!carrierOn) return;
|
|
const g = gainL ? gainL.gain.value : 0;
|
|
meterFill.style.width = Math.round(g * 100) + '%';
|
|
gainLabel.textContent = Math.round(g * 100) + '%';
|
|
requestAnimationFrame(_meterTick);
|
|
}
|
|
|
|
/* transmit a byte array via volume modulation.
|
|
* frame layout per bit: SPACE(start) + 8 data bits LSB first + MARK(stop). */
|
|
async function txFrame(bytes, baud) {
|
|
if (!carrierOn) throw new Error('carrier not started');
|
|
txQueue.push({ bytes, baud });
|
|
if (txBusy) return;
|
|
txBusy = true;
|
|
try {
|
|
while (txQueue.length) {
|
|
const job = txQueue.shift();
|
|
await _txOne(job.bytes, job.baud);
|
|
if (loopChan) {
|
|
try {
|
|
const ab = job.bytes.buffer.slice(
|
|
job.bytes.byteOffset,
|
|
job.bytes.byteOffset + job.bytes.byteLength);
|
|
loopChan.postMessage(ab);
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
} finally {
|
|
txBusy = false;
|
|
}
|
|
}
|
|
|
|
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++) {
|
|
_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);
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* 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 */
|
|
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 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 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;
|
|
}
|
|
negotiatedBaud = m;
|
|
baudLabel.textContent = m + ' baud';
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* introspector bridge (RX path) + dev loopback *
|
|
* ============================================================== */
|
|
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;
|
|
onRxFrame(buf);
|
|
};
|
|
}
|
|
|
|
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.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;
|
|
}
|
|
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);
|
|
let entry = peers.get(sid) || {};
|
|
entry.handle = f.handle;
|
|
entry.maxBaud = f.maxBaud;
|
|
entry.lastSeen = Date.now();
|
|
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();
|
|
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; }
|
|
} else {
|
|
/* pubkey mode: try each peer's shared key */
|
|
let 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;
|
|
}
|
|
}
|
|
const handle = (peers.get(sid) || {}).handle || sid.slice(0,8);
|
|
logLine('peer', `<span class="from">${escapeHtml(handle)}:</span> ${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 = '<div class="empty">none yet</div>';
|
|
return;
|
|
}
|
|
for (const [sid, p] of peers) {
|
|
const row = document.createElement('div');
|
|
row.className = 'peer';
|
|
row.innerHTML = `<span class="h">${escapeHtml(p.handle || '?')}</span>
|
|
<span style="color:#888"> · sid ${sid.slice(0,8)} · ${p.maxBaud || '?'} baud</span>`;
|
|
el.appendChild(row);
|
|
}
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* 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)`);
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* mode toggle *
|
|
* ============================================================== */
|
|
function currentMode() {
|
|
const r = document.querySelector('input[name="mode"]:checked');
|
|
return r ? r.value : 'passphrase';
|
|
}
|
|
function showModePanel() {
|
|
const m = currentMode();
|
|
$('sec-passphrase').classList.toggle('active', m === 'passphrase');
|
|
$('sec-pubkey').classList.toggle('active', m === 'pubkey');
|
|
}
|
|
for (const r of document.querySelectorAll('input[name="mode"]')) {
|
|
r.addEventListener('change', () => {
|
|
showModePanel();
|
|
updateSendButton();
|
|
});
|
|
}
|
|
|
|
/* ============================================================== *
|
|
* UI: identity *
|
|
* ============================================================== */
|
|
const handleIn = $('handle-in');
|
|
handleIn.value = localStorage.getItem(LS_HANDLE) || '';
|
|
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; }
|
|
$('pp-status').textContent = 'deriving (~600k iter)…';
|
|
try {
|
|
groupKey = await deriveGroupKey(p);
|
|
$('pp-status').textContent = 'room key ready';
|
|
$('pp-status').className = 'status-line ok';
|
|
logLine('sys', 'room key derived from passphrase');
|
|
updateSendButton();
|
|
/* announce ourselves */
|
|
if (carrierOn) 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;
|
|
$('pub-display').textContent = myPubB64;
|
|
}
|
|
initPubkey();
|
|
|
|
$('pub-display').addEventListener('click', () => {
|
|
navigator.clipboard.writeText(myPubB64 || '').then(() => {
|
|
$('key-status').textContent = 'copied'; $('key-status').className = 'status-line ok';
|
|
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;
|
|
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;
|
|
try {
|
|
const sk = await deriveShared(myPriv, peerB64);
|
|
const sidBytes = new Uint8Array(await crypto.subtle.digest('SHA-256', unb64(peerB64)));
|
|
const sid = hex(sidBytes.slice(0, 4));
|
|
const entry = peers.get(sid) || {};
|
|
entry.sharedKey = sk;
|
|
entry.handle = entry.handle || ('peer-' + sid.slice(0,4));
|
|
peers.set(sid, entry);
|
|
renderPeerList();
|
|
$('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 *
|
|
* ============================================================== */
|
|
$('btn-audio').addEventListener('click', async () => {
|
|
if (carrierOn) return;
|
|
try {
|
|
await startCarrier();
|
|
$('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');
|
|
} catch (e) {
|
|
logLine('err', 'carrier failed: ' + e.message);
|
|
}
|
|
});
|
|
|
|
/* ============================================================== *
|
|
* UI: handshake *
|
|
* ============================================================== */
|
|
$('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 = 'benchmark 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';
|
|
/* also announce ourselves */
|
|
await announceHello();
|
|
} catch (e) {
|
|
$('hs-status').textContent = 'tx failed: ' + e.message;
|
|
}
|
|
});
|
|
|
|
/* ============================================================== *
|
|
* UI: rx connect *
|
|
* ============================================================== */
|
|
$('btn-rx-connect').addEventListener('click', () => {
|
|
if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; }
|
|
rxConnect(true);
|
|
});
|
|
|
|
/* ============================================================== *
|
|
* UI: chat send *
|
|
* ============================================================== */
|
|
function updateSendButton() {
|
|
const mode = currentMode();
|
|
const haveKey = mode === 'passphrase' ? !!groupKey :
|
|
Array.from(peers.values()).some(p => p.sharedKey);
|
|
$('btn-send').disabled = !(carrierOn && haveKey);
|
|
}
|
|
|
|
async function sendCurrentMsg() {
|
|
const msg = $('msg-in').value;
|
|
if (!msg) return;
|
|
if (!carrierOn) { logLine('err', 'start carrier first'); return; }
|
|
const mode = currentMode();
|
|
const baud = negotiatedBaud || myMaxBaud || ZEBRA_BAUD_DEFAULT;
|
|
try {
|
|
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);
|
|
} else {
|
|
/* pubkey: send one frame per peer, encrypted to that peer */
|
|
let sent = 0;
|
|
for (const [sid, p] of peers) {
|
|
if (!p.sharedKey) continue;
|
|
const ct = await aesEncrypt(p.sharedKey, msg);
|
|
const frame = await buildDataFrame(ct);
|
|
await txFrame(frame, baud);
|
|
sent++;
|
|
}
|
|
if (sent === 0) { logLine('err', 'no pubkey peers ready'); return; }
|
|
}
|
|
const handle = handleIn.value.trim() || 'me';
|
|
logLine('me', `<span class="from">${escapeHtml(handle)}:</span> ${escapeHtml(msg)}`);
|
|
$('msg-in').value = '';
|
|
} catch (e) {
|
|
logLine('err', 'send failed: ' + e.message);
|
|
}
|
|
}
|
|
|
|
$('btn-send').addEventListener('click', sendCurrentMsg);
|
|
$('msg-in').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendCurrentMsg(); }
|
|
});
|
|
|
|
/* ============================================================== *
|
|
* boot *
|
|
* ============================================================== */
|
|
showModePanel();
|
|
updateSendButton();
|
|
|
|
setInterval(updateSendButton, 1000);
|
|
|
|
/* try to find the introspector; quiet failure */
|
|
rxConnect(false);
|
|
|
|
if (LOOPBACK) {
|
|
const banner = document.createElement('div');
|
|
banner.style.cssText =
|
|
'background:#fc0;color:#000;padding:0.6rem 1rem;text-align:center;' +
|
|
'font-size:0.8rem;font-weight:bold;border:1px solid #000;' +
|
|
'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.';
|
|
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.');
|
|
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|