From 18f09d49d43c21619ca3bf519254d7b69ec02353 Mon Sep 17 00:00:00 2001 From: archipelago Date: Sat, 8 Aug 2026 09:51:32 -0400 Subject: [PATCH] fix(indeehub): NIP-98 auth must prove an identity, never mint one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sign_nip98` read the node's pubkey through `get_nostr_pubkey`, which goes through `load_or_create_nostr_keys`. On a node with no Nostr identity that does not fail — it GENERATES a keypair, writes the secret to disk, and signs with it. So an IndeeHub auth header could quietly create a new node identity as a side effect, then authenticate as a stranger holding a key nobody has ever seen. The `.context("node has no Nostr identity")` guarding the call could never fire, because the call could never fail that way. `nostr_identity_exists` is the missing distinction: bootstrap may create, but anything AUTHENTICATING as this node must prove the identity it already has. sign_nip98 now gates on it and bails loudly. Caught by `a_nip98_event_names_the_exact_url_and_method`, which asserts exactly this ("must fail loudly rather than sign something empty") and had been failing since the file landed in 58c759c1 — invisible because the earlier runs on this branch filtered to `container::`. Full bin suite 1381/1381. Co-Authored-By: Claude Opus 5 (1M context) --- core/archipelago/src/content_indeehub.rs | 10 ++++++++++ core/archipelago/src/nostr_discovery.rs | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/core/archipelago/src/content_indeehub.rs b/core/archipelago/src/content_indeehub.rs index 043c60b3..8ff96ce5 100644 --- a/core/archipelago/src/content_indeehub.rs +++ b/core/archipelago/src/content_indeehub.rs @@ -157,6 +157,16 @@ async fn nostr_session(client: &reqwest::Client, data_dir: &Path) -> Result Result { let identity_dir = data_dir.join("identity"); + // Prove the identity this node already has; never mint one here. + // `get_nostr_pubkey` goes through `load_or_create_nostr_keys`, so on a node + // without an identity it would GENERATE a keypair, sign with it, and write + // the secret to disk — an HTTP auth header quietly creating a new node + // identity, and authenticating to IndeeHub as a stranger with a key nobody + // has ever seen. The `.context("node has no Nostr identity")` below could + // never fire because of it. + if !nostr_discovery::nostr_identity_exists(&identity_dir).await { + anyhow::bail!("node has no Nostr identity"); + } let pubkey = nostr_discovery::get_nostr_pubkey(&identity_dir) .await .context("node has no Nostr identity")?; diff --git a/core/archipelago/src/nostr_discovery.rs b/core/archipelago/src/nostr_discovery.rs index 13a84753..f37b89ff 100644 --- a/core/archipelago/src/nostr_discovery.rs +++ b/core/archipelago/src/nostr_discovery.rs @@ -26,6 +26,20 @@ const D_TAG: &str = "archipelago-node"; /// Relays we previously published to (for one-time revocation overwrite only) const LEGACY_RELAYS: &[&str] = &["wss://relay.damus.io", "wss://relay.nostr.info"]; +/// Does this node already have a Nostr identity on disk? +/// +/// `load_or_create_nostr_keys` mints one when it finds none. That is right at +/// bootstrap and wrong for anything that AUTHENTICATES as this node: signing +/// with a key generated on the spot proves nothing, presents the node as a +/// stranger to whatever it is authenticating to, and writes a new secret to +/// disk as a side effect of what the caller thought was a read. Callers that +/// must prove an EXISTING identity gate on this first and fail loudly. +pub(crate) async fn nostr_identity_exists(identity_dir: &Path) -> bool { + fs::try_exists(identity_dir.join(NOSTR_SECRET_FILE)) + .await + .unwrap_or(false) +} + /// Load or create Nostr keys (secp256k1) for node discovery. pub(crate) async fn load_or_create_nostr_keys(identity_dir: &Path) -> Result { let secret_path = identity_dir.join(NOSTR_SECRET_FILE);