//! 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"; /// Runtime discoverability override written by the `nostr.set-discovery` RPC. /// Lives here (not api/rpc) so the server's heartbeat can honour the same /// state the toggle writes. pub const DISCOVERY_STATE_FILE: &str = "nostr_discovery_state.json"; /// How long a presence event stays valid. Published as a NIP-40 expiration /// tag AND enforced client-side in `discover` (relay NIP-40 support varies). /// Must be comfortably longer than the re-publish heartbeat (12h in /// server.rs) so a node that misses one heartbeat doesn't vanish: 48h /// tolerates three misses. pub const PRESENCE_TTL_SECS: u64 = 48 * 3600; /// Read the runtime discovery override and the operator-chosen display name. /// Enabled `None` means the toggle has never been used on this node — /// callers fall back to the config flag. pub async fn discovery_overrides(data_dir: &Path) -> (Option, Option) { let Ok(raw) = fs::read_to_string(data_dir.join(DISCOVERY_STATE_FILE)).await else { return (None, None); }; let Ok(v) = serde_json::from_str::(&raw) else { return (None, None); }; let enabled = v.get("enabled").and_then(|e| e.as_bool()); let name = v .get("name") .and_then(|n| n.as_str()) .and_then(clean_display_name); (enabled, name) } /// Display names travel in a PUBLIC relay event and come back from untrusted /// peers — normalise both directions: single line, control chars stripped, /// hard length cap, empty collapses to None. pub fn clean_display_name(raw: &str) -> Option { let cleaned: String = raw .chars() .filter(|c| !c.is_control()) .take(32) .collect::() .trim() .to_string(); (!cleaned.is_empty()).then_some(cleaned) } /// This node's own published npub (bech32), if discovery keys exist. /// Load-only: never mints keys on a read. pub async fn own_npub(identity_dir: &Path) -> Result> { Ok(load_nostr_keys(identity_dir) .await? .map(|k| k.public_key().to_bech32().unwrap_or_default()) .filter(|s| !s.is_empty())) } /// 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, 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 — 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 mut fields = serde_json::json!({ "did": did, "nostr_pubkey": nostr_pubkey, "nostr_npub": nostr_npub, "version": version, // No onion address — exchanged only via encrypted DM }); // Operator-chosen display name (optional, already normalised). Public by // construction: it exists to label this node in other nodes' discovery // lists, so only ever include what clean_display_name lets through. if let Some(n) = name.and_then(clean_display_name) { fields["name"] = serde_json::Value::String(n); } let content = fields.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"); } // NIP-40 expiration: relays that honour it garbage-collect the event if // this node stops heartbeating (reinstall, decommission, long outage). // `discover` enforces the same window client-side for relays that don't. let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS); let builder = EventBuilder::new(Kind::Custom(30078), content) .tag(Tag::identifier("archipelago-node")) .tag(Tag::expiration(expires)); let _ = client.send_event_builder(builder).await; client.disconnect().await; tracing::info!( "📡 Published presence (no onion) to {} relays", relays.len() ); Ok(()) } /// Overwrite this node's presence with an empty tombstone (NIP-33: same /// author + kind + d-tag replaces). Called when discovery is switched off /// and — critically — during factory-reset BEFORE the keys are wiped: once /// the secret is gone, nothing can ever replace the stale event. pub async fn publish_tombstone( identity_dir: &Path, relays: &[String], tor_proxy: Option<&str>, ) -> Result<()> { if relays.is_empty() { return Ok(()); } let Some(keys) = load_nostr_keys(identity_dir).await? else { return Ok(()); // never published — nothing to tombstone }; 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"); } // Tombstone also expires: after TTL the relay may drop it entirely, // which is the desired end state (nothing left to list). let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS); let builder = EventBuilder::new(Kind::Custom(30078), "{}") .tag(Tag::identifier("archipelago-node")) .tag(Tag::expiration(expires)); let _ = client.send_event_builder(builder).await; client.disconnect().await; tracing::info!("🔒 Published presence tombstone 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, /// Operator-chosen display name from the presence event. Untrusted peer /// input — normalised through `clean_display_name` on the way in. pub name: Option, } 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(); let stale_cutoff = Timestamp::from(Timestamp::now().as_u64().saturating_sub(PRESENCE_TTL_SECS)); for event in events { // Client-side staleness enforcement: pre-TTL events (and events from // relays that ignore NIP-40) would otherwise list dead installs // forever — every reinstall mints a new key, so the old author can // never replace its own event. if event.created_at < stale_cutoff { continue; } // A tombstone ("{}" content) parses but yields no pubkey — the // nostr_pubkey.is_empty() guard below already drops it. 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(); let name = content .get("name") .and_then(|v| v.as_str()) .and_then(clean_display_name); nodes.push(DiscoverableNode { nostr_pubkey, nostr_npub, did, version, name, }); } } } 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)); // Surface real delivery failures. Before 2026-07-22 this was `let _ =`, // so a request no relay accepted still reported ok:true to the UI and // the sender believed it was delivered. let send = client.send_event_builder(builder).await; client.disconnect().await; match send { Ok(output) if !output.success.is_empty() => Ok(()), Ok(output) => anyhow::bail!( "no relay accepted the handshake event (tried {}, all failed)", output.failed.len() ), Err(e) => anyhow::bail!("failed to publish handshake event: {e}"), } } /// 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) }