diff --git a/app/keycrypto.py b/app/keycrypto.py deleted file mode 100644 index 1b02a40..0000000 --- a/app/keycrypto.py +++ /dev/null @@ -1,95 +0,0 @@ -"""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 700d366..9fe8fc4 100644 --- a/app/main.py +++ b/app/main.py @@ -24,7 +24,75 @@ 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 +# --- Key encryption at rest (inlined from keycrypto.py; see repo history) --- +# Member chat keys are encrypted with AES-256-GCM using a key-encryption key +# (KEK) from the PORTAL_KEK env var. Plaintext exists only in process memory +# when a key is served. Format: "enc1:" + base64(nonce[12] || ct || tag). +# Values without the prefix are legacy plaintext (pre-migration) and pass +# through on decrypt. Fail-closed: nothing new is stored plaintext when +# PORTAL_KEK is unset, and encrypted values cannot be served without it. +import base64 as _b64 + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM as _AESGCM + +_KEYENC_PREFIX = "enc1:" +_KEYENC_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 = _b64.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(_KEYENC_PREFIX): + return plain + nonce = os.urandom(12) + ct = _AESGCM(_kek()).encrypt(nonce, plain.encode(), _KEYENC_AAD) + return _KEYENC_PREFIX + _b64.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(_KEYENC_PREFIX): + return stored # pre-migration plaintext + blob = _b64.b64decode(stored[len(_KEYENC_PREFIX):]) + return _AESGCM(_kek()).decrypt(blob[:12], blob[12:], _KEYENC_AAD).decode() + + +def migrate_plaintext_keys(conn) -> int: + """Encrypt plaintext key_token rows. Idempotent; returns rows changed.""" + if not os.environ.get("PORTAL_KEK"): + logging.getLogger("uvicorn.error").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 +# --- end key encryption --- import httpx import jwt @@ -719,7 +787,7 @@ def get_db() -> sqlite3.Connection: # (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 — - # encrypted at rest (keycrypto.py), decrypted in memory only when served.) + # encrypted at rest (AES-256-GCM), 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") @@ -734,7 +802,7 @@ def get_db() -> sqlite3.Connection: # 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. + # CRITICAL log if PORTAL_KEK is unset — see the inline key-encryption module. migrate_plaintext_keys(conn) # Commit the DML above — ALTER TABLE auto-commits, but the UPDATEs open an