From 4872c318d58834182fab58e77ce89f0ecc2c4890 Mon Sep 17 00:00:00 2001 From: Jeroen Ubbink Date: Sat, 27 Jun 2026 20:36:07 +0200 Subject: [PATCH] fix(settings): keep npub consistent with a store-only nsec on initialize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the nsec lives only in the encrypted Secret store (env carries no NSEC) and the settings blob holds no npub, bootstrap_secrets decrypted the nsec and derived the npub into memory, but SettingsService.initialize then re-derived settings from the npub-less blob and overwrote the live npub back to empty — leaving a private key with no matching public key, so the node silently stopped announcing a usable Nostr identity. Derive npub from the live nsec during initialize when the merged settings carry none, so the public key stays consistent with the identity and is persisted to the blob. Co-Authored-By: Claude Opus 4.8 --- routstr/core/settings.py | 10 +++++++ tests/integration/test_secret_bootstrap.py | 33 ++++++++++++++++++++++ 2 files changed, 43 insertions(+) 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,