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).
This commit is contained in:
1 parent
ac3d79a1df
commit
3ee385de12
3 files changed
+113
-9
No files matched your search
@@ -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
|
||||||
+17
-9
@@ -24,6 +24,8 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
|
||||||
|
from keycrypto import decrypt_key, encrypt_key, migrate_plaintext_keys
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import jwt
|
import jwt
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -716,8 +718,8 @@ def get_db() -> sqlite3.Connection:
|
|||||||
# lists) and purge the stored plaintext. Member API keys are revealed ONCE
|
# lists) and purge the stored plaintext. Member API keys are revealed ONCE
|
||||||
# (at creation) and never again — the industry standard. Revocation uses the
|
# (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.
|
# 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
|
# (The `members.key_token` chat key is a different table and IS retained —
|
||||||
# plaintext — the injector needs it on every request.)
|
# 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()]
|
ak_cols = [r[1] for r in conn.execute("PRAGMA table_info(member_api_keys)").fetchall()]
|
||||||
if "fingerprint" not in ak_cols:
|
if "fingerprint" not in ak_cols:
|
||||||
conn.execute("ALTER TABLE member_api_keys ADD COLUMN fingerprint TEXT")
|
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 != ''"
|
"WHERE (fingerprint IS NULL OR fingerprint = '') AND sk_token != ''"
|
||||||
)
|
)
|
||||||
conn.execute("UPDATE member_api_keys SET sk_token = '' WHERE 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
|
# Commit the DML above — ALTER TABLE auto-commits, but the UPDATEs open an
|
||||||
# implicit transaction that would otherwise roll back when get_db() closes.
|
# implicit transaction that would otherwise roll back when get_db() closes.
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -1499,7 +1507,7 @@ def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str =
|
|||||||
"VALUES (?, ?, ?, ?, 1) "
|
"VALUES (?, ?, ?, ?, 1) "
|
||||||
"ON CONFLICT(email) DO UPDATE SET key_token=excluded.key_token, "
|
"ON CONFLICT(email) DO UPDATE SET key_token=excluded.key_token, "
|
||||||
"cloudron_user_id=excluded.cloudron_user_id, slug=excluded.slug, active=1",
|
"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.commit()
|
||||||
conn.close()
|
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,)
|
"SELECT key_token FROM members WHERE email = ? AND active = 1", (email,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
conn.close()
|
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:
|
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,)
|
"SELECT key_token FROM members WHERE email = ?", (email,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
conn.close()
|
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:
|
def touch_welcome_sent(email: str) -> None:
|
||||||
@@ -1550,11 +1558,11 @@ def get_member_by_slug(slug: str) -> dict | None:
|
|||||||
conn.close()
|
conn.close()
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
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:
|
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()
|
conn = get_db()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT key_token FROM members WHERE email = ?", (email,)
|
"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.execute("UPDATE members SET active = 0 WHERE email = ?", (email,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return row[0] if row else None
|
return decrypt_key(row[0]) if row else None
|
||||||
|
|
||||||
|
|
||||||
# --- Helpers ---
|
# --- Helpers ---
|
||||||
@@ -3771,7 +3779,7 @@ async def admin_librechat_keys(request: Request):
|
|||||||
conn.close()
|
conn.close()
|
||||||
return {
|
return {
|
||||||
"members": [
|
"members": [
|
||||||
{"email": email, "key": key_token}
|
{"email": email, "key": decrypt_key(key_token)}
|
||||||
for email, key_token in rows
|
for email, key_token in rows
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ pydantic==2.9.2
|
|||||||
PyJWT==2.9.0
|
PyJWT==2.9.0
|
||||||
slowapi==0.1.9
|
slowapi==0.1.9
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
|
cryptography==44.0.2
|
||||||
Reference in new issue
Block a user