Inline key-encryption module into main.py

The build service's context dropped new files added under app/
(keycrypto.py never reached the image while main.py did), causing a
crash loop. Inline the module so the deploy cannot miss it. Behavior
unchanged: AES-256-GCM at rest, KEK from PORTAL_KEK, enc1: format,
legacy plaintext passthrough, fail-closed migration.
This commit is contained in:
inference-bot committed 2026-10-28 17:11:12 -06:00
1 parent 4a48529cbb
commit a5410ac237
2 files changed
+71 -98

No files matched your search

+71 -3
View File
@@ -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