mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-30 15:26:14 +00:00
fix(settings): keep npub consistent with a store-only nsec on initialize
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
56a67c0a86
commit
4872c318d5
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user