feat(crypto): fail loudly on unpersistable secret key; flag undecryptable secrets
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:
+16
-1
@@ -1,11 +1,14 @@
|
||||
# steward/app.py
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from quart import Quart, render_template
|
||||
from .config import load_bootstrap
|
||||
from .database import init_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_app(
|
||||
config_path: Path | str | None = None,
|
||||
@@ -50,9 +53,17 @@ def create_app(
|
||||
# ── 3b. Encryption-at-rest: bind the encryptor, then encrypt legacy secrets ─
|
||||
if not testing:
|
||||
from .core.crypto import init_crypto
|
||||
from .core.settings import migrate_plaintext_secrets
|
||||
from .core.settings import migrate_plaintext_secrets, scan_undecryptable_secrets
|
||||
init_crypto(app.config["SECRET_KEY"])
|
||||
migrate_plaintext_secrets(app.config["DATABASE_URL"])
|
||||
# Flag any secrets sealed under a now-lost key (see the admin banner).
|
||||
undecryptable = scan_undecryptable_secrets(app.config["DATABASE_URL"])
|
||||
if undecryptable:
|
||||
logger.warning(
|
||||
"%d stored secret(s) cannot be decrypted with the current app key "
|
||||
"(re-enter them in Settings): %s",
|
||||
len(undecryptable), ", ".join(undecryptable),
|
||||
)
|
||||
|
||||
# ── 4. Load all settings from DB → populate app.config ────────────────────
|
||||
if not testing:
|
||||
@@ -167,6 +178,7 @@ def create_app(
|
||||
@app.context_processor
|
||||
def _inject_plugin_failures():
|
||||
from .core.plugin_manager import get_plugin_failures
|
||||
from .core.settings import get_undecryptable_secrets
|
||||
# enabled_plugins lets templates gate cross-plugin embeds (e.g. the
|
||||
# Hosts hub only fetches the docker fragment when docker is enabled),
|
||||
# avoiding a 404 to a route whose blueprint isn't registered.
|
||||
@@ -177,6 +189,9 @@ def create_app(
|
||||
return {
|
||||
"plugin_failures": get_plugin_failures(),
|
||||
"enabled_plugins": enabled_plugins,
|
||||
# Read from an in-memory cache (no DB hit per render); kept current by
|
||||
# the startup scan + set_setting's discard-on-write.
|
||||
"undecryptable_secrets": get_undecryptable_secrets(),
|
||||
}
|
||||
|
||||
# ── 11. Share-token middleware ─────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user