From 56a67c0a86e56d2cccd020bbadd2b3e7ae6e51ab Mon Sep 17 00:00:00 2001 From: Jeroen Ubbink Date: Sat, 27 Jun 2026 20:33:51 +0200 Subject: [PATCH] fix(settings): fail fast when an nsec is set but ROUTSTR_SECRET_KEY is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encryption of the Nostr identity at rest is mandatory. When a legacy nsec is present (env or blob) but no ROUTSTR_SECRET_KEY is set, bootstrap previously fell into vault.encrypt and surfaced its generic "key not set" error. Raise an explicit, nsec-contextual error first so the boot failure is intentional and actionable — it names the missing key and prints the generation command — rather than relying on vault throwing incidentally. No secret is dropped: the node refuses to start until the operator sets the key. Co-Authored-By: Claude Opus 4.8 --- routstr/core/settings.py | 11 +++++++++++ tests/integration/test_secret_bootstrap.py | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/routstr/core/settings.py b/routstr/core/settings.py index b8eacdd6..728a42d3 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -498,6 +498,17 @@ async def bootstrap_secrets(db_session: AsyncSession) -> None: else: legacy_nsec = _legacy_plaintext(raw_blob, "NSEC", "nsec") if legacy_nsec: + # The node has a Nostr identity to protect. Encryption at rest is + # mandatory: without a key we fail fast (clear, actionable boot + # error) rather than silently persisting the nsec in plaintext. + if not os.environ.get("ROUTSTR_SECRET_KEY"): + raise RuntimeError( + "An nsec is configured but ROUTSTR_SECRET_KEY is not set. " + "The key is required to encrypt the Nostr identity at rest. " + "Generate one and set it in the environment:\n" + ' python -c "from cryptography.fernet import Fernet; ' + 'print(Fernet.generate_key().decode())"' + ) secret.encrypted_nsec = vault.encrypt(legacy_nsec) settings.nsec = legacy_nsec changed = True diff --git a/tests/integration/test_secret_bootstrap.py b/tests/integration/test_secret_bootstrap.py index 3b45fd61..9aa957ee 100644 --- a/tests/integration/test_secret_bootstrap.py +++ b/tests/integration/test_secret_bootstrap.py @@ -163,6 +163,27 @@ async def test_fail_fast_when_nsec_encrypted_with_different_key( await bootstrap_secrets(integration_session) +# --- encryption is mandatory: a node with an nsec needs ROUTSTR_SECRET_KEY ----- + + +@pytest.mark.asyncio +async def test_legacy_nsec_without_secret_key_fails_fast( + clean_secret_env: None, + integration_session: AsyncSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # A node with a Nostr identity must not boot without a key to encrypt it: + # encryption at rest is mandatory, not opt-in. The failure names the missing + # key and hands over the generation command rather than crashing opaquely or + # silently persisting the nsec in plaintext. No env/blob copy is dropped — the + # node refuses until the operator sets the key. + monkeypatch.delenv("ROUTSTR_SECRET_KEY", raising=False) + monkeypatch.setenv("NSEC", NSEC_HEX) + + with pytest.raises(RuntimeError, match="ROUTSTR_SECRET_KEY"): + await bootstrap_secrets(integration_session) + + # --- boot ordering: rescue legacy blob secrets before they are stripped ----