From 1045f1061ba7024782de9e571cdc32659fb38e20 Mon Sep 17 00:00:00 2001 From: Jeroen Ubbink Date: Mon, 13 Jul 2026 17:28:31 +0200 Subject: [PATCH] fix(settings): derive npub from the vault nsec, not a stale env value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit initialize() only filled npub when it was empty, so an existing node with a stale NSEC still lingering in its env/blob kept that value's derived npub even after the vault took ownership of a different nsec. The node then held the vault's private key but announced the old env key's public key — a split identity that anything reading settings.npub would broadcast. npub is a pure derivation of nsec and is never configured on its own, so derive it from the live (vault) nsec and override rather than only fill. Co-Authored-By: Claude Opus 4.8 --- routstr/core/settings.py | 15 +++++++----- tests/integration/test_secret_bootstrap.py | 27 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/routstr/core/settings.py b/routstr/core/settings.py index b3165cc2..542aa80e 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -337,12 +337,15 @@ 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: + # Keep npub consistent with the live nsec. bootstrap_secrets has + # already run and holds the single authoritative nsec (decrypted from + # the encrypted store, or freshly imported). merged_dict starts from + # the env/blob, which may carry a STALE nsec — and therefore a stale + # derived npub — after the vault took ownership. Derive from the live + # value and OVERRIDE, not just fill: otherwise the node keeps the + # vault's private key but announces the old env key's npub (npub is a + # pure derivation of nsec, never configured independently of it). + if settings.nsec: derived_npub = derive_npub_from_nsec(settings.nsec) if derived_npub: merged_dict["npub"] = derived_npub diff --git a/tests/integration/test_secret_bootstrap.py b/tests/integration/test_secret_bootstrap.py index 077a5f74..18899dea 100644 --- a/tests/integration/test_secret_bootstrap.py +++ b/tests/integration/test_secret_bootstrap.py @@ -268,6 +268,33 @@ async def test_stale_env_nsec_does_not_override_vault_nsec( assert settings.nsec == NSEC_HEX +@pytest.mark.asyncio +async def test_stale_env_nsec_does_not_split_npub_from_vault_nsec( + clean_secret_env: None, + integration_session: AsyncSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # As above, the vault owns the nsec while a stale NSEC lingers in env. The + # private key correctly comes from the vault, but the npub must too: if + # initialize derives the public key from the stale env nsec, the node ends up + # with a private key from the vault and a public key from the old env value, + # and anything reading settings.npub announces the wrong Nostr identity. + expected_npub = derive_npub_from_nsec(NSEC_HEX) + stale_npub = derive_npub_from_nsec(STALE_NSEC_HEX) + assert expected_npub and stale_npub and expected_npub != stale_npub # guard + + await set_nsec(integration_session, NSEC_HEX) + + monkeypatch.setenv("NSEC", STALE_NSEC_HEX) + await _create_settings_blob(integration_session, {"name": "LegacyNode"}) + + await bootstrap_secrets(integration_session) + await SettingsService.initialize(integration_session) + + assert settings.nsec == NSEC_HEX + assert settings.npub == expected_npub + + @pytest.mark.asyncio async def test_cleared_nsec_stays_cleared_across_reboot( clean_secret_env: None,