feat(crypto): fail loudly on unpersistable secret key; flag undecryptable secrets
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 47s
CI / integration (push) Successful in 2m21s
CI / publish (push) Successful in 1m2s

Follow-up to the incident where an unwritable /data made Steward silently mint a
fresh ephemeral secret key on every boot, orphaning all encrypted secrets — only
discovered when an Ansible run failed. Make both failure modes loud and visible.

- config._resolve_secret_key: if a new key must be generated but can't be
  persisted (no STEWARD_SECRET_KEY, unwritable /data), raise RuntimeError and
  refuse to start, with an actionable fix (set STEWARD_SECRET_KEY, or make /data
  writable by uid 1000). Also raises a clear error if an existing key file can't
  be read. First-run on a writable volume still generates + persists normally.
- core.settings: detect secrets stored as ciphertext that won't decrypt with the
  current key (scan_undecryptable_secrets at startup; _is_undecryptable helper).
  Cached in memory; set_setting discards a key on a fresh write so the banner
  clears without a restart.
- app.py: run the scan after migrate_plaintext_secrets, log a warning, and inject
  undecryptable_secrets into the template context.
- base.html: admin banner naming which secrets to re-enter, each linked to its
  settings tab.
- compose.deploy.yml: document the uid-1000 /data write requirement and the
  STEWARD_SECRET_KEY option for multi-node Swarm.
- Tests: secret-key resolver (reuse existing file, generate when writable, raise
  when unpersistable) and the undecryptable-secret detection helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
2026-06-19 13:52:27 -04:00
parent 4fc8c96c41
commit 0940dc6972
7 changed files with 206 additions and 11 deletions
+29
View File
@@ -0,0 +1,29 @@
"""Unit tests for undecryptable-secret detection (the admin-banner source)."""
from steward.core.crypto import encrypt_secret, init_crypto
from steward.core.settings import _is_undecryptable, get_undecryptable_secrets
def test_is_undecryptable_false_for_current_key():
init_crypto("key-A")
tok = encrypt_secret("hunter2")
assert _is_undecryptable(tok, "smtp.password") is False
def test_is_undecryptable_true_after_key_rotation():
init_crypto("key-A")
tok = encrypt_secret("hunter2") # sealed under key-A
init_crypto("key-B") # key changed/lost
assert _is_undecryptable(tok, "smtp.password") is True
def test_is_undecryptable_ignores_plaintext_and_empty():
init_crypto("key-A")
assert _is_undecryptable("", "smtp.password") is False
assert _is_undecryptable("plain-value", "smtp.password") is False
assert _is_undecryptable(None, "smtp.password") is False
def test_get_undecryptable_secrets_returns_sorted_list():
# Default (nothing scanned) is an empty list; the accessor is always safe to
# call from the template context processor.
assert isinstance(get_undecryptable_secrets(), list)