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:
inference-bot committed 2026-10-28 16:48:58 -06:00
1 parent ac3d79a1df
commit 3ee385de12
3 files changed
+113 -9

No files matched your search

+17 -9
View File
@@ -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
]
}