//! Encrypted peer handshake via Nostr NIP-44. //! //! Instead of publishing onion addresses publicly on relays, nodes exchange //! them privately via NIP-44 encrypted DMs: //! //! 1. Node publishes presence-only event (DID + Nostr pubkey, NO onion address) //! 2. To connect, Node A sends NIP-44 encrypted DM to Node B's Nostr pubkey //! containing A's onion address + Ed25519 node pubkey //! 3. Node B auto-responds with its own onion address + pubkey //! 4. Both nodes add each other as known peers //! //! Uses NIP-44 (ChaCha20-Poly1305) for encryption, kind 4 for DMs. use anyhow::{Context, Result}; use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; use std::net::SocketAddr; use std::path::Path; use std::time::Duration; use tokio::fs; use tracing::warn; const NOSTR_SECRET_FILE: &str = "nostr_secret"; /// Message types for the encrypted handshake protocol #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum HandshakeMessage { #[serde(rename = "connect-request")] ConnectRequest { onion: String, node_pubkey: String, did: String, version: String, name: Option, }, #[serde(rename = "connect-response")] ConnectResponse { onion: String, node_pubkey: String, did: String, version: String, name: Option, }, } /// Result of polling for incoming handshake messages #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IncomingHandshake { /// Sender's Nostr public key in hex pub from_nostr_pubkey: String, /// Sender's Nostr public key in bech32 npub format (NIP-19) pub from_nostr_npub: String, pub message: HandshakeMessage, pub timestamp: String, } /// Parse "host:port" to SocketAddr. fn parse_proxy_addr(s: &str) -> Option { s.trim().parse().ok() } /// Load existing Nostr keys (secp256k1). Returns None if no keys exist. async fn load_nostr_keys(identity_dir: &Path) -> Result> { 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)) } /// Build a Nostr client with optional Tor proxy. fn build_client(keys: Keys, tor_proxy: Option<&str>) -> Result { 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 presence-only event to Nostr relays. /// Content: { did, nostr_pubkey, version } — NO onion address. /// Uses NIP-33 replaceable events (kind 30078) with d-tag "archipelago-node". pub async fn publish_presence( identity_dir: &Path, did: &str, version: &str, relays: &[String], tor_proxy: Option<&str>, ) -> Result<()> { if relays.is_empty() { anyhow::bail!("No relays configured"); } let keys = load_nostr_keys(identity_dir) .await? .ok_or_else(|| anyhow::anyhow!("No Nostr keys — generate them first"))?; let nostr_pubkey = keys.public_key().to_hex(); let nostr_npub = keys.public_key().to_bech32().unwrap_or_default(); let client = build_client(keys, tor_proxy)?; let content = serde_json::json!({ "did": did, "nostr_pubkey": nostr_pubkey, "nostr_npub": nostr_npub, "version": version, // No onion address — exchanged only via encrypted DM }) .to_string(); for url in relays { let _ = client.add_relay(url).await; } if tokio::time::timeout(Duration::from_secs(10), client.connect()).await.is_err() { warn!("Nostr relay connection timed out after 10s, continuing anyway"); } let builder = EventBuilder::new(Kind::Custom(30078), content) .tag(Tag::identifier("archipelago-node")); let _ = client.send_event_builder(builder).await; client.disconnect().await; tracing::info!("📡 Published presence (no onion) to {} relays", relays.len()); Ok(()) } /// Discover other Archipelago nodes (presence-only — no onion addresses). /// Returns Nostr pubkeys and DIDs of discoverable nodes. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiscoverableNode { /// Nostr secp256k1 public key in hex pub nostr_pubkey: String, /// Nostr public key in bech32 npub format (NIP-19) pub nostr_npub: String, pub did: String, pub version: String, } pub async fn discover_nodes( _identity_dir: &Path, relays: &[String], tor_proxy: Option<&str>, ) -> Result> { if relays.is_empty() { return Ok(Vec::new()); } let anon_keys = Keys::generate(); let client = build_client(anon_keys, tor_proxy)?; for url in relays { let _ = client.add_relay(url).await; } if tokio::time::timeout(Duration::from_secs(10), client.connect()).await.is_err() { warn!("Nostr relay connection timed out after 10s, continuing anyway"); } let filter = Filter::new() .kind(Kind::Custom(30078)) .identifier("archipelago-node") .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::(&event.content) { let nostr_pubkey = content .get("nostr_pubkey") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); 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(); // Skip entries that still have node_address (legacy public format) if content.get("node_address").is_some() { continue; } if !nostr_pubkey.is_empty() { // Derive npub (bech32 NIP-19) from hex let nostr_npub = nostr_sdk::PublicKey::from_hex(&nostr_pubkey) .ok() .and_then(|pk| pk.to_bech32().ok()) .unwrap_or_default(); nodes.push(DiscoverableNode { nostr_pubkey, nostr_npub, did, version, }); } } } Ok(nodes) } /// Send an encrypted connection request to a peer's Nostr pubkey. /// Uses NIP-44 encrypted DM (kind 4) containing our onion address. pub async fn send_connect_request( identity_dir: &Path, recipient_nostr_pubkey: &str, our_onion: &str, our_node_pubkey: &str, our_did: &str, our_version: &str, our_name: Option<&str>, relays: &[String], tor_proxy: Option<&str>, ) -> Result<()> { if relays.is_empty() { anyhow::bail!("No relays configured"); } let keys = load_nostr_keys(identity_dir) .await? .ok_or_else(|| anyhow::anyhow!("No Nostr keys"))?; let recipient_pk = PublicKey::from_hex(recipient_nostr_pubkey) .context("Invalid recipient Nostr pubkey")?; let msg = HandshakeMessage::ConnectRequest { onion: our_onion.to_string(), node_pubkey: our_node_pubkey.to_string(), did: our_did.to_string(), version: our_version.to_string(), name: our_name.map(String::from), }; let plaintext = serde_json::to_string(&msg).context("Failed to serialize handshake")?; // NIP-44 encrypt let encrypted = nip44::encrypt( keys.secret_key(), &recipient_pk, &plaintext, nip44::Version::V2, ) .map_err(|e| anyhow::anyhow!("NIP-44 encrypt failed: {}", e))?; let client = build_client(keys, tor_proxy)?; for url in relays { let _ = client.add_relay(url).await; } if tokio::time::timeout(Duration::from_secs(10), client.connect()).await.is_err() { warn!("Nostr relay connection timed out after 10s, continuing anyway"); } // Kind 4 encrypted DM with p-tag for recipient let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted) .tag(Tag::public_key(recipient_pk)); let _ = client.send_event_builder(builder).await; client.disconnect().await; tracing::info!( "🤝 Sent encrypted connect request to {}...{}", &recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())], &recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..] ); Ok(()) } /// Send an encrypted connection response to a peer. pub async fn send_connect_response( identity_dir: &Path, recipient_nostr_pubkey: &str, our_onion: &str, our_node_pubkey: &str, our_did: &str, our_version: &str, our_name: Option<&str>, relays: &[String], tor_proxy: Option<&str>, ) -> Result<()> { if relays.is_empty() { anyhow::bail!("No relays configured"); } let keys = load_nostr_keys(identity_dir) .await? .ok_or_else(|| anyhow::anyhow!("No Nostr keys"))?; let recipient_pk = PublicKey::from_hex(recipient_nostr_pubkey) .context("Invalid recipient Nostr pubkey")?; let msg = HandshakeMessage::ConnectResponse { onion: our_onion.to_string(), node_pubkey: our_node_pubkey.to_string(), did: our_did.to_string(), version: our_version.to_string(), name: our_name.map(String::from), }; let plaintext = serde_json::to_string(&msg).context("Failed to serialize handshake")?; let encrypted = nip44::encrypt( keys.secret_key(), &recipient_pk, &plaintext, nip44::Version::V2, ) .map_err(|e| anyhow::anyhow!("NIP-44 encrypt failed: {}", e))?; let client = build_client(keys, tor_proxy)?; for url in relays { let _ = client.add_relay(url).await; } if tokio::time::timeout(Duration::from_secs(10), client.connect()).await.is_err() { warn!("Nostr relay connection timed out after 10s, continuing anyway"); } let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted) .tag(Tag::public_key(recipient_pk)); let _ = client.send_event_builder(builder).await; client.disconnect().await; tracing::info!( "🤝 Sent encrypted connect response to {}...{}", &recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())], &recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..] ); Ok(()) } /// Poll relays for incoming encrypted handshake DMs addressed to us. /// Returns new handshake messages since `since` timestamp. pub async fn poll_handshakes( identity_dir: &Path, relays: &[String], tor_proxy: Option<&str>, since: Option, ) -> Result> { if relays.is_empty() { return Ok(Vec::new()); } let keys = load_nostr_keys(identity_dir) .await? .ok_or_else(|| anyhow::anyhow!("No Nostr keys"))?; let our_pk = keys.public_key(); let client = build_client(keys.clone(), tor_proxy)?; for url in relays { let _ = client.add_relay(url).await; } if tokio::time::timeout(Duration::from_secs(10), client.connect()).await.is_err() { warn!("Nostr relay connection timed out after 10s, continuing anyway"); } // Query for encrypted DMs addressed to us let mut filter = Filter::new() .kind(Kind::EncryptedDirectMessage) .pubkey(our_pk) .limit(50); if let Some(ts) = since { filter = filter.since(ts); } 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 handshakes = Vec::new(); for event in events { // Skip our own events if event.pubkey == our_pk { continue; } // Try NIP-44 decryption let plaintext = match nip44::decrypt(keys.secret_key(), &event.pubkey, &event.content) { Ok(pt) => pt, Err(_) => continue, // Not a handshake message or wrong key }; // Try to parse as HandshakeMessage if let Ok(msg) = serde_json::from_str::(&plaintext) { let from_npub = event.pubkey.to_bech32().unwrap_or_default(); handshakes.push(IncomingHandshake { from_nostr_pubkey: event.pubkey.to_hex(), from_nostr_npub: from_npub, message: msg, timestamp: event.created_at.to_human_datetime(), }); } } Ok(handshakes) }