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:
@@ -280,6 +280,34 @@ function setTimeRange(val) {
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if undecryptable_secrets and session.user_role == 'admin' %}
|
||||
{% set _sec_tab = {
|
||||
'smtp.password': '/settings/notifications/',
|
||||
'oidc.client_secret': '/settings/auth/',
|
||||
'ldap.bind_password': '/settings/auth/',
|
||||
'ansible.ssh_private_key': '/settings/ansible/',
|
||||
'ansible.become_password': '/settings/ansible/',
|
||||
'ansible.vault_password': '/settings/ansible/',
|
||||
} %}
|
||||
<div style="background:color-mix(in srgb,var(--red) 12%,var(--bg-elevated));
|
||||
border:1px solid color-mix(in srgb,var(--red) 35%,var(--border));
|
||||
border-radius:6px;padding:0.6rem 1rem;margin-bottom:1rem;
|
||||
font-size:0.84rem;display:flex;align-items:flex-start;gap:0.6rem;">
|
||||
<span style="color:var(--red);flex-shrink:0;">⚠</span>
|
||||
<span>
|
||||
<strong style="color:var(--text);">
|
||||
{{ undecryptable_secrets | length }} stored secret{{ 's' if undecryptable_secrets | length != 1 }}
|
||||
can’t be decrypted
|
||||
</strong>
|
||||
— the app secret key changed, so {{ 'they' if undecryptable_secrets | length != 1 else 'it' }}
|
||||
must be re-entered:
|
||||
{% for k in undecryptable_secrets -%}
|
||||
{%- if _sec_tab.get(k) %}<a href="{{ _sec_tab[k] }}" style="color:var(--accent);">{{ k }}</a>
|
||||
{%- else %}<code>{{ k }}</code>{% endif %}{% if not loop.last %}, {% endif %}
|
||||
{%- endfor %}.
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user