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:
225
chat.py
225
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"""<!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 <pw> · /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">🔒 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">🔒 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("/")
|
||||
|
||||
Reference in New Issue
Block a user