v14: persistent E2E keys - browser localStorage + server keys.json

Browser:
- ECDH key pair saved to localStorage (chat-key-priv, chat-key-pub)
- Loaded on reconnect, only generated once
- Re-registers public key with server on every connect
- Corrupted keys auto-regenerate

Server:
- Keys saved to keys.json on disk after each registration
- Loaded on startup, survives restarts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-26 16:21:20 +04:00
parent 04482faa6a
commit 93be964d52

64
chat.py
View File

@@ -25,7 +25,7 @@ import html
import urllib.parse
PORT = 9999
VERSION = "13"
VERSION = "14"
TUNNEL_TARGETS = {
"parspack": ("185.208.174.152", 22),
"mequ": ("188.213.68.133", 2022),
@@ -37,8 +37,29 @@ 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
KEYS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "keys.json")
def load_keys() -> dict[str, str]:
"""Load user keys from disk."""
try:
with open(KEYS_FILE, "r") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def save_keys():
"""Persist user keys to disk."""
try:
with open(KEYS_FILE, "w") as f:
json.dump(user_keys, f)
except Exception:
pass
user_keys: dict[str, str] = load_keys()
# DM routing: username -> list of queues for SSE delivery
dm_targets: dict[str, list] = {}
uploaded_files: dict[str, bytes] = {} # file_id -> raw bytes (insertion order)
@@ -477,11 +498,37 @@ 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
// Try to load persisted keys from localStorage
const savedPriv = localStorage.getItem('chat-key-priv');
const savedPub = localStorage.getItem('chat-key-pub');
if (savedPriv && savedPub) {
try {
const privJwk = JSON.parse(savedPriv);
myPubJwk = JSON.parse(savedPub);
const privKey = await crypto.subtle.importKey(
'jwk', privJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']
);
const pubKey = await crypto.subtle.importKey(
'jwk', myPubJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, []
);
myKeyPair = { privateKey: privKey, publicKey: pubKey };
} catch(e) {
// Corrupted keys, regenerate
localStorage.removeItem('chat-key-priv');
localStorage.removeItem('chat-key-pub');
return initCrypto();
}
} else {
// Generate new keys and persist
myKeyPair = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']
);
myPubJwk = await crypto.subtle.exportKey('jwk', myKeyPair.publicKey);
const privJwk = await crypto.subtle.exportKey('jwk', myKeyPair.privateKey);
localStorage.setItem('chat-key-priv', JSON.stringify(privJwk));
localStorage.setItem('chat-key-pub', JSON.stringify(myPubJwk));
}
// Always register public key with server (re-registers on reconnect)
const myName = $name.value.trim() || 'anon';
fetch('/keys', {
method: 'POST',
@@ -1000,6 +1047,7 @@ async def handle_http(reader, writer, first_line):
key = params.get("key", [""])[0]
if name and key:
user_keys[name] = key
save_keys()
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
await writer.drain()
writer.close()