archy/core/archipelago/src/nostr_discovery.rs
Dorian 540836f3d6 feat: fix NIP-07 signing to use node Nostr key, add test script
Added node.nostr-sign RPC that uses the node-level Nostr key (matching
getPublicKey), fixing pubkey mismatch where identity.nostr-sign used a
different key. Updated appLauncher to call node.nostr-sign. Added
nostr_sign_hash() to nostr_discovery.rs. Created test-nip07.sh with
11 automated checks (INSTALL-02).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 03:18:45 +00:00

360 lines
13 KiB
Rust

//! Nostr node discovery: publish node identity to relays for peer discovery.
//! Uses NIP-33 replaceable events (kind 30078) with d-tag "archipelago-node".
//!
//! Security: Publishing is opt-in (ARCHIPELAGO_NOSTR_DISCOVERY_ENABLED + relays).
//! All Nostr traffic routes through Tor when ARCHIPELAGO_NOSTR_TOR_PROXY is set.
//! Legacy revocation overwrites any previously published data on old public relays.
use anyhow::{Context, Result};
use nostr_sdk::prelude::*;
use nostr_sdk::pool;
use std::net::SocketAddr;
use std::path::Path;
use tokio::fs;
/// Parse "host:port" to SocketAddr. Returns None if invalid.
fn parse_proxy_addr(s: &str) -> Option<SocketAddr> {
s.trim().parse().ok()
}
const NOSTR_SECRET_FILE: &str = "nostr_secret";
const NOSTR_PUB_FILE: &str = "nostr_pub";
const NOSTR_REVOKED_FILE: &str = "nostr_revoked";
const ARCHIPELAGO_KIND: u64 = 30078;
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",
];
/// Load or create Nostr keys (secp256k1) for node discovery.
async fn load_or_create_nostr_keys(identity_dir: &Path) -> Result<Keys> {
let secret_path = identity_dir.join(NOSTR_SECRET_FILE);
let pub_path = identity_dir.join(NOSTR_PUB_FILE);
let keys = if secret_path.exists() {
let hex_secret = fs::read_to_string(&secret_path)
.await
.context("Failed to read Nostr secret")?;
Keys::parse(hex_secret.trim()).context("Invalid Nostr secret")?
} else {
let keys = Keys::generate();
fs::create_dir_all(identity_dir)
.await
.context("Failed to create identity dir")?;
let hex = keys.secret_key().to_secret_hex();
fs::write(&secret_path, hex)
.await
.context("Failed to write Nostr secret")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::task::spawn_blocking(move || {
std::fs::set_permissions(secret_path, std::fs::Permissions::from_mode(0o600))
})
.await
.context("spawn_blocking")?
.context("Failed to set Nostr key permissions")?;
}
fs::write(&pub_path, keys.public_key().to_hex())
.await
.context("Failed to write Nostr pubkey")?;
tracing::info!("🔑 Generated Nostr discovery key");
keys
};
Ok(keys)
}
/// Load Nostr keys only if they exist (does not create). Used for revocation.
async fn load_nostr_keys_if_exists(identity_dir: &Path) -> Result<Option<Keys>> {
let secret_path = identity_dir.join(NOSTR_SECRET_FILE);
if !secret_path.exists() {
return Ok(None);
}
let hex_secret = fs::read_to_string(&secret_path)
.await
.context("Failed to read Nostr secret")?;
let keys = Keys::parse(hex_secret.trim()).context("Invalid Nostr secret")?;
Ok(Some(keys))
}
/// Publish a replaceable event with empty content to overwrite/revoke previously published data.
/// Uses NIP-33: same kind + d-tag + author = latest replaces. Sends to LEGACY_RELAYS only.
/// Requires tor_proxy to avoid leaking IP to relay operators.
fn build_nostr_client(keys: Keys, tor_proxy: Option<&str>) -> Result<Client> {
let client = if let Some(proxy_str) = tor_proxy {
let addr = parse_proxy_addr(proxy_str)
.ok_or_else(|| anyhow::anyhow!("Invalid Nostr Tor proxy: {}", proxy_str))?;
let connection = Connection::new()
.proxy(addr)
.target(ConnectionTarget::All);
let opts = ClientOptions::new().connection(connection);
Client::builder().signer(keys).opts(opts).build()
} else {
Client::new(keys)
};
Ok(client)
}
/// Publish a replaceable event with empty content to overwrite/revoke previously published data.
/// Uses NIP-33: same kind + d-tag + author = latest replaces. Sends to LEGACY_RELAYS only.
/// Only call when tor_proxy is set (avoids IP leak).
pub async fn publish_node_revocation(
identity_dir: &Path,
tor_proxy: Option<&str>,
) -> Result<()> {
let Some(keys) = load_nostr_keys_if_exists(identity_dir).await? else {
return Ok(()); // No keys = never published, nothing to revoke
};
let client = build_nostr_client(keys, tor_proxy)?;
for url in LEGACY_RELAYS {
let _ = client.add_relay(*url).await;
}
client.connect().await;
// NIP-33 replaceable: empty content overwrites previous event
let builder = EventBuilder::new(Kind::Custom(ARCHIPELAGO_KIND as u16), "{}")
.tag(Tag::identifier(D_TAG));
let _ = client.send_event_builder(builder).await;
client.disconnect().await;
Ok(())
}
/// If we have Nostr keys but haven't revoked yet, publish revocation to overwrite legacy data.
/// Uses tor_proxy if set; otherwise tries 127.0.0.1:9050 (archy-tor default). Creates nostr_revoked sentinel.
pub async fn revoke_if_needed(identity_dir: &Path, tor_proxy: Option<&str>) -> Result<()> {
let revoked_path = identity_dir.join(NOSTR_REVOKED_FILE);
if revoked_path.exists() {
return Ok(());
}
if load_nostr_keys_if_exists(identity_dir).await?.is_none() {
return Ok(());
}
// Use configured proxy or Tor default (archy-tor exposes 127.0.0.1:9050)
let proxy = tor_proxy.or(Some("127.0.0.1:9050"));
if let Err(e) = publish_node_revocation(identity_dir, proxy).await {
tracing::warn!("Nostr revocation (non-fatal): {}", e);
return Ok(());
}
fs::create_dir_all(identity_dir).await?;
fs::write(&revoked_path, "").await?;
tracing::info!("🔒 Nostr discovery data revoked (overwritten on legacy relays)");
Ok(())
}
/// Publish node identity to Nostr relays for discovery.
/// Content: { did, node_address, version }
/// Only call when relays are non-empty (opt-in).
/// When tor_proxy is set, routes through Tor to prevent IP exposure.
/// Skips if nostr_revoked sentinel exists (revocation must not be overwritten).
pub async fn publish_node_identity(
identity_dir: &Path,
did: &str,
node_address: &str,
version: &str,
relays: &[String],
tor_proxy: Option<&str>,
) -> Result<pool::Output<EventId>> {
if relays.is_empty() {
anyhow::bail!("No relays configured for Nostr discovery");
}
if identity_dir.join(NOSTR_REVOKED_FILE).exists() {
tracing::debug!("Nostr discovery: skipping publish (revoked)");
return Err(anyhow::anyhow!("Nostr discovery revoked"));
}
let keys = load_or_create_nostr_keys(identity_dir).await?;
let client = build_nostr_client(keys, tor_proxy)?;
let content = serde_json::json!({
"did": did,
"node_address": node_address,
"version": version,
})
.to_string();
for url in relays {
let _ = client.add_relay(url).await;
}
client.connect().await;
let builder = EventBuilder::new(Kind::Custom(ARCHIPELAGO_KIND as u16), content)
.tag(Tag::identifier(D_TAG));
let output = client.send_event_builder(builder).await?;
client.disconnect().await;
Ok(output)
}
/// Get Nostr public key for this node (hex).
pub async fn get_nostr_pubkey(identity_dir: &Path) -> Result<String> {
let keys = load_or_create_nostr_keys(identity_dir).await?;
Ok(keys.public_key().to_hex())
}
/// Sign a 32-byte hash with the node's Nostr Schnorr key.
pub async fn nostr_sign_hash(identity_dir: &Path, hash_hex: &str) -> Result<String> {
let keys = load_or_create_nostr_keys(identity_dir).await?;
let hash_bytes = hex::decode(hash_hex).context("Invalid hash hex")?;
if hash_bytes.len() != 32 {
anyhow::bail!("Hash must be 32 bytes");
}
let message = nostr_sdk::secp256k1::Message::from_digest(
hash_bytes.try_into().map_err(|_| anyhow::anyhow!("Invalid hash length"))?,
);
let sig = keys.sign_schnorr(&message);
Ok(sig.to_string())
}
/// Verify that our node's Nostr discovery data was revoked on the legacy relays.
/// Queries relays for our pubkey's kind 30078 events; if latest has empty content, revocation succeeded.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct RevocationStatus {
pub revoked: bool,
pub nostr_pubkey: String,
pub latest_content: Option<String>,
pub error: Option<String>,
}
pub async fn verify_revocation(
identity_dir: &Path,
tor_proxy: Option<&str>,
) -> Result<RevocationStatus> {
let keys = match load_nostr_keys_if_exists(identity_dir).await? {
Some(k) => k,
None => {
return Ok(RevocationStatus {
revoked: true,
nostr_pubkey: String::new(),
latest_content: None,
error: Some("No Nostr keys - never published".to_string()),
});
}
};
let pubkey_hex = keys.public_key().to_hex();
let anon_keys = Keys::generate();
let client = build_nostr_client(anon_keys, tor_proxy)?;
for url in LEGACY_RELAYS {
let _ = client.add_relay(*url).await;
}
client.connect().await;
let filter = Filter::new()
.kind(Kind::Custom(ARCHIPELAGO_KIND as u16))
.identifier(D_TAG)
.author(keys.public_key())
.limit(10);
let events = client
.fetch_events(filter, std::time::Duration::from_secs(15))
.await
.map(|e| e.to_vec())
.unwrap_or_default();
client.disconnect().await;
// NIP-33: latest event wins. fetch_events returns sorted by timestamp desc.
let mut events: Vec<_> = events.into_iter().collect();
events.sort_by(|a, b| b.created_at.cmp(&a.created_at));
let latest = events.into_iter().next();
let (revoked, latest_content) = match latest {
None => (true, None),
Some(ev) => {
let content = ev.content;
let is_revoked = content == "{}" || content.is_empty() || !content.contains("node_address");
(is_revoked, Some(content))
}
};
Ok(RevocationStatus {
revoked,
nostr_pubkey: pubkey_hex,
latest_content,
error: None,
})
}
/// Discovered Archipelago node from Nostr.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct DiscoveredNode {
pub did: String,
pub node_address: String,
pub onion: String,
pub pubkey: String,
pub version: String,
}
/// Query Nostr relays for other Archipelago nodes.
/// Returns empty if relays is empty (opt-in discovery).
/// When tor_proxy is set, routes through Tor to prevent IP exposure.
pub async fn discover_archipelago_nodes(
identity_dir: &Path,
relays: &[String],
tor_proxy: Option<&str>,
) -> Result<Vec<DiscoveredNode>> {
if relays.is_empty() {
return Ok(Vec::new());
}
let _keys = load_or_create_nostr_keys(identity_dir).await?;
let anon_keys = Keys::generate();
let client = build_nostr_client(anon_keys, tor_proxy)?;
for url in relays {
let _ = client.add_relay(url).await;
}
client.connect().await;
let filter = Filter::new()
.kind(Kind::Custom(ARCHIPELAGO_KIND as u16))
.identifier(D_TAG)
.limit(50);
let events = client
.fetch_events(filter, std::time::Duration::from_secs(15))
.await
.map(|e| e.to_vec())
.unwrap_or_default();
client.disconnect().await;
let mut nodes = Vec::new();
for event in events {
if let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) {
// Skip revoked/empty events
let node_address = content.get("node_address").and_then(|v| v.as_str()).unwrap_or("").to_string();
if node_address.is_empty() {
continue;
}
let did = content.get("did").and_then(|v| v.as_str()).unwrap_or("").to_string();
let version = content.get("version").and_then(|v| v.as_str()).unwrap_or("0.1").to_string();
// Parse archipelago://xxx.onion#pubkey
let (onion, pubkey) = if node_address.starts_with("archipelago://") {
let rest = node_address.trim_start_matches("archipelago://");
if let Some((o, p)) = rest.split_once('#') {
(o.to_string(), p.to_string())
} else {
(rest.to_string(), "".to_string())
}
} else {
("".to_string(), "".to_string())
};
if !onion.is_empty() {
nodes.push(DiscoveredNode {
did,
node_address,
onion: onion.trim_end_matches('/').to_string(),
pubkey,
version,
});
}
}
}
Ok(nodes)
}