Files
FabledCurator/tests/test_credential_crypto.py
T
bvandeusenandClaude Opus 5 895589a578
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
fix: first boot raced itself for the credential key (4295)
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
2026-09-23 13:04:26 -04:00

197 lines
7.8 KiB
Python

import os
import stat
from pathlib import Path
import pytest
from backend.app.services.credential_crypto import (
CredentialCrypto,
InvalidCredentialBlob,
)
pytestmark = pytest.mark.integration
def test_load_or_create_writes_key_with_mode_0600(tmp_path):
key_path = tmp_path / "secrets" / "credential_key.b64"
CredentialCrypto(key_path, bootstrap_ok=True)
assert key_path.exists()
mode = stat.S_IMODE(os.stat(key_path).st_mode)
assert mode == 0o600
# parent dir is 0o700
parent_mode = stat.S_IMODE(os.stat(key_path.parent).st_mode)
assert parent_mode == 0o700
def test_load_existing_key_is_idempotent(tmp_path):
key_path = tmp_path / "credential_key.b64"
crypto1 = CredentialCrypto(key_path, bootstrap_ok=True)
contents_after_first = key_path.read_bytes()
crypto2 = CredentialCrypto(key_path, bootstrap_ok=True)
contents_after_second = key_path.read_bytes()
assert contents_after_first == contents_after_second
# And both crypto instances decrypt each other's ciphertext
ct = crypto1.encrypt("hello")
assert crypto2.decrypt(ct) == "hello"
def test_encrypt_decrypt_round_trip(tmp_path):
crypto = CredentialCrypto(tmp_path / "k", bootstrap_ok=True)
plaintext = "domain.com\tTRUE\t/\tTRUE\t1700000000\tname\tvalue"
ct = crypto.encrypt(plaintext)
assert isinstance(ct, bytes)
assert ct != plaintext.encode()
assert crypto.decrypt(ct) == plaintext
def test_decrypt_with_wrong_key_raises(tmp_path):
crypto_a = CredentialCrypto(tmp_path / "a", bootstrap_ok=True)
crypto_b = CredentialCrypto(tmp_path / "b", bootstrap_ok=True)
ct = crypto_a.encrypt("secret")
with pytest.raises(InvalidCredentialBlob):
crypto_b.decrypt(ct)
def test_missing_key_without_bootstrap_raises(tmp_path, monkeypatch):
"""Audit 2026-06-02: without explicit opt-in, a missing key file
is a fatal startup error — silent regeneration on partial restore
would make every existing Credential row undecryptable."""
from backend.app.services.credential_crypto import MissingCredentialKey
monkeypatch.delenv("CURATOR_BOOTSTRAP_NEW_KEY", raising=False)
with pytest.raises(MissingCredentialKey):
CredentialCrypto(tmp_path / "absent.b64")
def test_missing_key_with_env_var_bootstraps(tmp_path, monkeypatch):
"""The env var CURATOR_BOOTSTRAP_NEW_KEY=1 is the operator's
first-time-setup opt-in for auto-creating the key file."""
monkeypatch.setenv("CURATOR_BOOTSTRAP_NEW_KEY", "1")
key_path = tmp_path / "bootstrap.b64"
CredentialCrypto(key_path) # no bootstrap_ok kwarg — relies on env
assert key_path.exists()
# --- #3422: the install docs quote this module, so they must keep agreeing --
#
# README.md and .env.example both print the literal failure a new installer
# hits, and the literal path and env var they must act on. That is the right
# call — a stranger greps for the string their terminal showed them — but it
# means those two files now DEPEND on this module's wording, with nothing
# connecting them. The characteristic defect of the install surface is exactly
# this: the documented behaviour and the code drift apart, and the code is the
# one that is right.
#
# Presence checks on both sides, deliberately: an absence check against prose
# would pass for the wrong reason the moment a sentence were reworded
# (snippet #3352).
_REPO_ROOT = Path(__file__).resolve().parents[1]
_INSTALL_DOCS = ("README.md", ".env.example")
def test_the_bootstrap_refusal_still_reads_the_way_the_docs_quote_it(tmp_path, monkeypatch):
"""The three things a reader is told to look for, in the raised message."""
from backend.app.services.credential_crypto import (
_BOOTSTRAP_ENV_VAR,
MissingCredentialKey,
)
monkeypatch.delenv(_BOOTSTRAP_ENV_VAR, raising=False)
with pytest.raises(MissingCredentialKey) as exc:
CredentialCrypto(tmp_path / "absent.b64")
message = str(exc.value)
# The sentence README.md reproduces verbatim.
assert "Fernet key file not found at" in message
# The variable both docs tell the operator to set.
assert _BOOTSTRAP_ENV_VAR in message
# The alternative the docs lean on — that a restored instance restores the
# key rather than minting one. Losing this line loses the whole point.
assert "restore the key file" in message
def test_the_install_docs_name_the_real_key_path_and_variable():
"""Pins the two literals, read from the code rather than retyped here.
Changing `_CREDENTIAL_KEY_PATH` or the env var name without updating the
docs fails this — which is the only thing standing between a rename and a
README that sends strangers to a path that does not exist.
"""
from backend.app import _CREDENTIAL_KEY_PATH
from backend.app.services.credential_crypto import _BOOTSTRAP_ENV_VAR
for name in _INSTALL_DOCS:
text = (_REPO_ROOT / name).read_text()
assert str(_CREDENTIAL_KEY_PATH) in text, f"{name} does not name the key path"
assert _BOOTSTRAP_ENV_VAR in text, f"{name} does not name the bootstrap variable"
def test_compose_passes_the_bootstrap_variable_through():
"""The docs say "set it in .env"; that only works if compose forwards it.
Without this line the instruction is silently inert — the operator sets the
variable, the container never sees it, and the failure is identical to not
having set it at all.
"""
from backend.app.services.credential_crypto import _BOOTSTRAP_ENV_VAR
compose = (_REPO_ROOT / "docker-compose.yml").read_text()
assert f"{_BOOTSTRAP_ENV_VAR}: ${{{_BOOTSTRAP_ENV_VAR}" in compose
# --- the first-boot race -----------------------------------------------------
def test_every_process_bootstrapping_at_once_ends_up_with_the_same_key(tmp_path):
"""Run 7368's smoke, and the reason the key write is a link rather than a
write.
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 gave a
second process an `exists()` of True and a `read_bytes()` of b"":
ValueError: Fernet key must be 32 url-safe base64-encoded bytes.
Threads rather than processes because the failure is about the ORDER of
two filesystem operations, which threads reproduce and which a subprocess
fixture would make this suite pay for on every run.
What is asserted is AGREEMENT, not merely that nobody crashed: a race that
left each worker holding its own key would pass a "did it raise" check and
produce a system where a credential written by one worker cannot be read
by the next.
"""
import threading
path = tmp_path / "secrets" / "credential_key.b64"
keys: list[bytes] = []
errors: list[Exception] = []
start = threading.Barrier(8)
def bootstrap():
try:
start.wait(timeout=5)
CredentialCrypto(path, bootstrap_ok=True)
keys.append(path.read_bytes())
except Exception as exc: # noqa: BLE001 — recorded, asserted below
errors.append(exc)
threads = [threading.Thread(target=bootstrap) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
assert errors == []
assert len(keys) == 8
assert len(set(keys)) == 1, "the workers disagree about the credential key"
def test_the_bootstrap_leaves_no_temp_file_behind(tmp_path):
"""The temp file is an implementation detail of the atomic write and must
not survive it — a stray `.credential_key.b64.<pid>.tmp` in the secrets
directory is a copy of the key with nothing guarding it."""
path = tmp_path / "secrets" / "credential_key.b64"
CredentialCrypto(path, bootstrap_ok=True)
assert sorted(p.name for p in path.parent.iterdir()) == ["credential_key.b64"]