From c40723c1e2eff064fb1b502e6be1ecaee354df25 Mon Sep 17 00:00:00 2001 From: Jeroen Ubbink Date: Fri, 26 Jun 2026 14:32:46 +0200 Subject: [PATCH] feat(settings): bootstrap secrets at startup and require ROUTSTR_SECRET_KEY for stored nsec Persist and load admin password and nsec from the encrypted Secret store on boot: generate a temporary admin password on first run (logged once), encrypt a provided nsec, and fail fast if a stored nsec cannot be decrypted with the current key. Stop clobbering live secret settings with empty env values. Co-Authored-By: Claude Opus 4.8 --- routstr/core/main.py | 15 +- routstr/core/settings.py | 206 ++++++++++++++-- tests/integration/test_secret_bootstrap.py | 268 +++++++++++++++++++++ tests/unit/test_settings.py | 108 ++++++++- 4 files changed, 564 insertions(+), 33 deletions(-) create mode 100644 tests/integration/test_secret_bootstrap.py diff --git a/routstr/core/main.py b/routstr/core/main.py index c9598a1c..ca1a5a93 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -37,7 +37,7 @@ from .exceptions import general_exception_handler, http_exception_handler from .logging import get_logger, setup_logging from .middleware import LoggingMiddleware from .not_found import _NOT_FOUND_HTML, not_found_catch_all # noqa: F401 -from .settings import SettingsService +from .settings import SettingsService, bootstrap_secrets from .settings import settings as global_settings from .version import __version__ @@ -85,17 +85,20 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: # Initialize application settings (env -> computed -> DB precedence) async with create_session() as session: + # Move secrets into the encrypted/hashed store and decrypt the nsec + # into the in-memory settings BEFORE initializing settings: the + # initialize step strips secrets from the persisted blob, so legacy + # plaintext (env or old blob) must be migrated into the Secret store + # first or the only copy of a blob-only secret would be lost. + # Generates and logs an admin password on a fresh node; fails fast if + # a stored secret can't be decrypted. + await bootstrap_secrets(session) s = await SettingsService.initialize(session) if s.reset_reserved_balance_on_startup: from .db import reset_all_reserved_balances await reset_all_reserved_balances(session) - if not s.admin_password: - logger.warning( - f"Admin password is not set. Visit {s.http_url or 'http://localhost:8000'}/admin to set the password." - ) - # Apply app metadata from settings try: app.title = s.name diff --git a/routstr/core/settings.py b/routstr/core/settings.py index 3a144e10..8b779e42 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio import json import os +import secrets +import time from datetime import datetime, timezone from typing import Any @@ -26,7 +28,6 @@ class Settings(BaseSettings): # Core upstream_base_url: str = Field(default="", env="UPSTREAM_BASE_URL") upstream_api_key: str = Field(default="", env="UPSTREAM_API_KEY") - admin_password: str = Field(default="", env="ADMIN_PASSWORD") # Node info name: str = Field(default="ARoutstrNode", env="NAME") @@ -132,10 +133,67 @@ def _normalize_settings_data(data: dict[str, Any]) -> dict[str, Any]: return normalized +# Secrets are credentials, not config: they live in the encrypted/hashed Secret +# store (and decrypted in-memory for runtime use), never in the persisted +# settings blob. ``admin_password`` is gone from the model entirely; +# ``nsec``/``upstream_api_key`` remain live fields but are stripped from every +# blob write so they are never written back to plaintext. See +# ``bootstrap_secrets`` and ``routstr.core.vault``. +SECRET_FIELDS = frozenset({"admin_password", "nsec", "upstream_api_key"}) + + +def _strip_secret_fields(data: dict[str, Any]) -> dict[str, Any]: + """Return a copy of ``data`` without any secret fields (for persistence).""" + return {k: v for k, v in data.items() if k not in SECRET_FIELDS} + + +def _apply_to_live_settings(data: dict[str, Any]) -> None: + """Apply ``data`` onto the live ``settings`` for all in-process importers. + + Secrets are owned by ``bootstrap_secrets`` (which decrypts the nsec into + memory before this runs) — they are never persisted to the blob, so ``data`` + re-derived from the secret-free blob carries empty secret values. Skip those + empty overwrites so a live secret is never clobbered; a non-empty value + (legacy env, or a not-yet-stripped blob mid-migration) is still applied. + """ + live = settings.dict() + for k, v in data.items(): + if k in SECRET_FIELDS and not v and live.get(k): + continue + setattr(settings, k, v) + + def _compute_primary_mint(cashu_mints: list[str]) -> str: return cashu_mints[0] if cashu_mints else "https://mint.minibits.cash/Bitcoin" +def derive_npub_from_nsec(nsec: str) -> str | None: + """Derive the npub (bech32) from an nsec or 64-char hex private key, or None. + + Parsing is delegated to :func:`routstr.nostr.listing.nsec_to_keypair`, the + single place that knows the nsec/hex formats (and already returns ``None`` on + any unusable input); this only bech32-encodes the resulting public key. The + contract stays "return None on unusable input", so a bad key never crashes + boot. + """ + try: + from nostr.key import PublicKey # type: ignore + + from ..nostr.listing import nsec_to_keypair + except ImportError: + return None + + keypair = nsec_to_keypair(nsec) + if keypair is None: + return None + _privkey_hex, pubkey_hex = keypair + + try: + return PublicKey(bytes.fromhex(pubkey_hex)).bech32() + except (ValueError, AttributeError): + return None + + def resolve_bootstrap() -> Settings: base = Settings() # Reads env with custom parse_env_var # Back-compat env mapping @@ -190,23 +248,9 @@ def resolve_bootstrap() -> Settings: pass # Derive NPUB from NSEC if not provided if not base.npub and base.nsec: - try: - from nostr.key import PrivateKey # type: ignore - - if base.nsec.startswith("nsec"): - pk = PrivateKey.from_nsec(base.nsec) - elif len(base.nsec) == 64: - pk = PrivateKey(bytes.fromhex(base.nsec)) - else: - pk = None - if pk is not None: - try: - base.npub = pk.public_key.bech32() - except Exception: - # Fallback to hex if bech32 not available - base.npub = pk.public_key.hex() - except Exception: - pass + npub = derive_npub_from_nsec(base.nsec) + if npub: + base.npub = npub if not base.cors_origins: base.cors_origins = ["*"] if not base.primary_mint: @@ -256,15 +300,14 @@ class SettingsService: text( "INSERT INTO settings (id, data, updated_at) VALUES (1, :data, :updated_at)" ).bindparams( - data=json.dumps(env_resolved.dict()), + data=json.dumps(_strip_secret_fields(env_resolved.dict())), updated_at=datetime.now(timezone.utc), ) ) await db_session.commit() cls._current = settings # Update the existing instance in-place for all live importers - for k, v in env_resolved.dict().items(): - setattr(settings, k, v) + _apply_to_live_settings(env_resolved.dict()) return cls._current db_id, db_data, _updated_at = row @@ -291,20 +334,24 @@ class SettingsService: merged_dict.get("cashu_mints", []) ) - if db_json_raw != merged_dict: + # Persist without secrets; compare against the stripped target so a + # legacy blob that still carries plaintext secrets gets rewritten + # (and thereby sunset) even when its non-secret values are unchanged. + persisted = _strip_secret_fields(merged_dict) + if db_json_raw != persisted: await db_session.exec( # type: ignore text( "UPDATE settings SET data = :data, updated_at = :updated_at WHERE id = 1" ).bindparams( - data=json.dumps(merged_dict), + data=json.dumps(persisted), updated_at=datetime.now(timezone.utc), ) ) await db_session.commit() # Update the existing instance in-place for all live importers - for k, v in merged_dict.items(): - setattr(settings, k, v) + # (keeps the decrypted nsec/upstream_api_key live in memory). + _apply_to_live_settings(merged_dict) cls._current = settings return cls._current @@ -326,7 +373,7 @@ class SettingsService: text( "UPDATE settings SET data = :data, updated_at = :updated_at WHERE id = 1" ).bindparams( - data=json.dumps(candidate.dict()), + data=json.dumps(_strip_secret_fields(candidate.dict())), updated_at=datetime.now(timezone.utc), ) ) @@ -355,3 +402,110 @@ class SettingsService: setattr(settings, k, v) cls._current = settings return settings + + +async def _read_raw_settings_blob(db_session: AsyncSession) -> dict[str, Any]: + """Best-effort read of the raw persisted settings JSON (may not exist yet).""" + from sqlmodel import text + + try: + result = await db_session.exec( # type: ignore + text("SELECT data FROM settings WHERE id = 1") + ) + row = result.first() + except Exception: + return {} + if row is None: + return {} + (data_str,) = row + try: + data = json.loads(data_str) if isinstance(data_str, str) else dict(data_str) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _legacy_plaintext( + raw_blob: dict[str, Any], env_name: str, blob_key: str +) -> str | None: + """Legacy plaintext for a secret: env first, then the old settings blob.""" + env_value = os.environ.get(env_name) + if env_value: + return env_value + blob_value = raw_blob.get(blob_key) + if isinstance(blob_value, str) and blob_value: + return blob_value + return None + + +async def bootstrap_secrets(db_session: AsyncSession) -> None: + """Move node secrets into the encrypted/hashed Secret store at startup. + + Per secret: + * column already set -> use it (the nsec is decrypted into the in-memory + ``settings``; a wrong ROUTSTR_SECRET_KEY surfaces as a clear fail-fast). + * column empty but legacy plaintext exists (env, or the old settings + blob) -> transform it (hash the password / encrypt the nsec) into the + column. + * nothing (admin password only) -> generate a strong random password, + hash it, and log it once with the /admin URL. + """ + from cryptography.fernet import InvalidToken + + from . import vault + from .db import get_secret + from .logging import get_logger + + logger = get_logger(__name__) + + raw_blob = await _read_raw_settings_blob(db_session) + secret = await get_secret(db_session) + changed = False + + # Admin password — one-way scrypt hash. + if secret.admin_password_hash is None: + legacy_password = _legacy_plaintext( + raw_blob, "ADMIN_PASSWORD", "admin_password" + ) + if legacy_password: + secret.admin_password_hash = vault.hash_password(legacy_password) + else: + generated = secrets.token_urlsafe(24) + secret.admin_password_hash = vault.hash_password(generated) + admin_url = (settings.http_url or "http://localhost:8000").rstrip("/") + logger.warning( + "No admin password set; generated a temporary one (shown only " + "now): %s\nLog in at %s/admin and change it from the dashboard " + "settings.", + generated, + admin_url, + ) + changed = True + + # Nostr nsec — reversible Fernet encryption. + if secret.encrypted_nsec is not None: + try: + settings.nsec = vault.decrypt(secret.encrypted_nsec) + except InvalidToken as exc: + raise RuntimeError( + "Stored nsec cannot be decrypted with the current " + "ROUTSTR_SECRET_KEY. The key changed, or this database came from " + "another node. Restore the original ROUTSTR_SECRET_KEY to recover." + ) from exc + else: + legacy_nsec = _legacy_plaintext(raw_blob, "NSEC", "nsec") + if legacy_nsec: + secret.encrypted_nsec = vault.encrypt(legacy_nsec) + settings.nsec = legacy_nsec + changed = True + + # Derive npub from whatever nsec we now hold, if not already known. + if settings.nsec and not settings.npub: + npub = derive_npub_from_nsec(settings.nsec) + if npub: + settings.npub = npub + + if changed: + secret.updated_at = int(time.time()) + db_session.add(secret) + await db_session.commit() diff --git a/tests/integration/test_secret_bootstrap.py b/tests/integration/test_secret_bootstrap.py new file mode 100644 index 00000000..3b45fd61 --- /dev/null +++ b/tests/integration/test_secret_bootstrap.py @@ -0,0 +1,268 @@ +"""Tests for ``bootstrap_secrets`` — moving node secrets into the Secret store. + +Specifies the per-secret bootstrap that runs at startup (issue #553). For both +the admin password and the nsec it follows the same three branches: use the +column if already set, otherwise migrate any legacy plaintext (env first, then +the old settings blob), otherwise — admin password only — generate and log one. +A column written under a different ROUTSTR_SECRET_KEY fails fast rather than +silently corrupting state. +""" + +import json +from contextlib import asynccontextmanager +from typing import Any, AsyncGenerator + +import pytest +from sqlmodel import text +from sqlmodel.ext.asyncio.session import AsyncSession + +from routstr.core import vault +from routstr.core.db import get_secret +from routstr.core.settings import ( + SettingsService, + bootstrap_secrets, + derive_npub_from_nsec, + settings, +) + +# Valid Fernet keys; must match the suite default in tests/conftest.py. +TEST_SECRET_KEY = "l_Tkp-7xmjcQ-IFhr6qhILrU8HPRbEmYMrfSbo_5srU=" +TEST_SECRET_KEY_ALT = "_Teyrky_iToeDK51Tj1FsI9MJ340_cqKGmeher-a7MQ=" + +NSEC_HEX = "1" * 64 + + +@pytest.fixture +def clean_secret_env(monkeypatch: pytest.MonkeyPatch) -> None: + """No ambient legacy secrets, and a known in-memory settings baseline.""" + monkeypatch.delenv("ADMIN_PASSWORD", raising=False) + monkeypatch.delenv("NSEC", raising=False) + monkeypatch.setenv("ROUTSTR_SECRET_KEY", TEST_SECRET_KEY) + monkeypatch.setattr(settings, "nsec", "") + monkeypatch.setattr(settings, "npub", "") + monkeypatch.setattr(settings, "http_url", "") + + +async def _create_settings_blob(session: AsyncSession, data: dict) -> None: + await session.exec( # type: ignore + text( + "CREATE TABLE IF NOT EXISTS settings " + "(id INTEGER PRIMARY KEY, data TEXT NOT NULL, " + "updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)" + ) + ) + await session.exec( # type: ignore + text("INSERT INTO settings (id, data) VALUES (1, :data)").bindparams( + data=json.dumps(data) + ) + ) + await session.commit() + + +# --- admin password -------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_generates_admin_password_when_none( + clean_secret_env: None, integration_session: AsyncSession +) -> None: + await bootstrap_secrets(integration_session) + secret = await get_secret(integration_session) + assert secret.admin_password_hash is not None + assert secret.admin_password_hash.startswith("scrypt:") + + +@pytest.mark.asyncio +async def test_admin_password_generation_is_idempotent( + clean_secret_env: None, integration_session: AsyncSession +) -> None: + await bootstrap_secrets(integration_session) + first = (await get_secret(integration_session)).admin_password_hash + await bootstrap_secrets(integration_session) + second = (await get_secret(integration_session)).admin_password_hash + assert first is not None and first == second + + +@pytest.mark.asyncio +async def test_hashes_legacy_admin_password_from_env( + clean_secret_env: None, + integration_session: AsyncSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("ADMIN_PASSWORD", "hunter2") + await bootstrap_secrets(integration_session) + secret = await get_secret(integration_session) + assert secret.admin_password_hash is not None + assert vault.verify_password("hunter2", secret.admin_password_hash) is True + + +@pytest.mark.asyncio +async def test_hashes_legacy_admin_password_from_blob( + clean_secret_env: None, integration_session: AsyncSession +) -> None: + # No ADMIN_PASSWORD in env, but the old settings blob carries one. + await _create_settings_blob(integration_session, {"admin_password": "blobpw"}) + await bootstrap_secrets(integration_session) + secret = await get_secret(integration_session) + assert vault.verify_password("blobpw", secret.admin_password_hash or "") is True + + +# --- nsec ------------------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_encrypts_legacy_nsec_from_env_and_derives_npub( + clean_secret_env: None, + integration_session: AsyncSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("NSEC", NSEC_HEX) + await bootstrap_secrets(integration_session) + secret = await get_secret(integration_session) + assert secret.encrypted_nsec is not None + assert vault.is_encrypted(secret.encrypted_nsec) is True + assert vault.decrypt(secret.encrypted_nsec) == NSEC_HEX + # In-memory runtime value is the decrypted nsec, and npub is derived from it. + assert settings.nsec == NSEC_HEX + assert settings.npub == derive_npub_from_nsec(NSEC_HEX) + + +@pytest.mark.asyncio +async def test_decrypts_existing_nsec_column( + clean_secret_env: None, integration_session: AsyncSession +) -> None: + secret = await get_secret(integration_session) + secret.encrypted_nsec = vault.encrypt(NSEC_HEX) + integration_session.add(secret) + await integration_session.commit() + stored = secret.encrypted_nsec + + await bootstrap_secrets(integration_session) + reloaded = await get_secret(integration_session) + assert settings.nsec == NSEC_HEX + # The column is reused, not re-encrypted. + assert reloaded.encrypted_nsec == stored + + +@pytest.mark.asyncio +async def test_fail_fast_when_nsec_encrypted_with_different_key( + clean_secret_env: None, + integration_session: AsyncSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Encrypt the column under the alternate key, then bootstrap under the + # suite key -> the value cannot be decrypted -> clear startup failure. + monkeypatch.setenv("ROUTSTR_SECRET_KEY", TEST_SECRET_KEY_ALT) + secret = await get_secret(integration_session) + secret.encrypted_nsec = vault.encrypt(NSEC_HEX) + integration_session.add(secret) + await integration_session.commit() + + monkeypatch.setenv("ROUTSTR_SECRET_KEY", TEST_SECRET_KEY) + with pytest.raises(RuntimeError, match="ROUTSTR_SECRET_KEY"): + await bootstrap_secrets(integration_session) + + +# --- boot ordering: rescue legacy blob secrets before they are stripped ---- + + +@pytest.mark.asyncio +async def test_blob_only_nsec_is_migrated_before_blob_is_stripped( + clean_secret_env: None, integration_session: AsyncSession +) -> None: + # Legacy node whose nsec lives ONLY in the settings blob (never in env). + # bootstrap_secrets must run *before* SettingsService.initialize strips the + # blob, or the only copy of the secret would be lost. + await _create_settings_blob( + integration_session, {"nsec": NSEC_HEX, "name": "LegacyNode"} + ) + + await bootstrap_secrets(integration_session) + await SettingsService.initialize(integration_session) + + secret = await get_secret(integration_session) + # The plaintext nsec has been moved into the encrypted Secret store... + assert secret.encrypted_nsec is not None + assert vault.decrypt(secret.encrypted_nsec) == NSEC_HEX + assert settings.nsec == NSEC_HEX + # ...and stripped from the persisted settings blob. + row = await integration_session.exec( # type: ignore + text("SELECT data FROM settings WHERE id = 1") + ) + blob = json.loads(row.first()[0]) + assert "nsec" not in blob + assert blob["name"] == "LegacyNode" + + +@pytest.mark.asyncio +async def test_initialize_does_not_clobber_store_only_nsec( + clean_secret_env: None, integration_session: AsyncSession +) -> None: + # Steady state after migration: the nsec lives ONLY in the encrypted Secret + # store (NSEC removed from env, blob already stripped on a previous boot). + # bootstrap decrypts it into memory; initialize then re-derives settings from + # the secret-free blob and must NOT wipe the live nsec back to empty (or the + # node would silently stop signing Nostr announcements). + await _create_settings_blob(integration_session, {"name": "LegacyNode"}) + secret = await get_secret(integration_session) + secret.encrypted_nsec = vault.encrypt(NSEC_HEX) + integration_session.add(secret) + await integration_session.commit() + + await bootstrap_secrets(integration_session) + assert settings.nsec == NSEC_HEX # bootstrap decrypted it into memory + + await SettingsService.initialize(integration_session) + # The live secret survives initialize even though no env/blob carries it... + assert settings.nsec == NSEC_HEX + # ...and is still never written back to the persisted blob. + row = await integration_session.exec( # type: ignore + text("SELECT data FROM settings WHERE id = 1") + ) + assert "nsec" not in json.loads(row.first()[0]) + + +@pytest.mark.asyncio +async def test_startup_runs_bootstrap_before_settings_initialize( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # The two tests above prove the migration outcome *given* the call order; + # they hardcode that order themselves. This one guards the order at its real + # call site — the application lifespan — so a reorder in main.py (which would + # strip a blob-only secret before bootstrap could rescue it) is caught. + import routstr.core.main as main + + order: list[str] = [] + + class _Abort(Exception): + pass + + @asynccontextmanager + async def fake_create_session() -> AsyncGenerator[None, None]: + yield None + + async def fake_bootstrap(session: Any) -> None: + order.append("bootstrap") + + async def fake_initialize(session: Any) -> None: + order.append("initialize") + # Stop startup here, before the background-task fan-out (prices, nostr, + # upstreams) that we don't want to run in a unit test. + raise _Abort() + + async def noop_init_db() -> None: + return None + + monkeypatch.setattr(main, "configure_litellm", lambda: None) + monkeypatch.setattr(main, "register_deepseek_v4_pricing", lambda: None) + monkeypatch.setattr(main, "run_migrations", lambda: None) + monkeypatch.setattr(main, "init_db", noop_init_db) + monkeypatch.setattr(main, "create_session", fake_create_session) + monkeypatch.setattr(main, "bootstrap_secrets", fake_bootstrap) + monkeypatch.setattr(main.SettingsService, "initialize", fake_initialize) + + with pytest.raises(_Abort): + async with main.lifespan(main.app): + pass + + assert order == ["bootstrap", "initialize"] diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index ccdb71bc..ffe6352f 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -1,3 +1,4 @@ +import json import os import pytest @@ -6,7 +7,15 @@ from sqlalchemy.ext.asyncio import create_async_engine from sqlmodel import text from sqlmodel.ext.asyncio.session import AsyncSession -from routstr.core.settings import Settings, SettingsService +from routstr.core.settings import Settings, SettingsService, settings + +NSEC_HEX = "1" * 64 + + +async def _read_settings_blob(session: AsyncSession) -> dict: + """Return the raw persisted settings JSON (id=1) as a dict.""" + row = await session.exec(text("SELECT data FROM settings WHERE id = 1")) # type: ignore + return json.loads(row.first()[0]) @pytest.mark.asyncio @@ -120,3 +129,100 @@ async def test_settings_initialize_discards_unknown_keys() -> None: assert '"enable_analytics_sharing": true' in stored_data assert "nostr_analytics_enabled" not in stored_data assert "unknown_key" not in stored_data + + +# ── Secret fields are never written to the settings blob (issue #553) ──────── + + +def test_settings_model_drops_admin_password_field() -> None: + # admin_password now lives only as a one-way hash in the Secret store; it is + # no longer a settings field at all. + assert "admin_password" not in Settings.__fields__ + # nsec and upstream_api_key remain runtime values held in memory. + assert "nsec" in Settings.__fields__ + assert "upstream_api_key" in Settings.__fields__ + + +@pytest.mark.asyncio +async def test_secret_fields_kept_in_memory_but_not_persisted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("NSEC", NSEC_HEX) + monkeypatch.setenv("UPSTREAM_API_KEY", "sk-upstream") + # Reset the live globals so monkeypatch reverts them after the test. + monkeypatch.setattr(settings, "nsec", "") + monkeypatch.setattr(settings, "upstream_api_key", "") + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with AsyncSession(engine, expire_on_commit=False) as session: + s = await SettingsService.initialize(session) + + # Runtime consumers still see the live secret values. + assert s.nsec == NSEC_HEX + assert s.upstream_api_key == "sk-upstream" + + # ...but they are never written to the settings blob. + blob = await _read_settings_blob(session) + assert "nsec" not in blob + assert "upstream_api_key" not in blob + assert "admin_password" not in blob + # Non-secret derived/public values are still persisted. + assert blob["npub"] == s.npub + + +@pytest.mark.asyncio +async def test_existing_blob_secrets_are_stripped_on_initialize( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("NSEC", raising=False) + monkeypatch.delenv("UPSTREAM_API_KEY", raising=False) + monkeypatch.delenv("ADMIN_PASSWORD", raising=False) + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with AsyncSession(engine, expire_on_commit=False) as session: + await SettingsService.initialize(session) + + # Simulate a legacy row that still carries plaintext secrets in the blob. + await session.exec( # type: ignore + text("UPDATE settings SET data = :d WHERE id = 1").bindparams( + d=json.dumps( + { + "name": "LegacyNode", + "admin_password": "pw", + "nsec": NSEC_HEX, + "upstream_api_key": "sk-legacy", + } + ) + ) + ) + await session.commit() + + await SettingsService.initialize(session) + + blob = await _read_settings_blob(session) + assert "admin_password" not in blob + assert "nsec" not in blob + assert "upstream_api_key" not in blob + # Non-secret values survive the migration. + assert blob["name"] == "LegacyNode" + + +@pytest.mark.asyncio +async def test_update_does_not_persist_secret_fields( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("NSEC", NSEC_HEX) + monkeypatch.setenv("UPSTREAM_API_KEY", "sk-upstream") + monkeypatch.setattr(settings, "nsec", "") + monkeypatch.setattr(settings, "upstream_api_key", "") + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with AsyncSession(engine, expire_on_commit=False) as session: + await SettingsService.initialize(session) + await SettingsService.update({"name": "Updated"}, session) + + blob = await _read_settings_blob(session) + assert blob["name"] == "Updated" + assert "nsec" not in blob + assert "upstream_api_key" not in blob + assert "admin_password" not in blob