//! Encrypted peer-discovery handshake via Nostr NIP-44. //! //! Goals: //! - A node can opt in to being *discoverable* on Nostr by publishing a //! minimal presence event (DID + nostr pubkey, NIP-33 kind 30078). The //! presence event NEVER contains the onion address, the federation list, //! the app inventory, or anything beyond a DID + nostr pubkey + version. //! - Another node that sees the presence event can send an encrypted //! `PeerRequest` (NIP-44 DM, kind 4) asking to peer. The request also //! does NOT contain the requester's onion — it carries only the DID //! the requester claims, an optional friendly name, and an optional //! one-line message. //! - The recipient does NOT auto-accept and does NOT auto-respond. //! Instead, the request is queued in `federation::pending` for the //! user to manually approve or reject in the Federation UI. //! - On approval, the recipient generates a one-shot federation invite //! code (which contains *their* onion + pubkey) and ships it back via //! NIP-44 encrypted to the requester's nostr pubkey. The requester's //! poll loop receives the invite, applies it via the existing //! `federation.join` flow, and the two boxes complete the trust //! exchange over Tor — never over a public relay. //! //! Result: the only thing ever visible on a public Nostr relay is the //! presence event (a DID + a npub + a version). Everything actionable //! lives inside NIP-44 ciphertext addressed to a specific nostr pubkey. 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 exchanged inside NIP-44 encrypted DMs (kind 4). /// /// Note: NONE of these variants carry an onion address. The onion is only /// transmitted as part of a `PeerInvite { invite_code }`, where the invite /// code is generated by the local approval flow and points at the local /// federation hidden service. The legacy `connect-request` / `connect-response` /// variants from earlier development are preserved on the deserialize side /// (`#[serde(other)]`-style fallback via untagged Unknown) so that an old /// peer that hasn't been upgraded yet can't crash the parser, but we no /// longer construct or act on them. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum HandshakeMessage { /// Inbound peer-discovery request. The sender proves they hold the /// nostr secret key by signing the kind-4 envelope (Nostr does this /// at the protocol layer). They claim a `from_did` here, but we do /// not trust it until the federation invite round-trip completes. #[serde(rename = "peer-request")] PeerRequest { from_did: String, version: String, name: Option, message: Option, }, /// Approval reply: contains a one-shot federation invite code /// generated by the approver. The invite code embeds the approver's /// onion + pubkey, but the whole envelope is NIP-44 encrypted to the /// requester's nostr pubkey, so only the requester can read it. #[serde(rename = "peer-invite")] PeerInvite { invite_code: String }, /// Rejection reply. Optional one-line reason for the user. #[serde(rename = "peer-reject")] PeerReject { reason: Option }, /// Cancellation of a previously-sent `PeerRequest`. Sent by the /// original requester to withdraw the handshake before the target /// has approved/rejected it. The recipient removes the matching /// inbound pending row so it no longer shows in their UI. #[serde(rename = "peer-cancel")] PeerCancel { /// Optional one-line reason (shown in the target's UI if they /// still have the row). Typically empty. reason: 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) } /// Encrypt and publish a `HandshakeMessage` to a recipient's nostr pubkey. /// Used by both the request-side (PeerRequest) and the approver-side /// (PeerInvite / PeerReject) flows. The message is wrapped in a NIP-44 v2 /// ciphertext addressed to `recipient_nostr_pubkey` and posted as a kind-4 /// encrypted DM, so only the holder of that nostr secret key can decrypt /// the contents. Relays only ever see the ciphertext. async fn send_handshake_message( identity_dir: &Path, recipient_nostr_pubkey: &str, msg: &HandshakeMessage, 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 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; Ok(()) } /// Send a `PeerRequest` to a discovered node's nostr pubkey. We never /// include an onion address — the recipient learns nothing that would let /// them dial us directly. They learn only our claimed DID, version, /// optional friendly name, and the optional message. pub async fn send_peer_request( identity_dir: &Path, recipient_nostr_pubkey: &str, our_did: &str, our_version: &str, our_name: Option<&str>, message: Option<&str>, relays: &[String], tor_proxy: Option<&str>, ) -> Result<()> { let msg = HandshakeMessage::PeerRequest { from_did: our_did.to_string(), version: our_version.to_string(), name: our_name.map(String::from), message: message.map(String::from), }; send_handshake_message( identity_dir, recipient_nostr_pubkey, &msg, relays, tor_proxy, ) .await?; tracing::info!( "🤝 Sent peer-request to {}...{}", &recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())], &recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..] ); Ok(()) } /// Send a `PeerInvite` reply containing a one-shot federation invite code. /// The code embeds our onion + pubkey, but the entire envelope is NIP-44 /// encrypted to the requester's nostr pubkey, so the onion is only ever /// readable by them. pub async fn send_peer_invite( identity_dir: &Path, recipient_nostr_pubkey: &str, invite_code: &str, relays: &[String], tor_proxy: Option<&str>, ) -> Result<()> { let msg = HandshakeMessage::PeerInvite { invite_code: invite_code.to_string(), }; send_handshake_message( identity_dir, recipient_nostr_pubkey, &msg, relays, tor_proxy, ) .await?; tracing::info!( "🤝 Sent peer-invite to {}...{}", &recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())], &recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..] ); Ok(()) } /// Send a `PeerReject` reply with an optional reason. pub async fn send_peer_reject( identity_dir: &Path, recipient_nostr_pubkey: &str, reason: Option<&str>, relays: &[String], tor_proxy: Option<&str>, ) -> Result<()> { let msg = HandshakeMessage::PeerReject { reason: reason.map(String::from), }; send_handshake_message( identity_dir, recipient_nostr_pubkey, &msg, relays, tor_proxy, ) .await?; tracing::info!( "🚫 Sent peer-reject to {}...{}", &recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())], &recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..] ); Ok(()) } /// Send a `PeerCancel` notice to withdraw a previously-sent PeerRequest. /// The recipient will drop their matching inbound pending row. pub async fn send_peer_cancel( identity_dir: &Path, recipient_nostr_pubkey: &str, reason: Option<&str>, relays: &[String], tor_proxy: Option<&str>, ) -> Result<()> { let msg = HandshakeMessage::PeerCancel { reason: reason.map(String::from), }; send_handshake_message( identity_dir, recipient_nostr_pubkey, &msg, relays, tor_proxy, ) .await?; tracing::info!( "↩️ Sent peer-cancel 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) }