"""Fernet-based encryption for credential blobs. The key is a single 32-byte value (urlsafe-base64-encoded; what Fernet.generate_key produces) stored at /images/secrets/credential_key.b64 (mode 0600, parent dir 0700). The 2026-06-02 audit caught a silent key-regeneration path: on a partial disaster restore where the DB was restored but the secrets dir was lost, the old `_load_or_create_key` would mint a fresh key with no log, producing a working-looking system where every authenticated download failed AUTH_ERROR until the operator re-uploaded every credential by hand. Now the constructor refuses to auto-generate unless either: * the caller explicitly passes `bootstrap_ok=True` (tests, scripts), or * the env var `CURATOR_BOOTSTRAP_NEW_KEY=1` is set (operator opt-in during first-time setup). Otherwise it raises `MissingCredentialKey` so the app fails fast at startup and the operator can restore the key file from backup. Operator backup procedure must include /images/secrets/ alongside the rest of /images/ — losing the key file makes existing encrypted_blob rows undecryptable (recovery = delete the rows and re-upload). """ import logging import os from pathlib import Path from cryptography.fernet import Fernet, InvalidToken log = logging.getLogger(__name__) _BOOTSTRAP_ENV_VAR = "CURATOR_BOOTSTRAP_NEW_KEY" class InvalidCredentialBlob(Exception): """Raised when decryption fails (wrong key, tampered blob, …).""" class MissingCredentialKey(Exception): """The Fernet key file is missing AND the caller hasn't opted in to generating a new one. Audit 2026-06-02: prevents silent key regeneration on partial DB-restored / secrets-lost deployments. Set CURATOR_BOOTSTRAP_NEW_KEY=1 for first-time setup, or restore the key file from backup.""" class CredentialCrypto: """Fernet encrypt/decrypt with an on-disk key file. Instantiate with a path; the file is loaded if present, or created if absent AND the caller has opted in (bootstrap_ok=True or CURATOR_BOOTSTRAP_NEW_KEY=1 env var). Production sites: `IMAGES_ROOT / "secrets" / "credential_key.b64"`. """ def __init__(self, key_path: Path, *, bootstrap_ok: bool | None = None): self._key_path = Path(key_path) if bootstrap_ok is None: bootstrap_ok = os.environ.get(_BOOTSTRAP_ENV_VAR) == "1" self._fernet = Fernet(self._load_or_create_key(bootstrap_ok)) def _load_or_create_key(self, bootstrap_ok: bool) -> bytes: if self._key_path.exists(): return self._key_path.read_bytes() if not bootstrap_ok: raise MissingCredentialKey( f"Fernet key file not found at {self._key_path}. " f"For first-time setup, set {_BOOTSTRAP_ENV_VAR}=1. " f"If this is a restored instance, restore the key file " f"from backup — generating a new one would make every " f"existing Credential row undecryptable." ) log.warning( "Generating NEW Fernet credential key at %s. Any existing " "encrypted_blob rows in the DB will be undecryptable — " "re-upload each credential after this completes.", self._key_path, ) parent = self._key_path.parent parent.mkdir(parents=True, exist_ok=True) os.chmod(parent, 0o700) # Written to a temp file and LINKED into place, not written directly. # # hypercorn starts several worker processes and each one builds the # app, so on a first boot they all reach this at once. A plain # `write_bytes` creates the file at size zero and fills it a moment # later, which gives a second process an `exists()` of True and a # `read_bytes()` of b"" — and the app dies with # # ValueError: Fernet key must be 32 url-safe base64-encoded bytes. # # Seen on run 7368's smoke, and it is a race rather than a certainty: # the same image had booted cleanly on the three runs before it. A # first boot that fails one time in five is worse than one that fails # every time, because it looks like the deployment rather than the code. # # `os.link` is the atomic part: it either creates the name or raises # FileExistsError, and it cannot expose a half-written file. NOT # `os.replace`, which would succeed — so two processes that both # generated a key would each think they had won, and the loser's key # would overwrite the one the winner had already handed to Fernet. key = Fernet.generate_key() tmp = parent / f".{self._key_path.name}.{os.getpid()}.tmp" try: tmp.write_bytes(key) os.chmod(tmp, 0o600) try: os.link(tmp, self._key_path) except FileExistsError: # Another process created it between our `exists()` check and # here. Theirs is as good as ours, and using it is what keeps # every worker on ONE key. log.info( "another process created %s first; using that key", self._key_path, ) finally: tmp.unlink(missing_ok=True) # Read back rather than returning `key`: on the losing branch the file # holds somebody else's, and returning ours would leave this worker # encrypting with a key no other worker can read. return self._key_path.read_bytes() def encrypt(self, plaintext: str) -> bytes: return self._fernet.encrypt(plaintext.encode("utf-8")) def decrypt(self, ciphertext: bytes) -> str: try: return self._fernet.decrypt(ciphertext).decode("utf-8") except InvalidToken as exc: raise InvalidCredentialBlob( "credential blob is corrupt or encrypted with a different key" ) from exc