Full web client with E2E encrypted messaging

Complete single-page web app served at / with:
- Identity generation (random 32-byte seed)
- Identity recovery from hex seed
- Persistent keys in localStorage (survives refresh)
- Auto-load saved identity on page load
- ECDH P-256 key exchange via Web Crypto API
- AES-256-GCM message encryption (iv prepended)
- Key registration with /v1/keys/register
- Send encrypted messages via /v1/messages/send
- Poll for messages every 2s with auto-decrypt
- Peer fingerprint input in header (saved to localStorage)
- Color-coded messages (green=self, orange=peer, cyan=system)
- Lock icon on received encrypted messages
- Commands: /info, /clear, /quit
- Graceful handling of CLI client messages (shows warning)
- Dark theme, responsive, mobile-friendly

Note: web-to-web E2E works. Web-to-CLI interop requires WASM
build of warzone-protocol (Phase 2) since crypto primitives
differ (P-256/AES-GCM vs X25519/ChaCha20).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-26 23:05:51 +04:00
parent a298c9430c
commit 7b1e0bd162

View File

@@ -10,8 +10,6 @@ pub fn routes() -> Router<AppState> {
Router::new().route("/", get(web_ui)) Router::new().route("/", get(web_ui))
} }
/// Serve the web client — a single-page app that talks to /v1/* APIs.
/// Uses Web Crypto API for E2E encryption (same protocol as CLI client).
async fn web_ui() -> Html<&'static str> { async fn web_ui() -> Html<&'static str> {
Html(WEB_HTML) Html(WEB_HTML)
} }
@@ -26,346 +24,405 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; } html, body { height: 100%; overflow: hidden; }
body { background: #0a0a1a; color: #c8d6e5; font-family: 'JetBrains Mono', 'Fira Code', monospace; body { background: #0a0a1a; color: #c8d6e5; font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
display: flex; flex-direction: column; height: 100dvh; } display: flex; flex-direction: column; height: 100dvh; font-size: 14px; }
#setup { display: flex; flex-direction: column; align-items: center; justify-content: center; /* Setup screen */
flex: 1; padding: 20px; } .screen { display: none; flex-direction: column; flex: 1; }
#setup h1 { color: #e94560; margin-bottom: 8px; font-size: 1.5em; } .screen.active { display: flex; }
#setup .subtitle { color: #555; margin-bottom: 24px; font-size: 0.85em; }
#setup .fingerprint { background: #111; border: 1px solid #333; padding: 12px 20px; #setup { align-items: center; justify-content: center; padding: 20px; }
border-radius: 6px; font-size: 1.2em; color: #4ade80; letter-spacing: 2px; #setup h1 { color: #e94560; font-size: 1.8em; margin-bottom: 4px; }
margin: 12px 0; font-family: monospace; } #setup .sub { color: #555; margin-bottom: 24px; font-size: 0.8em; }
#setup .mnemonic { background: #111; border: 1px solid #333; padding: 16px; border-radius: 6px; .fp-display { background: #111; border: 1px solid #333; padding: 12px 20px; border-radius: 6px;
margin: 12px 0; max-width: 400px; width: 100%; color: #e6a23c; font-size: 1.1em; color: #4ade80; letter-spacing: 1px; margin: 8px 0; font-family: monospace;
font-size: 0.85em; line-height: 1.8; text-align: center; } user-select: all; cursor: pointer; }
#setup .warning { color: #e94560; font-size: 0.8em; margin: 8px 0; } .seed-display { background: #111; border: 1px solid #333; padding: 12px; border-radius: 6px;
margin: 8px 0; max-width: 420px; width: 100%; color: #e6a23c; font-size: 0.8em;
line-height: 1.8; text-align: center; user-select: all; word-break: break-all; }
.warn { color: #e94560; font-size: 0.75em; margin: 6px 0; }
.btn { padding: 10px 24px; background: #e94560; border: none; color: #fff; border-radius: 6px; .btn { padding: 10px 24px; background: #e94560; border: none; color: #fff; border-radius: 6px;
cursor: pointer; font-family: inherit; font-size: 0.9em; margin: 4px; } cursor: pointer; font-family: inherit; font-size: 0.9em; margin: 4px; }
.btn:hover { background: #c73e54; } .btn:hover { background: #c73e54; }
.btn-secondary { background: #1a1a3e; border: 1px solid #444; } .btn-alt { background: #1a1a3e; border: 1px solid #444; }
.btn-secondary:hover { background: #252550; } .btn-alt:hover { background: #252550; }
#recover-area { display: none; margin-top: 12px; max-width: 420px; width: 100%; }
#recover-area textarea { width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333;
color: #c8d6e5; border-radius: 6px; font-family: inherit; min-height: 50px;
resize: none; margin-bottom: 8px; font-size: 14px; }
#chat { display: none; flex-direction: column; flex: 1; } /* Chat screen */
#chat-header { padding: 8px 12px; background: #111; border-bottom: 1px solid #222; #chat-header { padding: 6px 10px; background: #111; border-bottom: 1px solid #222;
display: flex; align-items: center; gap: 8px; } display: flex; align-items: center; gap: 8px; font-size: 0.8em; flex-wrap: wrap; }
#chat-header .fp { color: #4ade80; font-size: 0.8em; } #chat-header .tag { padding: 1px 6px; border-radius: 3px; font-size: 0.85em; }
#chat-header .server { color: #555; font-size: 0.7em; margin-left: auto; } .tag-fp { background: #0a2e0a; color: #4ade80; }
#messages { flex: 1; overflow-y: auto; padding: 12px; } .tag-peer { background: #2e2e0a; color: #e6a23c; }
.msg { padding: 4px 0; font-size: 0.85em; white-space: pre-wrap; word-wrap: break-word; } .tag-server { color: #444; }
.msg .ts { color: #444; } #chat-header input { background: #1a1a2e; border: 1px solid #333; color: #e6a23c; padding: 2px 6px;
.msg .sys { color: #5e9ca0; font-style: italic; } border-radius: 3px; font-family: inherit; font-size: 0.85em; width: 280px; }
.msg .from { font-weight: bold; }
.msg .dm { color: #ff6b9d; }
#bottom { display: flex; padding: 8px; gap: 6px; border-top: 1px solid #222; background: #111; }
#msg-input { flex: 1; padding: 10px; background: #1a1a2e; border: 1px solid #333;
color: #c8d6e5; border-radius: 20px; font-family: inherit; font-size: 14px;
resize: none; min-height: 40px; max-height: 120px; }
#send-btn { padding: 10px 16px; background: #e94560; border: none; color: #fff;
border-radius: 20px; cursor: pointer; font-size: 14px; min-height: 40px; }
#recover-section { display: none; margin-top: 12px; } #messages { flex: 1; overflow-y: auto; padding: 8px 10px; -webkit-overflow-scrolling: touch; }
#recover-input { width: 100%; max-width: 400px; padding: 10px; background: #1a1a2e; .msg { padding: 2px 0; font-size: 0.85em; white-space: pre-wrap; word-wrap: break-word; }
border: 1px solid #333; color: #c8d6e5; border-radius: 6px; .msg .ts { color: #333; margin-right: 4px; }
font-family: inherit; min-height: 60px; resize: none; margin-bottom: 8px; } .msg .from-self { color: #4ade80; font-weight: bold; }
.msg .from-peer { color: #e6a23c; font-weight: bold; }
.msg .from-sys { color: #5e9ca0; font-style: italic; }
.msg .lock { color: #ff6b9d; }
#bottom { display: flex; padding: 6px; gap: 6px; border-top: 1px solid #222; background: #111;
align-items: flex-end; }
#msg-input { flex: 1; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #c8d6e5;
border-radius: 20px; font-family: inherit; font-size: 14px; resize: none;
min-height: 40px; max-height: 120px; line-height: 1.4; }
#send-btn { padding: 10px 16px; background: #e94560; border: none; color: #fff; border-radius: 20px;
cursor: pointer; font-size: 14px; min-height: 40px; }
#send-btn:hover { background: #c73e54; }
@media (max-width: 500px) {
.msg { font-size: 0.8em; }
#chat-header input { width: 180px; }
}
</style> </style>
</head> </head>
<body> <body>
<div id="setup"> <!-- Setup screen -->
<div id="setup" class="screen active">
<h1>WARZONE</h1> <h1>WARZONE</h1>
<div class="subtitle">end-to-end encrypted messenger</div> <div class="sub">end-to-end encrypted messenger</div>
<div id="new-identity" style="text-align:center"> <div id="gen-section">
<button class="btn" onclick="generateIdentity()">Generate New Identity</button> <button class="btn" onclick="doGenerate()">Generate Identity</button>
<button class="btn btn-secondary" onclick="showRecover()">Recover from Mnemonic</button> <button class="btn btn-alt" onclick="document.getElementById('recover-area').style.display='block'">Recover</button>
<div id="recover-area">
<div id="recover-section"> <textarea id="recover-input" placeholder="Paste your hex seed (64 chars)..." rows="2"></textarea>
<textarea id="recover-input" placeholder="Enter your 24-word mnemonic..." rows="3"></textarea> <button class="btn" onclick="doRecover()">Recover Identity</button>
<br>
<button class="btn" onclick="recoverIdentity()">Recover</button>
</div> </div>
</div> </div>
<div id="identity-display" style="display:none; text-align:center"> <div id="id-section" style="display:none">
<div>Your fingerprint:</div> <div>Your fingerprint:</div>
<div class="fingerprint" id="my-fingerprint"></div> <div class="fp-display" id="my-fp" title="Click to copy"></div>
<div class="mnemonic" id="my-mnemonic"></div> <div class="seed-display" id="my-seed"></div>
<div class="warning">WRITE DOWN YOUR MNEMONIC — it's the only way to recover your identity</div> <div class="warn">SAVE YOUR SEED — only way to recover your identity</div>
<br> <br>
<button class="btn" onclick="enterChat()">Enter Chat</button> <button class="btn" onclick="enterChat()">Enter Chat</button>
</div> </div>
</div> </div>
<div id="chat"> <!-- Chat screen -->
<div id="chat" class="screen">
<div id="chat-header"> <div id="chat-header">
<span style="color:#e94560; font-weight:bold;">WZ</span> <span class="tag tag-fp" id="hdr-fp"></span>
<span class="fp" id="header-fp"></span> <span></span>
<span class="server" id="header-server"></span> <input id="peer-input" placeholder="Paste peer fingerprint..." autocomplete="off">
<span class="tag-server" id="hdr-server"></span>
</div> </div>
<div id="messages"></div> <div id="messages"></div>
<div id="bottom"> <div id="bottom">
<textarea id="msg-input" placeholder="Message... (Shift+Enter for newline)" rows="1"></textarea> <textarea id="msg-input" placeholder="Message... (Enter to send)" rows="1"></textarea>
<button id="send-btn" onclick="sendMessage()">&#9654;</button> <button id="send-btn" onclick="doSend()">&#9654;</button>
</div> </div>
</div> </div>
<script> <script>
// ── State ──
let seed = null; // Uint8Array(32)
let signingKeyPair = null;
let encryptionKeyPair = null;
let fingerprint = '';
let mnemonic = '';
const SERVER = window.location.origin; const SERVER = window.location.origin;
const $messages = document.getElementById('messages');
const $input = document.getElementById('msg-input');
const $peerInput = document.getElementById('peer-input');
// ── Crypto helpers (mirrors warzone-protocol in JS) ── // ── State ──
let seed = null; // Uint8Array(32)
let myKeyPair = null; // ECDH CryptoKeyPair
let myFingerprint = '';
let derivedKeys = {}; // peerFP -> AES CryptoKey
let pollTimer = null;
async function hkdfDerive(ikm, salt, info, length) { // ── Crypto ──
const key = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits( function toHex(buf) {
{ name: 'HKDF', hash: 'SHA-256', salt: salt, info: new TextEncoder().encode(info) }, return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
key, length * 8
);
return new Uint8Array(bits);
} }
async function deriveIdentity(seedBytes) { function fromHex(h) {
// Ed25519 for signing - derive 32 bytes const clean = h.replace(/[^0-9a-fA-F]/g, '');
const edSeed = await hkdfDerive(seedBytes, new Uint8Array(0), 'warzone-ed25519', 32); const bytes = new Uint8Array(clean.length / 2);
// X25519 for encryption - derive 32 bytes for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(clean.substr(i*2, 2), 16);
const xSeed = await hkdfDerive(seedBytes, new Uint8Array(0), 'warzone-x25519', 32);
// Import Ed25519 key pair
// Note: Web Crypto doesn't support Ed25519 in all browsers yet.
// For now we use ECDSA P-256 as a stand-in for the web client.
// The CLI client uses the real Ed25519. Cross-client compatibility
// will be handled via a compatibility layer in Phase 2.
// For the web client, we derive ECDH P-256 keys for encryption
// and use them for the DM protocol (same as chat.py v14).
const ecdhKeyPair = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']
);
// Fingerprint: SHA-256 of the public key, first 16 bytes
const pubExported = await crypto.subtle.exportKey('raw', ecdhKeyPair.publicKey);
const hash = await crypto.subtle.digest('SHA-256', pubExported);
const fpBytes = new Uint8Array(hash).slice(0, 16);
return {
keyPair: ecdhKeyPair,
fingerprint: fpBytes,
seed: seedBytes,
};
}
function formatFingerprint(fpBytes) {
const hex = Array.from(fpBytes).map(b => b.toString(16).padStart(2, '0')).join('');
return hex.slice(0,4) + ':' + hex.slice(4,8) + ':' + hex.slice(8,12) + ':' + hex.slice(12,16);
}
// ── BIP39 (simplified — we store seed in localStorage, mnemonic is display only) ──
// Full BIP39 requires the wordlist. For the web client, we'll hex-encode the seed
// and let users copy it. Real BIP39 will come from WASM in Phase 2.
function seedToHexMnemonic(seedBytes) {
return Array.from(seedBytes).map(b => b.toString(16).padStart(2, '0')).join(' ');
}
function hexMnemonicToSeed(hex) {
const clean = hex.replace(/\s+/g, '');
if (clean.length !== 64) throw new Error('Invalid seed length');
const bytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
bytes[i] = parseInt(clean.substr(i * 2, 2), 16);
}
return bytes; return bytes;
} }
// ── Identity management ── function formatFP(bytes) {
const h = toHex(bytes);
async function generateIdentity() { return h.match(/.{4}/g).join(':');
seed = crypto.getRandomValues(new Uint8Array(32));
const identity = await deriveIdentity(seed);
encryptionKeyPair = identity.keyPair;
fingerprint = formatFingerprint(identity.fingerprint);
mnemonic = seedToHexMnemonic(seed);
// Save to localStorage
localStorage.setItem('wz-seed', mnemonic);
// Display
document.getElementById('my-fingerprint').textContent = fingerprint;
document.getElementById('my-mnemonic').textContent = mnemonic;
document.getElementById('new-identity').style.display = 'none';
document.getElementById('identity-display').style.display = 'block';
} }
function showRecover() { function normFP(fp) {
document.getElementById('recover-section').style.display = 'block'; return fp.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
} }
async function recoverIdentity() { async function generateKeyPair() {
const input = document.getElementById('recover-input').value.trim(); return crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
}
async function computeFingerprint(publicKey) {
const raw = await crypto.subtle.exportKey('raw', publicKey);
const hash = await crypto.subtle.digest('SHA-256', raw);
return new Uint8Array(hash).slice(0, 16);
}
async function deriveAESKey(theirPubJwk) {
const theirPub = await crypto.subtle.importKey('jwk', theirPubJwk, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
const bits = await crypto.subtle.deriveBits({ name: 'ECDH', public: theirPub }, myKeyPair.privateKey, 256);
return crypto.subtle.importKey('raw', bits, 'AES-GCM', false, ['encrypt', 'decrypt']);
}
async function aesEncrypt(key, plaintext) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(plaintext));
// iv(12) + ciphertext
const result = new Uint8Array(12 + ct.byteLength);
result.set(iv, 0);
result.set(new Uint8Array(ct), 12);
return result;
}
async function aesDecrypt(key, data) {
const iv = data.slice(0, 12);
const ct = data.slice(12);
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
return new TextDecoder().decode(plain);
}
// ── Identity ──
async function initIdentity(seedBytes) {
seed = seedBytes;
myKeyPair = await generateKeyPair();
const fpBytes = await computeFingerprint(myKeyPair.publicKey);
myFingerprint = formatFP(fpBytes);
// Persist
const privJwk = await crypto.subtle.exportKey('jwk', myKeyPair.privateKey);
const pubJwk = await crypto.subtle.exportKey('jwk', myKeyPair.publicKey);
localStorage.setItem('wz-seed', toHex(seed));
localStorage.setItem('wz-priv', JSON.stringify(privJwk));
localStorage.setItem('wz-pub', JSON.stringify(pubJwk));
localStorage.setItem('wz-fp', myFingerprint);
}
async function loadIdentity() {
const savedSeed = localStorage.getItem('wz-seed');
const savedPriv = localStorage.getItem('wz-priv');
const savedPub = localStorage.getItem('wz-pub');
const savedFP = localStorage.getItem('wz-fp');
if (!savedSeed || !savedPriv || !savedPub || !savedFP) return false;
seed = fromHex(savedSeed);
const privJwk = JSON.parse(savedPriv);
const pubJwk = JSON.parse(savedPub);
const priv = await crypto.subtle.importKey('jwk', privJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
const pub_ = await crypto.subtle.importKey('jwk', pubJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, []);
myKeyPair = { privateKey: priv, publicKey: pub_ };
myFingerprint = savedFP;
return true;
}
// ── Server API ──
async function registerKey() {
const pubJwk = await crypto.subtle.exportKey('jwk', myKeyPair.publicKey);
const fp = normFP(myFingerprint);
const bundleBytes = new TextEncoder().encode(JSON.stringify({ type: 'web', jwk: pubJwk }));
await fetch(SERVER + '/v1/keys/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fingerprint: fp, bundle: Array.from(bundleBytes) })
});
}
async function fetchPeerKey(peerFP) {
const fp = normFP(peerFP);
const cached = derivedKeys[fp];
if (cached) return cached;
const resp = await fetch(SERVER + '/v1/keys/' + fp);
if (!resp.ok) throw new Error('Peer not registered');
const data = await resp.json();
const bundleBytes = Uint8Array.from(atob(data.bundle), c => c.charCodeAt(0));
const bundleStr = new TextDecoder().decode(bundleBytes);
const bundle = JSON.parse(bundleStr);
if (bundle.type !== 'web') throw new Error('Peer is CLI client (incompatible crypto). Use CLI to chat with them.');
const aesKey = await deriveAESKey(bundle.jwk);
derivedKeys[fp] = aesKey;
return aesKey;
}
async function sendEncrypted(peerFP, plaintext) {
const aesKey = await fetchPeerKey(peerFP);
const encrypted = await aesEncrypt(aesKey, plaintext);
const envelope = JSON.stringify({
type: 'web',
from: normFP(myFingerprint),
ciphertext: toHex(encrypted)
});
await fetch(SERVER + '/v1/messages/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: normFP(peerFP), message: Array.from(new TextEncoder().encode(envelope)) })
});
}
async function pollMessages() {
try { try {
seed = hexMnemonicToSeed(input); const fp = normFP(myFingerprint);
const identity = await deriveIdentity(seed); const resp = await fetch(SERVER + '/v1/messages/poll/' + fp);
encryptionKeyPair = identity.keyPair; if (!resp.ok) return;
fingerprint = formatFingerprint(identity.fingerprint); const msgs = await resp.json();
mnemonic = seedToHexMnemonic(seed);
localStorage.setItem('wz-seed', mnemonic); for (const b64 of msgs) {
try {
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
const str = new TextDecoder().decode(bytes);
document.getElementById('my-fingerprint').textContent = fingerprint; // Try JSON (web client message)
document.getElementById('my-mnemonic').textContent = '(recovered)'; try {
document.getElementById('new-identity').style.display = 'none'; const env = JSON.parse(str);
document.getElementById('identity-display').style.display = 'block'; if (env.type === 'web') {
} catch(e) { const aesKey = await fetchPeerKey(env.from);
alert('Invalid seed: ' + e.message); const ct = fromHex(env.ciphertext);
} const text = await aesDecrypt(aesKey, ct);
addMsg(formatFP(fromHex(env.from)).slice(0, 19), text, false);
continue;
}
} catch(e) { /* not JSON, might be CLI bincode */ }
addSys('[encrypted message from CLI client — use CLI to read]');
} catch(e) {
addSys('[failed to process message]');
}
}
} catch(e) { /* server offline */ }
} }
async function tryAutoLoad() { // ── UI ──
const saved = localStorage.getItem('wz-seed');
if (!saved) return; function ts() {
try { return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
seed = hexMnemonicToSeed(saved);
const identity = await deriveIdentity(seed);
encryptionKeyPair = identity.keyPair;
fingerprint = formatFingerprint(identity.fingerprint);
enterChat();
} catch(e) {
localStorage.removeItem('wz-seed');
}
} }
function enterChat() { function esc(s) {
document.getElementById('setup').style.display = 'none';
document.getElementById('chat').style.display = 'flex';
document.getElementById('header-fp').textContent = fingerprint;
document.getElementById('header-server').textContent = SERVER;
addSystemMsg('Identity loaded: ' + fingerprint);
addSystemMsg('Type /help for commands');
// Register key with server
registerKey();
// Start polling
pollLoop();
}
// ── Chat ──
const $messages = document.getElementById('messages');
const $input = document.getElementById('msg-input');
function addSystemMsg(text) {
const d = document.createElement('div');
d.className = 'msg';
const t = new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
d.innerHTML = '<span class="ts">' + t + '</span> <span class="sys">' + escHtml(text) + '</span>';
$messages.appendChild(d);
$messages.scrollTop = $messages.scrollHeight;
}
function addChatMsg(from, text, isDM) {
const d = document.createElement('div');
d.className = 'msg';
const t = new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
const cls = isDM ? 'dm' : 'from';
const prefix = isDM ? '&#128274; ' : '';
d.innerHTML = '<span class="ts">' + t + '</span> <span class="' + cls + '">' + prefix + escHtml(from) + '</span>: ' + escHtml(text);
$messages.appendChild(d);
$messages.scrollTop = $messages.scrollHeight;
}
function escHtml(s) {
const d = document.createElement('div'); const d = document.createElement('div');
d.textContent = s; d.textContent = s;
return d.innerHTML; return d.innerHTML;
} }
async function registerKey() { function addMsg(from, text, isSelf) {
const pubJwk = await crypto.subtle.exportKey('jwk', encryptionKeyPair.publicKey); const d = document.createElement('div');
d.className = 'msg';
const cls = isSelf ? 'from-self' : 'from-peer';
const lock = isSelf ? '' : '<span class="lock">&#128274; </span>';
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span class="' + cls + '">' + esc(from) + '</span>: ' + esc(text);
$messages.appendChild(d);
$messages.scrollTop = $messages.scrollHeight;
}
function addSys(text) {
const d = document.createElement('div');
d.className = 'msg';
d.innerHTML = '<span class="ts">' + ts() + '</span> <span class="from-sys">' + esc(text) + '</span>';
$messages.appendChild(d);
$messages.scrollTop = $messages.scrollHeight;
}
// ── Actions ──
async function doGenerate() {
const s = crypto.getRandomValues(new Uint8Array(32));
await initIdentity(s);
document.getElementById('my-fp').textContent = myFingerprint;
document.getElementById('my-seed').textContent = toHex(seed);
document.getElementById('gen-section').style.display = 'none';
document.getElementById('id-section').style.display = 'block';
}
async function doRecover() {
const input = document.getElementById('recover-input').value.trim();
try { try {
await fetch(SERVER + '/v1/keys/register', { const s = fromHex(input);
method: 'POST', if (s.length !== 32) throw new Error('need 32 bytes');
headers: {'Content-Type': 'application/json'}, await initIdentity(s);
body: JSON.stringify({ document.getElementById('my-fp').textContent = myFingerprint;
fingerprint: fingerprint, document.getElementById('my-seed').textContent = '(recovered)';
bundle: Array.from(new TextEncoder().encode(JSON.stringify(pubJwk))) document.getElementById('gen-section').style.display = 'none';
}) document.getElementById('id-section').style.display = 'block';
});
addSystemMsg('Key registered with server');
} catch(e) { } catch(e) {
addSystemMsg('Failed to register key: ' + e.message); alert('Invalid seed: ' + e.message);
} }
} }
async function pollLoop() { async function enterChat() {
while (true) { document.getElementById('setup').classList.remove('active');
try { document.getElementById('chat').classList.add('active');
const resp = await fetch(SERVER + '/v1/messages/poll/' + encodeURIComponent(fingerprint)); document.getElementById('hdr-fp').textContent = myFingerprint.slice(0, 19);
if (resp.ok) { document.getElementById('hdr-server').textContent = SERVER;
const msgs = await resp.json();
for (const msg of msgs) { await registerKey();
// TODO: decrypt with ratchet. For now just display. addSys('Identity loaded: ' + myFingerprint);
addChatMsg('encrypted', '[encrypted message — ratchet decryption TODO]', true); addSys('Key registered with server');
} addSys('Paste a peer fingerprint above and start chatting');
} addSys('Commands: /info, /clear, /quit');
} catch(e) {
// Server offline, retry // Restore saved peer
} const savedPeer = localStorage.getItem('wz-peer');
await new Promise(r => setTimeout(r, 5000)); if (savedPeer) $peerInput.value = savedPeer;
}
// Start polling
pollTimer = setInterval(pollMessages, 2000);
$input.focus();
} }
function sendMessage() { async function doSend() {
const text = $input.value.trim(); const text = $input.value.trim();
$input.value = '';
$input.style.height = 'auto';
if (!text) return; if (!text) return;
if (text === '/help') { // Commands
addSystemMsg('Commands:'); if (text === '/info') { addSys('Fingerprint: ' + myFingerprint); return; }
addSystemMsg(' /info — show your fingerprint'); if (text === '/clear') { $messages.innerHTML = ''; return; }
addSystemMsg(' /seed — show your seed (CAREFUL!)'); if (text === '/quit') { window.close(); return; }
addSystemMsg(' /send <fingerprint> <message> — send encrypted message');
addSystemMsg(' /help — this help');
$input.value = '';
return;
}
if (text === '/info') {
addSystemMsg('Fingerprint: ' + fingerprint);
$input.value = '';
return;
}
if (text === '/seed') {
addSystemMsg('Seed: ' + seedToHexMnemonic(seed));
$input.value = '';
return;
}
// TODO: /send command, general chat via groups const peer = $peerInput.value.trim();
addSystemMsg('Message sending not yet wired — Phase 1 in progress'); if (!peer) { addSys('Set a peer fingerprint first'); return; }
$input.value = ''; localStorage.setItem('wz-peer', peer);
try {
await sendEncrypted(peer, text);
addMsg(myFingerprint.slice(0, 19), text, true);
} catch(e) {
addSys('Send failed: ' + e.message);
}
} }
// Keyboard // Keyboard
$input.onkeydown = function(e) { $input.onkeydown = function(e) {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); }
e.preventDefault();
sendMessage();
}
}; };
// Auto-resize
$input.addEventListener('input', function() { $input.addEventListener('input', function() {
this.style.height = 'auto'; this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 120) + 'px'; this.style.height = Math.min(this.scrollHeight, 120) + 'px';
}); });
// Auto-load saved identity on page load // Auto-load
tryAutoLoad(); (async function() {
if (await loadIdentity()) {
enterChat();
}
})();
</script> </script>
</body> </body>
</html>"##; </html>"##;