//! Federation invite creation, parsing, and acceptance. use anyhow::{Context, Result}; use std::path::Path; use super::storage::{add_node, load_invites, load_nodes, save_invites, save_nodes}; use super::types::{FederatedNode, FederationInvite, TrustLevel}; /// Parsed contents of a federation invite code. #[derive(Debug, Clone)] pub struct ParsedInvite { pub did: String, pub onion: String, pub pubkey: String, /// Per-invite randomness; retained by parsers but not consumed /// end-to-end — the outer signature binds the relationship. #[allow(dead_code)] pub token: String, /// Inviter's FIPS npub if advertised in the code. pub fips_npub: Option, } /// Generate an invite code. Format: `fed1:`. /// `fips_npub` is only included when the local node has a materialised FIPS key. pub async fn create_invite( data_dir: &Path, did: &str, onion: &str, pubkey: &str, fips_npub: Option<&str>, ) -> Result { use base64::Engine; use rand::Rng; let mut token_bytes = [0u8; 16]; rand::thread_rng().fill(&mut token_bytes); let token = hex::encode(token_bytes); let mut payload = serde_json::json!({ "did": did, "onion": onion, "pubkey": pubkey, "token": token, }); if let Some(npub) = fips_npub { payload["fips_npub"] = serde_json::Value::String(npub.to_string()); } let json = serde_json::to_string(&payload).context("Failed to serialize invite")?; let code = format!( "fed1:{}", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json.as_bytes()) ); let invite = FederationInvite { code: code.clone(), did: did.to_string(), onion: onion.to_string(), pubkey: pubkey.to_string(), created_at: chrono::Utc::now().to_rfc3339(), accepted: false, fips_npub: fips_npub.map(|s| s.to_string()), }; let mut invites = load_invites(data_dir).await?; invites.outgoing.push(invite); save_invites(data_dir, &invites).await?; Ok(code) } /// Parse an invite code into its components. pub fn parse_invite(code: &str) -> Result { use base64::Engine; let encoded = code .strip_prefix("fed1:") .ok_or_else(|| anyhow::anyhow!("Invalid invite format: must start with fed1:"))?; let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(encoded) .context("Invalid base64 in invite code")?; let payload: serde_json::Value = serde_json::from_slice(&bytes).context("Invalid JSON in invite")?; let did = payload["did"] .as_str() .ok_or_else(|| anyhow::anyhow!("Missing did in invite"))? .to_string(); let onion = payload["onion"] .as_str() .ok_or_else(|| anyhow::anyhow!("Missing onion in invite"))? .to_string(); let pubkey = payload["pubkey"] .as_str() .ok_or_else(|| anyhow::anyhow!("Missing pubkey in invite"))? .to_string(); let token = payload["token"] .as_str() .ok_or_else(|| anyhow::anyhow!("Missing token in invite"))? .to_string(); let fips_npub = payload .get("fips_npub") .and_then(|v| v.as_str()) .map(|s| s.to_string()); Ok(ParsedInvite { did, onion, pubkey, token, fips_npub, }) } /// Accept an invite: parse code, verify the remote node, add to federation. pub async fn accept_invite( data_dir: &Path, code: &str, local_did: &str, local_onion: &str, local_pubkey: &str, local_fips_npub: Option<&str>, local_name: Option<&str>, sign_fn: impl FnOnce(&[u8]) -> String, ) -> Result { let ParsedInvite { did, onion, pubkey, token: _, fips_npub, } = parse_invite(code)?; // Refuse self-peering. If the invite's did / onion / pubkey matches // our own, adding it pollutes the federation list with a node that // sees itself as its own peer and causes sync loops. The user // almost certainly pasted the wrong invite. if did == local_did || pubkey == local_pubkey || { let a = onion.trim_end_matches(".onion"); let b = local_onion.trim_end_matches(".onion"); !a.is_empty() && a == b } { anyhow::bail!( "Refusing to federate with self — invite points at this node's own did / onion / pubkey" ); } // Make accept idempotent: drop any existing entry that conflicts with // this invite — same DID (same node, refreshing the link), same onion // (node rotated identity but kept its hidden service), or same pubkey // (DID and onion reformatted but the underlying key is the same). // Whatever is there gets replaced so re-accepting an invite is always // safe and the user never has to manually remove an entry first. let mut nodes = load_nodes(data_dir).await?; let onion_norm = onion.trim_end_matches(".onion"); let before = nodes.len(); nodes.retain(|n| { n.did != did && n.onion.trim_end_matches(".onion") != onion_norm && n.pubkey != pubkey }); if nodes.len() != before { save_nodes(data_dir, &nodes).await?; tracing::info!( removed = before - nodes.len(), new_did = %did, onion = %onion, "Replaced stale federation entry on re-accept" ); } let node = FederatedNode { did: did.clone(), pubkey, onion, name: None, trust_level: TrustLevel::Trusted, added_at: chrono::Utc::now().to_rfc3339(), last_seen: None, last_state: None, fips_npub: fips_npub.clone(), last_transport: None, last_transport_at: None, }; add_node(data_dir, node.clone()).await?; // Record as incoming accepted invite let mut invites = load_invites(data_dir).await?; invites.incoming.push(FederationInvite { code: code.to_string(), did: did.clone(), onion: node.onion.clone(), pubkey: node.pubkey.clone(), created_at: chrono::Utc::now().to_rfc3339(), accepted: true, fips_npub, }); save_invites(data_dir, &invites).await?; // Notify remote node (best-effort, FIPS-first → Tor fallback) let _ = notify_join( &node.onion, node.fips_npub.as_deref(), local_did, local_onion, local_pubkey, local_fips_npub, local_name, sign_fn, ) .await; Ok(node) } /// Best-effort notification to the remote node that we joined their federation. /// Prefers FIPS (if the remote advertised an npub in their invite) and /// falls back to Tor. Signs the message with our ed25519 key so the /// remote peer can verify authenticity regardless of transport. pub(crate) async fn notify_join( remote_onion: &str, remote_fips_npub: Option<&str>, local_did: &str, local_onion: &str, local_pubkey: &str, local_fips_npub: Option<&str>, local_name: Option<&str>, sign_fn: impl FnOnce(&[u8]) -> String, ) -> Result<()> { // Sign the canonical message: "peer-joined:{did}:{onion}:{pubkey}" // Signature domain intentionally unchanged — fips_npub + name are // carried as unsigned informational fields. Name is display-only // (any identity claim is anchored on the signed did/pubkey); the // FIPS daemon's own Noise handshake authenticates the transport // session regardless of the advertised npub. let sign_data = format!("peer-joined:{}:{}:{}", local_did, local_onion, local_pubkey); let signature = sign_fn(sign_data.as_bytes()); let mut params = serde_json::json!({ "did": local_did, "onion": local_onion, "pubkey": local_pubkey, "signature": signature, }); if let Some(npub) = local_fips_npub { params["fips_npub"] = serde_json::Value::String(npub.to_string()); } if let Some(name) = local_name { params["name"] = serde_json::Value::String(name.to_string()); } let body = serde_json::json!({ "method": "federation.peer-joined", "params": params, }); let _ = crate::fips::dial::PeerRequest::new(remote_fips_npub, remote_onion, "/rpc/v1") .service(crate::settings::transport::PeerService::Federation) .timeout(std::time::Duration::from_secs(30)) .send_json(&body) .await; Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::federation::storage::load_nodes; #[tokio::test] async fn test_create_and_parse_invite() { let dir = tempfile::tempdir().unwrap(); let code = create_invite(dir.path(), "did:key:z1", "test.onion", "aabbcc", None) .await .unwrap(); assert!(code.starts_with("fed1:")); let parsed = parse_invite(&code).unwrap(); assert_eq!(parsed.did, "did:key:z1"); assert_eq!(parsed.onion, "test.onion"); assert_eq!(parsed.pubkey, "aabbcc"); assert_eq!(parsed.token.len(), 32); // 16 bytes = 32 hex chars assert!(parsed.fips_npub.is_none()); } #[tokio::test] async fn test_invite_roundtrips_fips_npub() { let dir = tempfile::tempdir().unwrap(); let fips = "npub1fipstest0000000000000000000000000000000000"; let code = create_invite(dir.path(), "did:key:z1", "test.onion", "aabbcc", Some(fips)) .await .unwrap(); let parsed = parse_invite(&code).unwrap(); assert_eq!(parsed.fips_npub.as_deref(), Some(fips)); } #[tokio::test] async fn test_parse_invite_tolerates_missing_fips() { // Older invites minted before fips_npub existed must still parse. use base64::Engine; let legacy = serde_json::json!({ "did": "did:key:zOld", "onion": "old.onion", "pubkey": "00", "token": "aa", }); let code = format!( "fed1:{}", base64::engine::general_purpose::URL_SAFE_NO_PAD .encode(serde_json::to_string(&legacy).unwrap()) ); let parsed = parse_invite(&code).unwrap(); assert_eq!(parsed.did, "did:key:zOld"); assert!(parsed.fips_npub.is_none()); } #[test] fn test_parse_invalid_invite() { assert!(parse_invite("invalid").is_err()); assert!(parse_invite("fed1:not-valid-base64!!!").is_err()); } #[tokio::test] async fn test_accept_invite_creates_node() { let dir = tempfile::tempdir().unwrap(); let code = create_invite( dir.path(), "did:key:zRemote", "remote.onion", "remotepub", None, ) .await .unwrap(); // Accept from a different "local" perspective let dir2 = tempfile::tempdir().unwrap(); let node = accept_invite( dir2.path(), &code, "did:key:zLocal", "local.onion", "localpub", None, |_| "test-sig".to_string(), ) .await .unwrap(); assert_eq!(node.did, "did:key:zRemote"); assert_eq!(node.trust_level, TrustLevel::Trusted); let nodes = load_nodes(dir2.path()).await.unwrap(); assert_eq!(nodes.len(), 1); } #[tokio::test] async fn test_accept_invite_persists_fips_npub() { let dir = tempfile::tempdir().unwrap(); let fips = "npub1remotefipsaddrxxxxxxxxxxxxxxxxxxxxxxxxxx"; let code = create_invite( dir.path(), "did:key:zRemote", "remote.onion", "remotepub", Some(fips), ) .await .unwrap(); let dir2 = tempfile::tempdir().unwrap(); let node = accept_invite( dir2.path(), &code, "did:key:zLocal", "local.onion", "localpub", None, |_| "test-sig".to_string(), ) .await .unwrap(); assert_eq!(node.fips_npub.as_deref(), Some(fips)); } #[tokio::test] async fn test_accept_invite_is_idempotent() { // Re-accepting the same invite is a no-op refresh — it must not // duplicate the entry and must not error. This is the contract the // UI relies on: clicking "Join" twice or refreshing after an // identity rotation always converges to one entry. let dir = tempfile::tempdir().unwrap(); let code = create_invite( dir.path(), "did:key:zRemote", "remote.onion", "remotepub", None, ) .await .unwrap(); let dir2 = tempfile::tempdir().unwrap(); accept_invite( dir2.path(), &code, "did:key:zLocal", "local.onion", "localpub", None, |_| "test-sig".to_string(), ) .await .unwrap(); accept_invite( dir2.path(), &code, "did:key:zLocal", "local.onion", "localpub", None, |_| "test-sig".to_string(), ) .await .unwrap(); let nodes = load_nodes(dir2.path()).await.unwrap(); assert_eq!(nodes.len(), 1, "re-accept should not duplicate"); } }