diff --git a/routstr/core/settings.py b/routstr/core/settings.py index 728a42d3..623cc11a 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -337,6 +337,16 @@ class SettingsService: merged_dict.get("cashu_mints", []) ) + # Keep npub consistent with the live nsec. bootstrap_secrets may hold + # the decrypted nsec (from the encrypted store) even when neither env + # nor the blob carries an nsec/npub; derive from that live value so + # initialize never wipes a known public key back to empty, leaving a + # private key with no matching npub. + if not merged_dict.get("npub") and settings.nsec: + derived_npub = derive_npub_from_nsec(settings.nsec) + if derived_npub: + merged_dict["npub"] = derived_npub + # 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. diff --git a/tests/integration/test_secret_bootstrap.py b/tests/integration/test_secret_bootstrap.py index 9aa957ee..4a8a51db 100644 --- a/tests/integration/test_secret_bootstrap.py +++ b/tests/integration/test_secret_bootstrap.py @@ -243,6 +243,39 @@ async def test_initialize_does_not_clobber_store_only_nsec( assert "nsec" not in json.loads(row.first()[0]) +@pytest.mark.asyncio +async def test_initialize_keeps_npub_matching_store_only_nsec( + clean_secret_env: None, integration_session: AsyncSession +) -> None: + # Steady state with mandatory encryption: the nsec lives ONLY in the + # encrypted Secret store (env carries no NSEC) and the blob has no npub. + # bootstrap decrypts the nsec and derives the npub into memory; initialize + # then re-derives settings from the npub-less blob and must NOT wipe the npub + # back to empty, or the node holds a private key with no matching public key + # and silently stops announcing a usable Nostr identity. + expected_npub = derive_npub_from_nsec(NSEC_HEX) + assert expected_npub # guard: the test key must yield a real npub + + 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.npub == expected_npub # bootstrap derived it + + await SettingsService.initialize(integration_session) + # The npub still matches the live nsec... + assert settings.nsec == NSEC_HEX + assert settings.npub == expected_npub + # ...and is persisted to the blob (it is public, not a stripped secret). + row = await integration_session.exec( # type: ignore + text("SELECT data FROM settings WHERE id = 1") + ) + assert json.loads(row.first()[0])["npub"] == expected_npub + + @pytest.mark.asyncio async def test_startup_runs_bootstrap_before_settings_initialize( monkeypatch: pytest.MonkeyPatch,