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/<user> 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) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-26 16:14:28 +04:00
parent c97a3834d1
commit 03d91cb844

225
chat.py
View File

@@ -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"""<!DOCTYPE html>
.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"""<!DOCTYPE html>
<div id="header">
<input id="name" placeholder="Name" value="" autocomplete="off">
<span id="group-tag"></span>
<span id="header-info">Shift+Enter newline · /setpass &lt;pw&gt; · /clearpass</span>
<span id="header-info">/dm @user msg · /users · /setpass · /color</span>
</div>
<div id="messages"></div>
<div id="bottom">
@@ -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 = '<span class="ts">' + t + '</span> <span style="color:#ff6b9d">&#128274; DM ' + arrow + '</span>: ' + 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 = '<span class="ts">' + t + '</span> <span style="color:#ff6b9d">&#128274; DM from ' + esc(data.user) + '</span>: <em>[cannot decrypt]</em>';
$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=<JWK JSON>
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/<username> — 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/<id>/<filename> — download uploaded file
if method == "GET" and path.startswith("/files/"):
parts = path.split("/")