|
|
|
|
@@ -0,0 +1,371 @@
|
|
|
|
|
use axum::{
|
|
|
|
|
response::Html,
|
|
|
|
|
routing::get,
|
|
|
|
|
Router,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
use crate::state::AppState;
|
|
|
|
|
|
|
|
|
|
pub fn routes() -> Router<AppState> {
|
|
|
|
|
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> {
|
|
|
|
|
Html(WEB_HTML)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const WEB_HTML: &str = r##"<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
|
|
|
|
|
<meta name="theme-color" content="#0a0a1a">
|
|
|
|
|
<title>Warzone</title>
|
|
|
|
|
<style>
|
|
|
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
|
html, body { height: 100%; overflow: hidden; }
|
|
|
|
|
body { background: #0a0a1a; color: #c8d6e5; font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
|
|
|
display: flex; flex-direction: column; height: 100dvh; }
|
|
|
|
|
|
|
|
|
|
#setup { display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
|
|
|
flex: 1; padding: 20px; }
|
|
|
|
|
#setup h1 { color: #e94560; margin-bottom: 8px; font-size: 1.5em; }
|
|
|
|
|
#setup .subtitle { color: #555; margin-bottom: 24px; font-size: 0.85em; }
|
|
|
|
|
#setup .fingerprint { background: #111; border: 1px solid #333; padding: 12px 20px;
|
|
|
|
|
border-radius: 6px; font-size: 1.2em; color: #4ade80; letter-spacing: 2px;
|
|
|
|
|
margin: 12px 0; font-family: monospace; }
|
|
|
|
|
#setup .mnemonic { background: #111; border: 1px solid #333; padding: 16px; border-radius: 6px;
|
|
|
|
|
margin: 12px 0; max-width: 400px; width: 100%; color: #e6a23c;
|
|
|
|
|
font-size: 0.85em; line-height: 1.8; text-align: center; }
|
|
|
|
|
#setup .warning { color: #e94560; font-size: 0.8em; margin: 8px 0; }
|
|
|
|
|
.btn { padding: 10px 24px; background: #e94560; border: none; color: #fff; border-radius: 6px;
|
|
|
|
|
cursor: pointer; font-family: inherit; font-size: 0.9em; margin: 4px; }
|
|
|
|
|
.btn:hover { background: #c73e54; }
|
|
|
|
|
.btn-secondary { background: #1a1a3e; border: 1px solid #444; }
|
|
|
|
|
.btn-secondary:hover { background: #252550; }
|
|
|
|
|
|
|
|
|
|
#chat { display: none; flex-direction: column; flex: 1; }
|
|
|
|
|
#chat-header { padding: 8px 12px; background: #111; border-bottom: 1px solid #222;
|
|
|
|
|
display: flex; align-items: center; gap: 8px; }
|
|
|
|
|
#chat-header .fp { color: #4ade80; font-size: 0.8em; }
|
|
|
|
|
#chat-header .server { color: #555; font-size: 0.7em; margin-left: auto; }
|
|
|
|
|
#messages { flex: 1; overflow-y: auto; padding: 12px; }
|
|
|
|
|
.msg { padding: 4px 0; font-size: 0.85em; white-space: pre-wrap; word-wrap: break-word; }
|
|
|
|
|
.msg .ts { color: #444; }
|
|
|
|
|
.msg .sys { color: #5e9ca0; font-style: italic; }
|
|
|
|
|
.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; }
|
|
|
|
|
#recover-input { width: 100%; max-width: 400px; padding: 10px; background: #1a1a2e;
|
|
|
|
|
border: 1px solid #333; color: #c8d6e5; border-radius: 6px;
|
|
|
|
|
font-family: inherit; min-height: 60px; resize: none; margin-bottom: 8px; }
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
|
|
|
|
|
<div id="setup">
|
|
|
|
|
<h1>WARZONE</h1>
|
|
|
|
|
<div class="subtitle">end-to-end encrypted messenger</div>
|
|
|
|
|
|
|
|
|
|
<div id="new-identity" style="text-align:center">
|
|
|
|
|
<button class="btn" onclick="generateIdentity()">Generate New Identity</button>
|
|
|
|
|
<button class="btn btn-secondary" onclick="showRecover()">Recover from Mnemonic</button>
|
|
|
|
|
|
|
|
|
|
<div id="recover-section">
|
|
|
|
|
<textarea id="recover-input" placeholder="Enter your 24-word mnemonic..." rows="3"></textarea>
|
|
|
|
|
<br>
|
|
|
|
|
<button class="btn" onclick="recoverIdentity()">Recover</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div id="identity-display" style="display:none; text-align:center">
|
|
|
|
|
<div>Your fingerprint:</div>
|
|
|
|
|
<div class="fingerprint" id="my-fingerprint"></div>
|
|
|
|
|
<div class="mnemonic" id="my-mnemonic"></div>
|
|
|
|
|
<div class="warning">WRITE DOWN YOUR MNEMONIC — it's the only way to recover your identity</div>
|
|
|
|
|
<br>
|
|
|
|
|
<button class="btn" onclick="enterChat()">Enter Chat</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div id="chat">
|
|
|
|
|
<div id="chat-header">
|
|
|
|
|
<span style="color:#e94560; font-weight:bold;">WZ</span>
|
|
|
|
|
<span class="fp" id="header-fp"></span>
|
|
|
|
|
<span class="server" id="header-server"></span>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="messages"></div>
|
|
|
|
|
<div id="bottom">
|
|
|
|
|
<textarea id="msg-input" placeholder="Message... (Shift+Enter for newline)" rows="1"></textarea>
|
|
|
|
|
<button id="send-btn" onclick="sendMessage()">▶</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
// ── State ──
|
|
|
|
|
let seed = null; // Uint8Array(32)
|
|
|
|
|
let signingKeyPair = null;
|
|
|
|
|
let encryptionKeyPair = null;
|
|
|
|
|
let fingerprint = '';
|
|
|
|
|
let mnemonic = '';
|
|
|
|
|
|
|
|
|
|
const SERVER = window.location.origin;
|
|
|
|
|
|
|
|
|
|
// ── Crypto helpers (mirrors warzone-protocol in JS) ──
|
|
|
|
|
|
|
|
|
|
async function hkdfDerive(ikm, salt, info, length) {
|
|
|
|
|
const key = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
|
|
|
|
|
const bits = await crypto.subtle.deriveBits(
|
|
|
|
|
{ name: 'HKDF', hash: 'SHA-256', salt: salt, info: new TextEncoder().encode(info) },
|
|
|
|
|
key, length * 8
|
|
|
|
|
);
|
|
|
|
|
return new Uint8Array(bits);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deriveIdentity(seedBytes) {
|
|
|
|
|
// Ed25519 for signing - derive 32 bytes
|
|
|
|
|
const edSeed = await hkdfDerive(seedBytes, new Uint8Array(0), 'warzone-ed25519', 32);
|
|
|
|
|
// X25519 for encryption - derive 32 bytes
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Identity management ──
|
|
|
|
|
|
|
|
|
|
async function generateIdentity() {
|
|
|
|
|
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() {
|
|
|
|
|
document.getElementById('recover-section').style.display = 'block';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function recoverIdentity() {
|
|
|
|
|
const input = document.getElementById('recover-input').value.trim();
|
|
|
|
|
try {
|
|
|
|
|
seed = hexMnemonicToSeed(input);
|
|
|
|
|
const identity = await deriveIdentity(seed);
|
|
|
|
|
encryptionKeyPair = identity.keyPair;
|
|
|
|
|
fingerprint = formatFingerprint(identity.fingerprint);
|
|
|
|
|
mnemonic = seedToHexMnemonic(seed);
|
|
|
|
|
|
|
|
|
|
localStorage.setItem('wz-seed', mnemonic);
|
|
|
|
|
|
|
|
|
|
document.getElementById('my-fingerprint').textContent = fingerprint;
|
|
|
|
|
document.getElementById('my-mnemonic').textContent = '(recovered)';
|
|
|
|
|
document.getElementById('new-identity').style.display = 'none';
|
|
|
|
|
document.getElementById('identity-display').style.display = 'block';
|
|
|
|
|
} catch(e) {
|
|
|
|
|
alert('Invalid seed: ' + e.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function tryAutoLoad() {
|
|
|
|
|
const saved = localStorage.getItem('wz-seed');
|
|
|
|
|
if (!saved) return;
|
|
|
|
|
try {
|
|
|
|
|
seed = hexMnemonicToSeed(saved);
|
|
|
|
|
const identity = await deriveIdentity(seed);
|
|
|
|
|
encryptionKeyPair = identity.keyPair;
|
|
|
|
|
fingerprint = formatFingerprint(identity.fingerprint);
|
|
|
|
|
enterChat();
|
|
|
|
|
} catch(e) {
|
|
|
|
|
localStorage.removeItem('wz-seed');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function enterChat() {
|
|
|
|
|
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 ? '🔒 ' : '';
|
|
|
|
|
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');
|
|
|
|
|
d.textContent = s;
|
|
|
|
|
return d.innerHTML;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function registerKey() {
|
|
|
|
|
const pubJwk = await crypto.subtle.exportKey('jwk', encryptionKeyPair.publicKey);
|
|
|
|
|
try {
|
|
|
|
|
await fetch(SERVER + '/v1/keys/register', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {'Content-Type': 'application/json'},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
fingerprint: fingerprint,
|
|
|
|
|
bundle: Array.from(new TextEncoder().encode(JSON.stringify(pubJwk)))
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
addSystemMsg('Key registered with server');
|
|
|
|
|
} catch(e) {
|
|
|
|
|
addSystemMsg('Failed to register key: ' + e.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function pollLoop() {
|
|
|
|
|
while (true) {
|
|
|
|
|
try {
|
|
|
|
|
const resp = await fetch(SERVER + '/v1/messages/poll/' + encodeURIComponent(fingerprint));
|
|
|
|
|
if (resp.ok) {
|
|
|
|
|
const msgs = await resp.json();
|
|
|
|
|
for (const msg of msgs) {
|
|
|
|
|
// TODO: decrypt with ratchet. For now just display.
|
|
|
|
|
addChatMsg('encrypted', '[encrypted message — ratchet decryption TODO]', true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch(e) {
|
|
|
|
|
// Server offline, retry
|
|
|
|
|
}
|
|
|
|
|
await new Promise(r => setTimeout(r, 5000));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sendMessage() {
|
|
|
|
|
const text = $input.value.trim();
|
|
|
|
|
if (!text) return;
|
|
|
|
|
|
|
|
|
|
if (text === '/help') {
|
|
|
|
|
addSystemMsg('Commands:');
|
|
|
|
|
addSystemMsg(' /info — show your fingerprint');
|
|
|
|
|
addSystemMsg(' /seed — show your seed (CAREFUL!)');
|
|
|
|
|
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
|
|
|
|
|
addSystemMsg('Message sending not yet wired — Phase 1 in progress');
|
|
|
|
|
$input.value = '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Keyboard
|
|
|
|
|
$input.onkeydown = function(e) {
|
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
sendMessage();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Auto-resize
|
|
|
|
|
$input.addEventListener('input', function() {
|
|
|
|
|
this.style.height = 'auto';
|
|
|
|
|
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Auto-load saved identity on page load
|
|
|
|
|
tryAutoLoad();
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>"##;
|