"""Encryption-at-rest for service secrets. Service API keys / tokens are stored encrypted in the ``services.secrets_json`` column. Encryption uses Fernet (symmetric authenticated encryption) with a single master key provided via the ``MANAGE_ENCRYPTION_KEY`` environment variable. * The key **must** be a urlsafe base64-encoded 32-byte value (Fernet format). * The key is **always required** — there is no development fallback, so secrets are never accidentally stored in plaintext. * Secrets are encrypted field-by-field; the ``"which secrets are set"`` metadata can be derived from the ciphertext blob without decrypting. """ from __future__ import annotations import os from functools import lru_cache from cryptography.fernet import Fernet, InvalidToken ENCRYPTION_KEY_ENV = "MANAGE_ENCRYPTION_KEY" class EncryptionKeyError(RuntimeError): """Raised when the encryption key is missing or invalid.""" @lru_cache(maxsize=1) def get_encryption_key() -> bytes: """Return the raw Fernet key, or raise if missing/invalid. The result is cached for the process lifetime. Tests should call :func:`reset_encryption_key_cache` after changing the environment. """ raw = os.environ.get(ENCRYPTION_KEY_ENV) if not raw: raise EncryptionKeyError(f"{ENCRYPTION_KEY_ENV} is required to store service secrets") key = raw.strip().encode() try: Fernet(key) except (ValueError, TypeError) as exc: # pragma: no cover - validated by tests raise EncryptionKeyError(f"{ENCRYPTION_KEY_ENV} must be a valid Fernet key") from exc return key def reset_encryption_key_cache() -> None: """Drop the cached encryption key (used by tests that swap keys).""" get_encryption_key.cache_clear() def _fernet() -> Fernet: return Fernet(get_encryption_key()) def encrypt_value(plaintext: str) -> str: """Encrypt a single secret value and return the ciphertext string.""" return _fernet().encrypt(plaintext.encode()).decode() def decrypt_value(ciphertext: str) -> str: """Decrypt a single ciphertext value.""" try: return _fernet().decrypt(ciphertext.encode()).decode() except InvalidToken as exc: raise EncryptionKeyError("Service secret could not be decrypted") from exc def encrypt_secrets(values: dict[str, str]) -> dict[str, str]: """Encrypt every provided secret value.""" fernet = _fernet() return {key: fernet.encrypt(value.encode()).decode() for key, value in values.items()} def decrypt_secrets(blob: dict[str, str]) -> dict[str, str]: """Decrypt every secret value in a blob.""" fernet = _fernet() result: dict[str, str] = {} for key, ciphertext in blob.items(): try: result[key] = fernet.decrypt(ciphertext.encode()).decode() except InvalidToken as exc: raise EncryptionKeyError(f"Service secret '{key}' could not be decrypted") from exc return result def generate_development_key() -> str: """Return a freshly generated Fernet key (helper for operators/docs).""" return Fernet.generate_key().decode() def validate_encryption_key() -> None: """Eagerly validate that the encryption key is present and well-formed.""" get_encryption_key() # raises EncryptionKeyError on failure