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("/")