fix(config): degrade gracefully on a TOKEN_ENCRYPTION_KEY mismatch instead of a 500 (B-core-2)

A rotated or missing TOKEN_ENCRYPTION_KEY makes every stored secret undecryptable.
sysconfig._raw_override called security.decrypt() with no guard, and it sits on
hot paths (/api/me via google_enabled, login, every YouTube scheduler job,
account purge) — so a key mismatch raised InvalidToken and 500'd the whole app,
the SPA wouldn't even boot, with no documented recovery.

Catch the decrypt failure in _raw_override -> warn once per key and fall back to
the env/config default. The YouTube client's refresh-token decrypt now raises a
clear YouTubeError ("reconnect") that every caller already handles, and
purge_user uses decrypt_optional so a best-effort revoke can't 500 the delete.
Document that the key must never change once secrets are stored (recovery: clear
the affected system_config rows and re-enter).
This commit is contained in:
2026-08-18 00:22:15 +02:00
parent 67d32fecd6
commit 7c431d73fb
4 changed files with 42 additions and 7 deletions
+28 -2
View File
@@ -7,6 +7,7 @@ bounds, and whether it's a secret. The resolver returns the DB override if one i
stored Fernet-encrypted at rest (requires TOKEN_ENCRYPTION_KEY; without it, secrets can't be
stored in the DB and stay env-only).
"""
import logging
from dataclasses import dataclass
from sqlalchemy.orm import Session
@@ -15,6 +16,12 @@ from app import security
from app.config import settings
from app.models import SystemConfig
log = logging.getLogger("siftlode.config")
# Config keys whose stored ciphertext failed to decrypt — tracked so the warning is logged once per
# key, not once per request (bounded by the number of secret config keys).
_decrypt_warned: set[str] = set()
@dataclass(frozen=True)
class ConfigSpec:
@@ -118,11 +125,30 @@ def _coerce(s: ConfigSpec, raw: str):
def _raw_override(db: Session, key: str) -> str | None:
"""Stored (decrypted) override string for `key`, or None if no override is set."""
"""Stored (decrypted) override string for `key`, or None if no override is set.
A decrypt failure — a rotated or removed ``TOKEN_ENCRYPTION_KEY`` vs the key the value was written
with — must NOT crash the caller. `_raw_override` sits on hot paths (`/api/me` via `google_enabled`,
login, every YouTube scheduler job, account purge), so a raw ``InvalidToken`` here would 500 the
whole app and the SPA wouldn't even boot. Fall back to the env/config default instead, and warn once
per key so the operator gets a real signal without a per-request traceback. Recovery is documented in
docs/self-hosting.md: the key must never change once secrets are stored. (B-core-2)
"""
row = db.get(SystemConfig, key)
if row is None or row.value is None:
return None
return security.decrypt(row.value) if row.encrypted else row.value
if not row.encrypted:
return row.value
try:
return security.decrypt(row.value)
except Exception: # noqa: BLE001 — Fernet InvalidToken / malformed ciphertext (key mismatch)
if key not in _decrypt_warned:
_decrypt_warned.add(key)
log.warning(
"config %r could not be decrypted (TOKEN_ENCRYPTION_KEY changed or missing?) — falling "
"back to the env/config default; the key must not change once secrets are stored", key,
)
return None
def get(db: Session, key: str):