From 93be964d5250909abae6d345afaf74f4ba60b62a Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Thu, 26 Mar 2026 16:21:20 +0400 Subject: [PATCH] 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) --- chat.py | 64 +++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/chat.py b/chat.py index 6c24e57..ff32e03 100644 --- a/chat.py +++ b/chat.py @@ -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()