From 03d91cb8440c3077f8e131ee81fdf2da37fcc3f1 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Thu, 26 Mar 2026 16:14:28 +0400 Subject: [PATCH] v13: E2E encrypted DMs via ECDH + AES-256-GCM (Web Crypto API) Server: - /keys POST: register ECDH public key (JWK) for a username - /keys GET: list users with registered keys - /keys/ GET: get user's public key - /dm POST: relay encrypted DM blob to recipient - SSE streams now register for DM delivery via name param - Server never sees plaintext - only ciphertext passes through Web UI: - Auto-generates ECDH P-256 key pair on load (no setup needed) - /dm @username message - sends E2E encrypted DM - /users - list users with registered keys - DMs shown with lock icon, pink color, direction arrows - Decryption happens entirely in browser - Key re-registered on name change - Derived AES keys cached per peer Protocol: - ECDH key exchange: each client exports JWK public key - Shared secret derived via ECDH P-256 - Messages encrypted with AES-256-GCM + random 12-byte nonce - Ciphertext + nonce sent as base64 through server Co-Authored-By: Claude Opus 4.6 (1M context) --- chat.py | 225 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 221 insertions(+), 4 deletions(-) diff --git a/chat.py b/chat.py index b06d1fe..63bcb80 100644 --- a/chat.py +++ b/chat.py @@ -25,7 +25,7 @@ import html import urllib.parse PORT = 9999 -VERSION = "12" +VERSION = "13" TUNNEL_TARGETS = { "parspack": ("185.208.174.152", 22), "mequ": ("188.213.68.133", 2022), @@ -36,6 +36,11 @@ MAX_TOTAL_STORAGE = 50 * 1024 * 1024 # 50 MB total # ── Server ────────────────────────────────────────────────────────────── +# E2E encryption: public key registry (username -> JWK public key JSON string) +user_keys: dict[str, str] = {} +# DM routing: username -> list of (group, queue) for SSE, or (group, writer) for TCP +dm_targets: dict[str, list] = {} + uploaded_files: dict[str, bytes] = {} # file_id -> raw bytes (insertion order) total_file_bytes = 0 @@ -129,6 +134,7 @@ CHAT_HTML = r""" .msg a.auto-link { color: #67c7eb; } .ts { color: #666; } .sys { color: #5e9ca0; font-style: italic; } + .dm-hint { color: #555; font-size: 0.8em; display: block; margin-top: 2px; } .file-link { display: inline-block; background: #0f3460; border: 1px solid #444; padding: 6px 12px; border-radius: 4px; margin: 2px 0; color: #67c7eb; text-decoration: none; } @@ -194,7 +200,7 @@ CHAT_HTML = r"""
@@ -305,6 +311,21 @@ function send() { localStorage.setItem('chat-name', name); if (!text) return; // Local commands + const dmMatch = text.match(/^\/dm\s+@?(\S+)\s+([\s\S]+)/); + if (dmMatch) { + encryptAndSendDM(dmMatch[1], dmMatch[2]); + $input.value = ''; + $input.style.height = 'auto'; + return; + } + if (text === '/users' || text === '/online') { + fetch('/keys').then(r => r.json()).then(users => { + addMsg({ts:Date.now()/1000, user:'***', text:'Users with keys: ' + users.join(', ')}); + }); + $input.value = ''; + $input.style.height = 'auto'; + return; + } if (text === '/colors' || text === '/color') { reshuffleColors(); $input.value = ''; @@ -406,7 +427,11 @@ window.addEventListener('focus', function() { let sessionPass = sessionStorage.getItem('pw-' + GROUP) || ''; function startSSE() { - const url = BASE + '/events' + (sessionPass ? '?password=' + encodeURIComponent(sessionPass) : ''); + const myName = $name.value.trim() || 'anon'; + const params = new URLSearchParams(); + if (sessionPass) params.set('password', sessionPass); + params.set('name', myName); + const url = BASE + '/events?' + params.toString(); const es = new EventSource(url); es.onmessage = function(e) { // Check for auth error @@ -416,7 +441,11 @@ function startSSE() { showPasswordPrompt(); return; } - addMsg(data); + if (data.dm && data.encrypted) { + handleEncryptedDM(data); + } else { + addMsg(data); + } notify(data); }; es.onerror = function() { @@ -439,6 +468,110 @@ document.getElementById('pw-input').onkeydown = function(e) { if (e.key === 'Enter') document.getElementById('pw-btn').click(); }; +// ── E2E Encrypted DMs (ECDH + AES-256-GCM via Web Crypto) ── + +let myKeyPair = null; +let myPubJwk = null; +const derivedKeys = {}; // cache: username -> CryptoKey (AES) + +async function initCrypto() { + myKeyPair = await crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits'] + ); + myPubJwk = await crypto.subtle.exportKey('jwk', myKeyPair.publicKey); + // Register our public key with the server + const myName = $name.value.trim() || 'anon'; + fetch('/keys', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: 'name=' + encodeURIComponent(myName) + '&key=' + encodeURIComponent(JSON.stringify(myPubJwk)) + }); +} + +// Re-register key when name changes +$name.addEventListener('change', function() { + localStorage.setItem('chat-name', this.value); + if (myPubJwk) { + fetch('/keys', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: 'name=' + encodeURIComponent(this.value.trim()) + '&key=' + encodeURIComponent(JSON.stringify(myPubJwk)) + }); + } +}); + +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 getAESKey(username) { + if (derivedKeys[username]) return derivedKeys[username]; + const resp = await fetch('/keys/' + encodeURIComponent(username)); + if (!resp.ok) return null; + const jwk = JSON.parse(await resp.text()); + const key = await deriveAESKey(jwk); + derivedKeys[username] = key; + return key; +} + +async function encryptAndSendDM(recipient, plaintext) { + const aesKey = await getAESKey(recipient); + if (!aesKey) { + addMsg({ts: Date.now()/1000, user: '***', text: 'User "' + recipient + '" has no key registered. They must be online.'}); + return; + } + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const enc = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce }, aesKey, new TextEncoder().encode(plaintext) + ); + const myName = $name.value.trim() || 'anon'; + fetch('/dm', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: 'from=' + encodeURIComponent(myName) + + '&to=' + encodeURIComponent(recipient) + + '&encrypted=' + encodeURIComponent(btoa(String.fromCharCode(...new Uint8Array(enc)))) + + '&nonce=' + encodeURIComponent(btoa(String.fromCharCode(...nonce))) + }); +} + +async function handleEncryptedDM(data) { + const myName = $name.value.trim(); + // Only decrypt if we are sender or recipient + if (data.to !== myName && data.user !== myName) return; + const otherUser = data.user === myName ? data.to : data.user; + try { + const aesKey = await getAESKey(otherUser); + if (!aesKey) throw new Error('no key'); + const ciphertext = Uint8Array.from(atob(data.encrypted), c => c.charCodeAt(0)); + const nonce = Uint8Array.from(atob(data.nonce), c => c.charCodeAt(0)); + const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, aesKey, ciphertext); + const text = new TextDecoder().decode(plain); + const d = document.createElement('div'); + d.className = 'msg'; + const t = new Date(data.ts * 1000).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}); + const arrow = data.user === myName ? '→ ' + esc(data.to) : '← ' + esc(data.user); + d.innerHTML = '' + t + ' 🔒 DM ' + arrow + ': ' + renderMd(text); + $msg.appendChild(d); + $msg.scrollTop = $msg.scrollHeight; + } catch(e) { + const d = document.createElement('div'); + d.className = 'msg'; + const t = new Date(data.ts * 1000).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}); + d.innerHTML = '' + t + ' 🔒 DM from ' + esc(data.user) + ': [cannot decrypt]'; + $msg.appendChild(d); + $msg.scrollTop = $msg.scrollHeight; + } +} + +initCrypto(); + // Check password requirement and start if (HAS_PASSWORD && !sessionPass) { showPasswordPrompt(); @@ -783,6 +916,10 @@ async def handle_http(reader, writer, first_line): q: asyncio.Queue = asyncio.Queue() grp.sse_queues.append(q) + # Register for DM delivery + dm_name = qs.get("name", [""])[0] + if dm_name: + dm_targets.setdefault(dm_name, []).append(q) try: while True: msg = await q.get() @@ -793,6 +930,13 @@ async def handle_http(reader, writer, first_line): finally: if q in grp.sse_queues: grp.sse_queues.remove(q) + if dm_name and dm_name in dm_targets: + try: + dm_targets[dm_name].remove(q) + except ValueError: + pass + if not dm_targets[dm_name]: + del dm_targets[dm_name] writer.close() return @@ -845,6 +989,79 @@ async def handle_http(reader, writer, first_line): writer.close() return + # ── E2E encrypted DM routes ── + + # POST /keys — register public key: body = name=...&key= + if method == "POST" and path == "/keys": + params = urllib.parse.parse_qs(body.decode()) + name = params.get("name", [""])[0] + key = params.get("key", [""])[0] + if name and key: + user_keys[name] = key + writer.write(b"HTTP/1.1 204 No Content\r\n\r\n") + await writer.drain() + writer.close() + return + + # GET /keys/ — get public key + if method == "GET" and path.startswith("/keys/"): + username = urllib.parse.unquote(path[6:]) + key = user_keys.get(username) + if key: + resp = key.encode() + writer.write(b"HTTP/1.1 200 OK\r\n") + writer.write(b"Content-Type: application/json\r\n") + writer.write(f"Content-Length: {len(resp)}\r\n".encode()) + writer.write(b"\r\n") + writer.write(resp) + else: + writer.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n") + await writer.drain() + writer.close() + return + + # GET /keys — list all registered usernames + if method == "GET" and path == "/keys": + resp = json.dumps(list(user_keys.keys())).encode() + writer.write(b"HTTP/1.1 200 OK\r\n") + writer.write(b"Content-Type: application/json\r\n") + writer.write(f"Content-Length: {len(resp)}\r\n".encode()) + writer.write(b"\r\n") + writer.write(resp) + await writer.drain() + writer.close() + return + + # POST /dm — relay an encrypted DM: body = from=...&to=...&encrypted=...&nonce=... + if method == "POST" and path == "/dm": + params = urllib.parse.parse_qs(body.decode()) + sender = params.get("from", [""])[0] + recipient = params.get("to", [""])[0] + encrypted = params.get("encrypted", [""])[0] + nonce = params.get("nonce", [""])[0] + if sender and recipient and encrypted: + dm_msg = { + "ts": time.time(), "user": sender, "dm": True, + "to": recipient, "encrypted": encrypted, "nonce": nonce + } + # Deliver to all SSE queues registered for this recipient + for q in dm_targets.get(recipient, []): + try: + q.put_nowait(dm_msg) + except Exception: + pass + # Also deliver to sender so they see their own DM + if sender != recipient: + for q in dm_targets.get(sender, []): + try: + q.put_nowait(dm_msg) + except Exception: + pass + writer.write(b"HTTP/1.1 204 No Content\r\n\r\n") + await writer.drain() + writer.close() + return + # GET /files// — download uploaded file if method == "GET" and path.startswith("/files/"): parts = path.split("/")