489 lines
16 KiB
HTML
489 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 kernel</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
body {
|
|
font-family: monospace;
|
|
background: #fff;
|
|
color: #000;
|
|
padding: 2rem;
|
|
max-width: 640px;
|
|
margin: 0 auto;
|
|
}
|
|
@media (max-width: 600px) {
|
|
body { padding: 1.25rem; }
|
|
h1 { font-size: 2.2rem; }
|
|
}
|
|
|
|
h1 {
|
|
font-family: 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: 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.3s;
|
|
}
|
|
.dot.on { background: #000; }
|
|
|
|
label, .note { font-size: 0.75rem; color: #555; }
|
|
.note { margin-top: 0.4rem; line-height: 1.5; }
|
|
|
|
pre {
|
|
border: 1px solid #ccc;
|
|
padding: 0.6rem 0.9rem;
|
|
font-size: 0.78rem;
|
|
overflow-x: auto;
|
|
line-height: 1.5;
|
|
margin-bottom: 0.5rem;
|
|
background: #fafafa;
|
|
}
|
|
|
|
textarea {
|
|
width: 100%;
|
|
font-family: monospace;
|
|
font-size: 0.78rem;
|
|
border: 1px solid #ccc;
|
|
padding: 0.5rem;
|
|
resize: vertical;
|
|
background: #fafafa;
|
|
color: #000;
|
|
line-height: 1.5;
|
|
}
|
|
textarea:focus { outline: 1px solid #000; border-color: #000; }
|
|
textarea[readonly] { background: #f5f5f5; color: #333; }
|
|
|
|
input[type="text"] {
|
|
font-family: monospace;
|
|
font-size: 0.78rem;
|
|
border: 1px solid #ccc;
|
|
padding: 0.4rem 0.6rem;
|
|
width: 100%;
|
|
background: #fafafa;
|
|
color: #000;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
input:focus { outline: 1px solid #000; border-color: #000; }
|
|
|
|
.field-row { display: flex; gap: 0.5rem; align-items: flex-start; margin-bottom: 0.5rem; }
|
|
.field-row textarea { flex: 1; }
|
|
.field-row button { flex-shrink: 0; white-space: nowrap; }
|
|
|
|
.key-display {
|
|
font-family: monospace;
|
|
font-size: 0.7rem;
|
|
word-break: break-all;
|
|
background: #fafafa;
|
|
border: 1px solid #ccc;
|
|
padding: 0.5rem;
|
|
color: #333;
|
|
margin-bottom: 0.5rem;
|
|
cursor: pointer;
|
|
user-select: all;
|
|
}
|
|
.key-display:hover { border-color: #000; }
|
|
|
|
.status-line {
|
|
font-size: 0.75rem;
|
|
color: #555;
|
|
margin-top: 0.3rem;
|
|
min-height: 1.1em;
|
|
}
|
|
.status-line.ok { color: #000; font-weight: bold; }
|
|
.status-line.err { color: #c00; }
|
|
|
|
video { display: none; }
|
|
hr { border: none; border-top: 1px solid #e0e0e0; margin: 2rem 0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<video id="v" autoplay loop muted></video>
|
|
|
|
<h1>zebra kernel</h1>
|
|
<p class="sub">volume modem · stereo carrier · e2e encrypted</p>
|
|
|
|
<!-- ============================================================ -->
|
|
<!-- CARRIER -->
|
|
<!-- ============================================================ -->
|
|
<section>
|
|
<h2>carrier</h2>
|
|
<div class="row">
|
|
<div class="dot" id="dot"></div>
|
|
<button id="btn-audio">start audio</button>
|
|
<label id="audio-status">offline</label>
|
|
</div>
|
|
<p class="note">
|
|
Starts a stereo audio stream — registers this tab as a named sink input
|
|
in PulseAudio (pavucontrol, pactl) with channels=2. The C clients
|
|
modulate L and R volume independently to transmit data.
|
|
</p>
|
|
</section>
|
|
|
|
<!-- ============================================================ -->
|
|
<!-- KEYS -->
|
|
<!-- ============================================================ -->
|
|
<section>
|
|
<h2>keys</h2>
|
|
|
|
<p class="note" style="margin-bottom:0.75rem">
|
|
ECDH P-256 key pair. Generated once, stored in localStorage.
|
|
Share your public key with the other side. Paste theirs below.
|
|
Both sides derive the same AES-256-GCM key — no key ever leaves the browser.
|
|
</p>
|
|
|
|
<p style="font-size:0.75rem;margin-bottom:0.3rem;color:#555">your public key (click to copy)</p>
|
|
<div class="key-display" id="pub-display" title="click to copy">generating...</div>
|
|
|
|
<div class="row" style="margin-bottom:0.75rem">
|
|
<button id="btn-regen">regenerate keys</button>
|
|
<span class="status-line" id="key-status"></span>
|
|
</div>
|
|
|
|
<p style="font-size:0.75rem;margin-bottom:0.3rem;color:#555">peer public key (paste here)</p>
|
|
<input type="text" id="peer-key-input" placeholder="paste peer's public key...">
|
|
<div class="row">
|
|
<button id="btn-peer" class="invert">derive shared key</button>
|
|
<span class="status-line" id="peer-status"></span>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================ -->
|
|
<!-- ENCRYPT / DECRYPT -->
|
|
<!-- ============================================================ -->
|
|
<section>
|
|
<h2>encrypt</h2>
|
|
<div class="field-row">
|
|
<textarea id="plaintext-in" rows="3" placeholder="plaintext message..."></textarea>
|
|
<button id="btn-encrypt">encrypt →</button>
|
|
</div>
|
|
<textarea id="ciphertext-out" rows="3" readonly placeholder="ciphertext (base64) appears here..."></textarea>
|
|
<p class="note">Copy ciphertext → pipe through <code>./tx</code> or <code>./bt</code></p>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>decrypt</h2>
|
|
<div class="field-row">
|
|
<textarea id="ciphertext-in" rows="3" placeholder="paste received ciphertext (base64)..."></textarea>
|
|
<button id="btn-decrypt">decrypt →</button>
|
|
</div>
|
|
<textarea id="plaintext-out" rows="3" readonly placeholder="decrypted plaintext appears here..."></textarea>
|
|
<div class="status-line" id="dec-status"></div>
|
|
</section>
|
|
|
|
<hr>
|
|
|
|
<!-- ============================================================ -->
|
|
<!-- SETUP -->
|
|
<!-- ============================================================ -->
|
|
<section>
|
|
<h2>find this tab</h2>
|
|
<pre>./tx -l
|
|
pactl list sink-inputs short</pre>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>battle toads (stereo, 2x bandwidth)</h2>
|
|
<pre><code># auto-negotiate baud, dual-channel stereo
|
|
./rx -s RX_SINK -t TX_SINK # terminal 1: benchmark + send offer
|
|
./tx -s TX_SINK -r RX_SINK # terminal 2: wait for offer + READY
|
|
|
|
# or fixed baud
|
|
./bt -T MY_SINK -R THEIR_SINK -b 500</code></pre>
|
|
<p class="note">
|
|
L and R channels carry independent UART streams simultaneously.
|
|
PA sees channels=2 from this stereo carrier — required for Battle Toads.
|
|
</p>
|
|
</section>
|
|
|
|
<!-- ============================================================ -->
|
|
<!-- JS -->
|
|
<!-- ============================================================ -->
|
|
<script>
|
|
(async () => {
|
|
|
|
/* ------------------------------------------------------------ *
|
|
* audio carrier — stereo (channels=2 for Battle Toads) *
|
|
* ------------------------------------------------------------ */
|
|
const btnAudio = document.getElementById('btn-audio');
|
|
const dot = document.getElementById('dot');
|
|
const audioLabel = document.getElementById('audio-status');
|
|
const video = document.getElementById('v');
|
|
|
|
btnAudio.addEventListener('click', async () => {
|
|
if (btnAudio.disabled) return;
|
|
btnAudio.disabled = true;
|
|
const ctx = new AudioContext();
|
|
const dest = ctx.createMediaStreamDestination();
|
|
const merger = ctx.createChannelMerger(2);
|
|
/* L (440 Hz) and R (441 Hz) oscillators → stereo → PA channels=2 */
|
|
const oscL = ctx.createOscillator();
|
|
const oscR = ctx.createOscillator();
|
|
const gL = ctx.createGain();
|
|
const gR = ctx.createGain();
|
|
oscL.type = 'sine'; oscL.frequency.value = 440; gL.gain.value = 0.08;
|
|
oscR.type = 'sine'; oscR.frequency.value = 441; gR.gain.value = 0.08;
|
|
oscL.connect(gL); gL.connect(merger, 0, 0);
|
|
oscR.connect(gR); gR.connect(merger, 0, 1);
|
|
merger.connect(dest);
|
|
oscL.start(); oscR.start();
|
|
video.srcObject = dest.stream;
|
|
try { await video.play(); } catch (e) { console.error(e); }
|
|
dot.classList.add('on');
|
|
audioLabel.textContent = 'online — stereo (ch=2)';
|
|
});
|
|
|
|
/* ------------------------------------------------------------ *
|
|
* crypto helpers *
|
|
* ------------------------------------------------------------ */
|
|
const LS_PRIV = 'zebra_privkey';
|
|
const LS_PUB = 'zebra_pubkey';
|
|
const LS_PEER = 'zebra_peer_pubkey';
|
|
|
|
function b64(buf) {
|
|
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
|
}
|
|
function unb64(s) {
|
|
return Uint8Array.from(atob(s), c => c.charCodeAt(0));
|
|
}
|
|
|
|
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 privJwk = JSON.parse(privStr);
|
|
const privateKey = await crypto.subtle.importKey(
|
|
'jwk', privJwk,
|
|
{ name: 'ECDH', namedCurve: 'P-256' },
|
|
true, ['deriveKey', 'deriveBits']
|
|
);
|
|
return { privateKey, pubB64: pubStr };
|
|
} catch (_) { /* corrupted — regenerate */ }
|
|
}
|
|
return genKeypair();
|
|
}
|
|
|
|
async function deriveShared(myPrivKey, peerPubB64) {
|
|
const peerRaw = unb64(peerPubB64);
|
|
const peerPub = await crypto.subtle.importKey(
|
|
'raw', peerRaw,
|
|
{ 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 encryptMsg(sharedKey, plaintext) {
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
const enc = await crypto.subtle.encrypt(
|
|
{ name: 'AES-GCM', iv },
|
|
sharedKey,
|
|
new TextEncoder().encode(plaintext)
|
|
);
|
|
const out = new Uint8Array(12 + enc.byteLength);
|
|
out.set(iv); out.set(new Uint8Array(enc), 12);
|
|
return b64(out.buffer);
|
|
}
|
|
|
|
async function decryptMsg(sharedKey, ciphertextB64) {
|
|
const data = unb64(ciphertextB64.trim());
|
|
const iv = data.slice(0, 12);
|
|
const ct = data.slice(12);
|
|
const dec = await crypto.subtle.decrypt(
|
|
{ name: 'AES-GCM', iv },
|
|
sharedKey,
|
|
ct
|
|
);
|
|
return new TextDecoder().decode(dec);
|
|
}
|
|
|
|
/* ------------------------------------------------------------ *
|
|
* key management UI *
|
|
* ------------------------------------------------------------ */
|
|
const pubDisplay = document.getElementById('pub-display');
|
|
const keyStatus = document.getElementById('key-status');
|
|
const peerInput = document.getElementById('peer-key-input');
|
|
const peerStatus = document.getElementById('peer-status');
|
|
const btnRegen = document.getElementById('btn-regen');
|
|
const btnPeer = document.getElementById('btn-peer');
|
|
|
|
let myPrivKey = null;
|
|
let sharedKey = null;
|
|
|
|
function showPub(pubB64) {
|
|
pubDisplay.textContent = pubB64;
|
|
}
|
|
|
|
pubDisplay.addEventListener('click', () => {
|
|
navigator.clipboard.writeText(pubDisplay.textContent).then(() => {
|
|
keyStatus.textContent = 'copied to clipboard';
|
|
keyStatus.className = 'status-line ok';
|
|
setTimeout(() => { keyStatus.textContent = ''; keyStatus.className = 'status-line'; }, 2000);
|
|
});
|
|
});
|
|
|
|
const kp = await loadOrGenKeypair();
|
|
myPrivKey = kp.privateKey;
|
|
showPub(kp.pubB64);
|
|
keyStatus.textContent = 'keys loaded from localStorage';
|
|
keyStatus.className = 'status-line';
|
|
|
|
const savedPeer = localStorage.getItem(LS_PEER);
|
|
if (savedPeer) {
|
|
peerInput.value = savedPeer;
|
|
}
|
|
|
|
btnRegen.addEventListener('click', async () => {
|
|
if (!confirm('Regenerate keys? Any established shared key will be lost.')) return;
|
|
sharedKey = null;
|
|
peerStatus.textContent = '';
|
|
const kp2 = await genKeypair();
|
|
myPrivKey = kp2.privateKey;
|
|
showPub(kp2.pubB64);
|
|
keyStatus.textContent = 'new keys generated';
|
|
keyStatus.className = 'status-line ok';
|
|
setTimeout(() => { keyStatus.className = 'status-line'; }, 3000);
|
|
});
|
|
|
|
btnPeer.addEventListener('click', async () => {
|
|
const peerPub = peerInput.value.trim();
|
|
if (!peerPub) {
|
|
peerStatus.textContent = 'paste peer public key first';
|
|
peerStatus.className = 'status-line err';
|
|
return;
|
|
}
|
|
try {
|
|
sharedKey = await deriveShared(myPrivKey, peerPub);
|
|
localStorage.setItem(LS_PEER, peerPub);
|
|
peerStatus.textContent = 'shared key derived — encryption ready';
|
|
peerStatus.className = 'status-line ok';
|
|
} catch (e) {
|
|
peerStatus.textContent = 'invalid key: ' + e.message;
|
|
peerStatus.className = 'status-line err';
|
|
}
|
|
});
|
|
|
|
if (savedPeer && myPrivKey) {
|
|
try {
|
|
sharedKey = await deriveShared(myPrivKey, savedPeer);
|
|
peerStatus.textContent = 'shared key restored from localStorage';
|
|
peerStatus.className = 'status-line ok';
|
|
} catch (_) {
|
|
localStorage.removeItem(LS_PEER);
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------ *
|
|
* encrypt / decrypt UI *
|
|
* ------------------------------------------------------------ */
|
|
const plaintextIn = document.getElementById('plaintext-in');
|
|
const ciphertextOut = document.getElementById('ciphertext-out');
|
|
const ciphertextIn = document.getElementById('ciphertext-in');
|
|
const plaintextOut = document.getElementById('plaintext-out');
|
|
const decStatus = document.getElementById('dec-status');
|
|
const btnEncrypt = document.getElementById('btn-encrypt');
|
|
const btnDecrypt = document.getElementById('btn-decrypt');
|
|
|
|
btnEncrypt.addEventListener('click', async () => {
|
|
if (!sharedKey) {
|
|
ciphertextOut.value = '[no shared key — derive one first]';
|
|
return;
|
|
}
|
|
const msg = plaintextIn.value;
|
|
if (!msg) return;
|
|
try {
|
|
ciphertextOut.value = await encryptMsg(sharedKey, msg);
|
|
} catch (e) {
|
|
ciphertextOut.value = '[encrypt error: ' + e.message + ']';
|
|
}
|
|
});
|
|
|
|
btnDecrypt.addEventListener('click', async () => {
|
|
if (!sharedKey) {
|
|
decStatus.textContent = 'no shared key — derive one first';
|
|
decStatus.className = 'status-line err';
|
|
return;
|
|
}
|
|
const ct = ciphertextIn.value.trim();
|
|
if (!ct) return;
|
|
try {
|
|
plaintextOut.value = await decryptMsg(sharedKey, ct);
|
|
decStatus.textContent = 'decrypted ok';
|
|
decStatus.className = 'status-line ok';
|
|
} catch (e) {
|
|
plaintextOut.value = '';
|
|
decStatus.textContent = 'decrypt failed — wrong key or corrupted ciphertext';
|
|
decStatus.className = 'status-line err';
|
|
}
|
|
});
|
|
|
|
})();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|