//! 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}; use super::types::{FederatedNode, FederationInvite, TrustLevel}; /// Generate an invite code. Format: `fed1:` pub async fn create_invite( data_dir: &Path, did: &str, onion: &str, pubkey: &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 payload = serde_json::json!({ "did": did, "onion": onion, "pubkey": pubkey, "token": token, }); 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, }; 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<(String, String, String, String)> { 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(); Ok((did, onion, pubkey, token)) } /// 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, sign_fn: impl FnOnce(&[u8]) -> String, ) -> Result { let (did, onion, pubkey, _token) = parse_invite(code)?; // Check not already federated let nodes = load_nodes(data_dir).await?; if nodes.iter().any(|n| n.did == did) { anyhow::bail!("Already federated with node {}", did); } 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, }; 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, }); save_invites(data_dir, &invites).await?; // Notify remote node (best-effort over Tor) let _ = notify_join(&node.onion, local_did, local_onion, local_pubkey, sign_fn).await; Ok(node) } /// Best-effort notification to the remote node that we joined their federation. /// Signs the message with our ed25519 key so the remote peer can verify authenticity. async fn notify_join( remote_onion: &str, local_did: &str, local_onion: &str, local_pubkey: &str, sign_fn: impl FnOnce(&[u8]) -> String, ) -> Result<()> { let host = if remote_onion.ends_with(".onion") { remote_onion.to_string() } else { format!("{}.onion", remote_onion) }; let url = format!("http://{}/rpc/v1", host); // Sign the canonical message: "peer-joined:{did}:{onion}:{pubkey}" let sign_data = format!("peer-joined:{}:{}:{}", local_did, local_onion, local_pubkey); let signature = sign_fn(sign_data.as_bytes()); let body = serde_json::json!({ "method": "federation.peer-joined", "params": { "did": local_did, "onion": local_onion, "pubkey": local_pubkey, "signature": signature, } }); let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?; let client = reqwest::Client::builder() .proxy(proxy) .timeout(std::time::Duration::from_secs(30)) .build() .context("Failed to build HTTP client")?; let _ = client.post(&url).json(&body).send().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") .await .unwrap(); assert!(code.starts_with("fed1:")); let (did, onion, pubkey, token) = parse_invite(&code).unwrap(); assert_eq!(did, "did:key:z1"); assert_eq!(onion, "test.onion"); assert_eq!(pubkey, "aabbcc"); assert_eq!(token.len(), 32); // 16 bytes = 32 hex chars } #[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") .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", |_| "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_rejects_duplicate() { let dir = tempfile::tempdir().unwrap(); let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub") .await .unwrap(); let dir2 = tempfile::tempdir().unwrap(); accept_invite( dir2.path(), &code, "did:key:zLocal", "local.onion", "localpub", |_| "test-sig".to_string(), ) .await .unwrap(); // Accepting the same invite again should fail let result = accept_invite( dir2.path(), &code, "did:key:zLocal", "local.onion", "localpub", |_| "test-sig".to_string(), ) .await; assert!(result.is_err()); } }