diff --git a/routstr/core/settings.py b/routstr/core/settings.py index b3165cc..542aa80 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 077a5f7..18899de 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,