fix(indeehub): NIP-98 auth must prove an identity, never mint one

`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) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-08 09:51:32 -04:00
co-authored by Claude Opus 5
parent 78f0370afc
commit 18f09d49d4
2 changed files with 24 additions and 0 deletions
+10
View File
@@ -157,6 +157,16 @@ async fn nostr_session(client: &reqwest::Client, data_dir: &Path) -> Result<Stri
/// a different endpoint, so they are set from the same values used to send.
async fn sign_nip98(data_dir: &Path, url: &str, method: &str) -> Result<serde_json::Value> {
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")?;
+14
View File
@@ -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<Keys> {
let secret_path = identity_dir.join(NOSTR_SECRET_FILE);