CI and images / lint (push) Successful in 4s
CI and images / extension-version (push) Successful in 4s
CI and images / frontend-build (push) Successful in 26s
CI and images / backend-lint-and-test (push) Successful in 33s
CI and images / integration (push) Failing after 2m27s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / build-agent (push) Skipped
Run 7368's smoke, on an image whose five verification lanes were all green:
WARNING Generating NEW Fernet credential key at
/images/secrets/credential_key.b64
...
ValueError: Fernet key must be 32 url-safe base64-encoded bytes.
Nothing to do with this batch's changes — it is a first-boot race that has
been there since the key file existed, and it is a RACE rather than a
certainty: the same code booted cleanly on the three runs before it.
hypercorn starts several worker processes and each one builds the app, so on a
first boot they all reach the bootstrap together. `write_bytes` creates the
file at size zero and fills it a moment later, which gives the second process
an `exists()` of True and a `read_bytes()` of `b""`.
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 — and this is
the very first thing a new install does.
The key is now written to a temp file and `os.link`ed into place. `os.link` is
the atomic part: it either creates the name or raises FileExistsError, and it
cannot expose a half-written file. Deliberately NOT `os.replace`, which would
succeed — 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. The losing branch reads the winner's key back rather than returning
its own, which is what keeps every worker on ONE key.
Tested for AGREEMENT, not for the absence of a crash: eight threads through a
barrier, and all eight must end up holding the same key. A race that left each
worker with its own would pass a "did it raise" check and produce a system
where a credential written by one worker cannot be read by the next.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
136 lines
5.9 KiB
Python
136 lines
5.9 KiB
Python
"""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
|