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 ----