From 3ee385de12a212423bf5d29b55c73874ac2ea762 Mon Sep 17 00:00:00 2001 From: Inference Bot Date: Wed, 28 Oct 2026 16:48:46 -0600 Subject: [PATCH] Encrypt member chat keys at rest (AES-256-GCM) Member feedback: keys stored in plaintext in the portal DB are a key-compromise incident waiting to happen. Members.key_token (the chat key) is now encrypted with AES-256-GCM using a KEK from the PORTAL_KEK env var. Plaintext exists only in memory when a key is served (dashboard, LibreChat key feed, LiteLLM management). Startup migration encrypts existing rows idempotently; fail-closed when the KEK is absent. Member-managed API keys were already reveal-once (hash-only at rest). --- app/keycrypto.py | 95 ++++++++++++++++++++++++++++++++++++++++++++++++ app/main.py | 26 ++++++++----- requirements.txt | 1 + 3 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 app/keycrypto.py diff --git a/app/keycrypto.py b/app/keycrypto.py new file mode 100644 index 0000000..1b02a40 --- /dev/null +++ b/app/keycrypto.py @@ -0,0 +1,95 @@ +"""Key encryption at rest for member chat keys (members.key_token). + +Design (member feedback, 2026-10): + Member keys are encrypted with AES-256-GCM using a key-encryption key + (KEK) supplied via the PORTAL_KEK environment variable. The plaintext + exists only in process memory at the moment a key is served (dashboard + display, LibreChat key feed, LiteLLM management calls) — never on disk + after startup. A stolen database file yields ciphertext, not spending + power. + +Format: + "enc1:" + base64(nonce[12] || ciphertext || tag[16]) + Values without the prefix are treated as legacy plaintext (pre-migration + rows) and passed through on decrypt — this keeps the migration idempotent + and rollout safe. + +Fail-closed behavior: + - encrypt_key() raises if PORTAL_KEK is unset: nothing new is stored in + plaintext. + - decrypt_key() raises on an encrypted value without PORTAL_KEK (wrong or + missing key = the key cannot be served). + - get_db()'s migration encrypts any remaining plaintext rows at startup; + it is skipped (with a CRITICAL log) if PORTAL_KEK is unset. +""" + +import base64 +import logging +import os + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +log = logging.getLogger("uvicorn.error") + +_PREFIX = "enc1:" +_AAD = b"member-chat-key-v1" + + +def _kek() -> bytes: + raw = os.environ.get("PORTAL_KEK", "") + if not raw: + raise RuntimeError( + "PORTAL_KEK is not set — refusing to encrypt/decrypt member keys" + ) + key = base64.b64decode(raw) + if len(key) != 32: + raise RuntimeError("PORTAL_KEK must decode to exactly 32 bytes (base64)") + return key + + +def encrypt_key(plain: str | None) -> str | None: + """Encrypt a member key. Already-encrypted and empty values pass through.""" + if not plain or plain.startswith(_PREFIX): + return plain + nonce = os.urandom(12) + ct = AESGCM(_kek()).encrypt(nonce, plain.encode(), _AAD) + return _PREFIX + base64.b64encode(nonce + ct).decode() + + +def decrypt_key(stored: str | None) -> str | None: + """Decrypt a member key. Legacy plaintext rows pass through unchanged.""" + if not stored: + return stored + if not stored.startswith(_PREFIX): + return stored # pre-migration plaintext + blob = base64.b64decode(stored[len(_PREFIX):]) + pt = AESGCM(_kek()).decrypt(blob[:12], blob[12:], _AAD) + return pt.decode() + + +def migrate_plaintext_keys(conn) -> int: + """Encrypt any plaintext key_token rows. Idempotent; returns rows changed. + + Skipped with a CRITICAL log when PORTAL_KEK is unset — the portal keeps + running on legacy plaintext until the operator sets the key. + """ + if not os.environ.get("PORTAL_KEK"): + log.critical( + "PORTAL_KEK unset — member keys remain PLAINTEXT at rest; " + "set PORTAL_KEK to a base64 32-byte key and restart" + ) + return 0 + rows = conn.execute( + "SELECT email, key_token FROM members " + "WHERE key_token != '' AND key_token IS NOT NULL " + "AND key_token NOT LIKE 'enc1:%'" + ).fetchall() + changed = 0 + for email, kt in rows: + enc = encrypt_key(kt) + if enc != kt: + conn.execute( + "UPDATE members SET key_token = ? WHERE email = ?", (enc, email) + ) + changed += 1 + return changed diff --git a/app/main.py b/app/main.py index d6f9b7b..700d366 100644 --- a/app/main.py +++ b/app/main.py @@ -24,6 +24,8 @@ from datetime import datetime, timedelta, timezone from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart +from keycrypto import decrypt_key, encrypt_key, migrate_plaintext_keys + import httpx import jwt import asyncio @@ -716,8 +718,8 @@ def get_db() -> sqlite3.Connection: # lists) and purge the stored plaintext. Member API keys are revealed ONCE # (at creation) and never again — the industry standard. Revocation uses the # LiteLLM hash, not the plaintext, so the portal has no reason to keep it. - # (The `members.key_token` chat key is a different table and IS retained in - # plaintext — the injector needs it on every request.) + # (The `members.key_token` chat key is a different table and IS retained — + # encrypted at rest (keycrypto.py), decrypted in memory only when served.) ak_cols = [r[1] for r in conn.execute("PRAGMA table_info(member_api_keys)").fetchall()] if "fingerprint" not in ak_cols: conn.execute("ALTER TABLE member_api_keys ADD COLUMN fingerprint TEXT") @@ -729,6 +731,12 @@ def get_db() -> sqlite3.Connection: "WHERE (fingerprint IS NULL OR fingerprint = '') AND sk_token != ''" ) conn.execute("UPDATE member_api_keys SET sk_token = '' WHERE sk_token != ''") + + # Encrypt member chat keys at rest (AES-256-GCM, KEK from PORTAL_KEK env). + # Idempotent: only touches rows still holding plaintext. Skipped with a + # CRITICAL log if PORTAL_KEK is unset — see keycrypto.py for the design. + migrate_plaintext_keys(conn) + # Commit the DML above — ALTER TABLE auto-commits, but the UPDATEs open an # implicit transaction that would otherwise roll back when get_db() closes. conn.commit() @@ -1499,7 +1507,7 @@ def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str = "VALUES (?, ?, ?, ?, 1) " "ON CONFLICT(email) DO UPDATE SET key_token=excluded.key_token, " "cloudron_user_id=excluded.cloudron_user_id, slug=excluded.slug, active=1", - (email, key_token, cloudron_user_id, slug), + (email, encrypt_key(key_token), cloudron_user_id, slug), ) conn.commit() conn.close() @@ -1511,7 +1519,7 @@ def get_member_key(email: str) -> str | None: "SELECT key_token FROM members WHERE email = ? AND active = 1", (email,) ).fetchone() conn.close() - return row[0] if row else None + return decrypt_key(row[0]) if row else None def get_stored_key(email: str) -> str | None: @@ -1527,7 +1535,7 @@ def get_stored_key(email: str) -> str | None: "SELECT key_token FROM members WHERE email = ?", (email,) ).fetchone() conn.close() - return (row[0] if row and row[0] else None) + return (decrypt_key(row[0]) if row and row[0] else None) def touch_welcome_sent(email: str) -> None: @@ -1550,11 +1558,11 @@ def get_member_by_slug(slug: str) -> dict | None: conn.close() if not row: return None - return {"email": row[0], "key_token": row[1], "cloudron_user_id": row[2]} + return {"email": row[0], "key_token": decrypt_key(row[1]), "cloudron_user_id": row[2]} def deactivate_member(email: str) -> str | None: - """Mark a member inactive and return their key token (for deletion).""" + """Mark member inactive and return their DECRYPTED key token (for deletion).""" conn = get_db() row = conn.execute( "SELECT key_token FROM members WHERE email = ?", (email,) @@ -1562,7 +1570,7 @@ def deactivate_member(email: str) -> str | None: conn.execute("UPDATE members SET active = 0 WHERE email = ?", (email,)) conn.commit() conn.close() - return row[0] if row else None + return decrypt_key(row[0]) if row else None # --- Helpers --- @@ -3771,7 +3779,7 @@ async def admin_librechat_keys(request: Request): conn.close() return { "members": [ - {"email": email, "key": key_token} + {"email": email, "key": decrypt_key(key_token)} for email, key_token in rows ] } diff --git a/requirements.txt b/requirements.txt index 5ed9ad2..4d35850 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ pydantic==2.9.2 PyJWT==2.9.0 slowapi==0.1.9 psycopg2-binary==2.9.9 +cryptography==44.0.2