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:
223
chat.py
223
chat.py
@@ -25,7 +25,7 @@ import html
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
PORT = 9999
|
PORT = 9999
|
||||||
VERSION = "12"
|
VERSION = "13"
|
||||||
TUNNEL_TARGETS = {
|
TUNNEL_TARGETS = {
|
||||||
"parspack": ("185.208.174.152", 22),
|
"parspack": ("185.208.174.152", 22),
|
||||||
"mequ": ("188.213.68.133", 2022),
|
"mequ": ("188.213.68.133", 2022),
|
||||||
@@ -36,6 +36,11 @@ MAX_TOTAL_STORAGE = 50 * 1024 * 1024 # 50 MB total
|
|||||||
|
|
||||||
# ── Server ──────────────────────────────────────────────────────────────
|
# ── 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)
|
uploaded_files: dict[str, bytes] = {} # file_id -> raw bytes (insertion order)
|
||||||
total_file_bytes = 0
|
total_file_bytes = 0
|
||||||
|
|
||||||
@@ -129,6 +134,7 @@ CHAT_HTML = r"""<!DOCTYPE html>
|
|||||||
.msg a.auto-link { color: #67c7eb; }
|
.msg a.auto-link { color: #67c7eb; }
|
||||||
.ts { color: #666; }
|
.ts { color: #666; }
|
||||||
.sys { color: #5e9ca0; font-style: italic; }
|
.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;
|
.file-link { display: inline-block; background: #0f3460; border: 1px solid #444;
|
||||||
padding: 6px 12px; border-radius: 4px; margin: 2px 0; color: #67c7eb;
|
padding: 6px 12px; border-radius: 4px; margin: 2px 0; color: #67c7eb;
|
||||||
text-decoration: none; }
|
text-decoration: none; }
|
||||||
@@ -194,7 +200,7 @@ CHAT_HTML = r"""<!DOCTYPE html>
|
|||||||
<div id="header">
|
<div id="header">
|
||||||
<input id="name" placeholder="Name" value="" autocomplete="off">
|
<input id="name" placeholder="Name" value="" autocomplete="off">
|
||||||
<span id="group-tag"></span>
|
<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>
|
||||||
<div id="messages"></div>
|
<div id="messages"></div>
|
||||||
<div id="bottom">
|
<div id="bottom">
|
||||||
@@ -305,6 +311,21 @@ function send() {
|
|||||||
localStorage.setItem('chat-name', name);
|
localStorage.setItem('chat-name', name);
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
// Local commands
|
// 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') {
|
if (text === '/colors' || text === '/color') {
|
||||||
reshuffleColors();
|
reshuffleColors();
|
||||||
$input.value = '';
|
$input.value = '';
|
||||||
@@ -406,7 +427,11 @@ window.addEventListener('focus', function() {
|
|||||||
let sessionPass = sessionStorage.getItem('pw-' + GROUP) || '';
|
let sessionPass = sessionStorage.getItem('pw-' + GROUP) || '';
|
||||||
|
|
||||||
function startSSE() {
|
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);
|
const es = new EventSource(url);
|
||||||
es.onmessage = function(e) {
|
es.onmessage = function(e) {
|
||||||
// Check for auth error
|
// Check for auth error
|
||||||
@@ -416,7 +441,11 @@ function startSSE() {
|
|||||||
showPasswordPrompt();
|
showPasswordPrompt();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (data.dm && data.encrypted) {
|
||||||
|
handleEncryptedDM(data);
|
||||||
|
} else {
|
||||||
addMsg(data);
|
addMsg(data);
|
||||||
|
}
|
||||||
notify(data);
|
notify(data);
|
||||||
};
|
};
|
||||||
es.onerror = function() {
|
es.onerror = function() {
|
||||||
@@ -439,6 +468,110 @@ document.getElementById('pw-input').onkeydown = function(e) {
|
|||||||
if (e.key === 'Enter') document.getElementById('pw-btn').click();
|
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
|
// Check password requirement and start
|
||||||
if (HAS_PASSWORD && !sessionPass) {
|
if (HAS_PASSWORD && !sessionPass) {
|
||||||
showPasswordPrompt();
|
showPasswordPrompt();
|
||||||
@@ -783,6 +916,10 @@ async def handle_http(reader, writer, first_line):
|
|||||||
|
|
||||||
q: asyncio.Queue = asyncio.Queue()
|
q: asyncio.Queue = asyncio.Queue()
|
||||||
grp.sse_queues.append(q)
|
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:
|
try:
|
||||||
while True:
|
while True:
|
||||||
msg = await q.get()
|
msg = await q.get()
|
||||||
@@ -793,6 +930,13 @@ async def handle_http(reader, writer, first_line):
|
|||||||
finally:
|
finally:
|
||||||
if q in grp.sse_queues:
|
if q in grp.sse_queues:
|
||||||
grp.sse_queues.remove(q)
|
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()
|
writer.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -845,6 +989,79 @@ async def handle_http(reader, writer, first_line):
|
|||||||
writer.close()
|
writer.close()
|
||||||
return
|
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
|
# GET /files/<id>/<filename> — download uploaded file
|
||||||
if method == "GET" and path.startswith("/files/"):
|
if method == "GET" and path.startswith("/files/"):
|
||||||
parts = path.split("/")
|
parts = path.split("/")
|
||||||
|
|||||||
Reference in New Issue
Block a user