diff --git a/crates/wzp-client/src/birthday.rs b/crates/wzp-client/src/birthday.rs new file mode 100644 index 0000000..e4a5584 --- /dev/null +++ b/crates/wzp-client/src/birthday.rs @@ -0,0 +1,350 @@ +//! Birthday attack for hard NAT traversal. +//! +//! When both peers are behind symmetric NATs with random port +//! allocation, standard hole-punching fails because neither side +//! can predict the other's external port. This module implements +//! the birthday-paradox approach: +//! +//! 1. **Acceptor** opens N sockets, STUN-probes each to learn +//! their external ports, reports them to the Dialer. +//! 2. **Dialer** sprays QUIC connect attempts to the Acceptor's +//! reported ports + random ports on the Acceptor's IP. +//! 3. Birthday paradox: with N=64 ports and M=256 probes across +//! 65536 ports, collision probability is high. +//! +//! In practice, the Acceptor's STUN-probed ports are known +//! exactly (not random), so the Dialer targets them first — +//! making this more like "spray-and-pray with a hit list" than +//! a pure birthday attack. + +use std::net::{Ipv4Addr, SocketAddr}; +use std::time::{Duration, Instant}; + +use crate::stun; + +/// Configuration for the birthday attack. +#[derive(Debug, Clone)] +pub struct BirthdayConfig { + /// Number of sockets the Acceptor opens (default: 32). + /// Each socket gets STUN-probed to learn its external port. + /// More = higher chance of collision, but more resource usage. + pub acceptor_ports: u16, + /// Number of QUIC connect attempts the Dialer makes (default: 128). + /// Spread across the Acceptor's known ports + random ports. + pub dialer_probes: u16, + /// Rate limit: ms between consecutive probes (default: 20ms = 50/s). + pub probe_interval_ms: u16, + /// Overall timeout for the birthday attack phase. + pub timeout: Duration, + /// STUN config for probing external ports. + pub stun_config: stun::StunConfig, +} + +impl Default for BirthdayConfig { + fn default() -> Self { + Self { + acceptor_ports: 32, + dialer_probes: 128, + probe_interval_ms: 20, + timeout: Duration::from_secs(8), + stun_config: stun::StunConfig { + servers: vec!["stun.l.google.com:19302".into()], + timeout: Duration::from_secs(2), + }, + } + } +} + +/// Result of the Acceptor's port-opening phase. +#[derive(Debug, Clone, serde::Serialize)] +pub struct AcceptorPorts { + /// External IP (from STUN). + pub external_ip: Option, + /// List of (local_port, external_port) for each opened socket. + pub ports: Vec, + /// How many sockets we attempted to open. + pub attempted: u16, + /// How many STUN probes succeeded. + pub succeeded: u16, +} + +/// A single socket's local↔external port mapping. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PortMapping { + pub local_port: u16, + pub external_port: u16, +} + +/// Open N sockets and STUN-probe each to discover external ports. +/// +/// Returns the set of known external ports that the Dialer should +/// target. Each socket stays open (bound) so the NAT mapping +/// remains active until the returned `PortGuard` is dropped. +/// +/// The sockets are returned so the caller can keep them alive +/// during the attack. Dropping them closes the NAT pinholes. +pub async fn open_acceptor_ports( + config: &BirthdayConfig, +) -> (AcceptorPorts, Vec) { + let mut sockets = Vec::new(); + let mut mappings = Vec::new(); + let mut external_ip: Option = None; + let mut succeeded: u16 = 0; + + let stun_server = match config.stun_config.servers.first() { + Some(s) => match stun::resolve_stun_server(s).await { + Ok(a) => Some(a), + Err(_) => None, + }, + None => None, + }; + + for _ in 0..config.acceptor_ports { + // Bind to random port + let sock = match tokio::net::UdpSocket::bind("0.0.0.0:0").await { + Ok(s) => s, + Err(_) => continue, + }; + let local_port = match sock.local_addr() { + Ok(a) => a.port(), + Err(_) => continue, + }; + + // STUN probe to learn external port + if let Some(stun_addr) = stun_server { + match stun::stun_reflect(&sock, stun_addr, config.stun_config.timeout).await { + Ok(ext_addr) => { + if external_ip.is_none() { + if let std::net::IpAddr::V4(ip) = ext_addr.ip() { + external_ip = Some(ip); + } + } + mappings.push(PortMapping { + local_port, + external_port: ext_addr.port(), + }); + succeeded += 1; + } + Err(e) => { + tracing::debug!(local_port, error = %e, "birthday: STUN probe failed for socket"); + } + } + } + + sockets.push(sock); + } + + tracing::info!( + attempted = config.acceptor_ports, + succeeded, + external_ip = ?external_ip, + "birthday: acceptor ports opened" + ); + + let result = AcceptorPorts { + external_ip, + ports: mappings, + attempted: config.acceptor_ports, + succeeded, + }; + + (result, sockets) +} + +/// Generate the list of target addresses for the Dialer to spray. +/// +/// Priority order: +/// 1. Acceptor's known external ports (from STUN probes) — highest hit rate +/// 2. Random ports on the Acceptor's IP — birthday paradox fill +pub fn generate_dialer_targets( + acceptor_ip: Ipv4Addr, + known_ports: &[u16], + total_probes: u16, +) -> Vec { + let mut targets = Vec::with_capacity(total_probes as usize); + + // First: all known ports (guaranteed targets) + for &port in known_ports { + targets.push(SocketAddr::new( + std::net::IpAddr::V4(acceptor_ip), + port, + )); + } + + // Fill remaining with random ports (birthday attack) + let remaining = total_probes.saturating_sub(known_ports.len() as u16); + if remaining > 0 { + use rand::Rng; + let mut rng = rand::thread_rng(); + for _ in 0..remaining { + let port = rng.gen_range(1024..=65535u16); + let addr = SocketAddr::new( + std::net::IpAddr::V4(acceptor_ip), + port, + ); + if !targets.contains(&addr) { + targets.push(addr); + } + } + } + + targets +} + +/// Run the Dialer side of the birthday attack. +/// +/// Sprays QUIC connection attempts at the target addresses. +/// Returns the first successful connection, or None on timeout. +pub async fn spray_dialer( + endpoint: &wzp_transport::Endpoint, + targets: &[SocketAddr], + call_sni: &str, + probe_interval: Duration, + timeout: Duration, +) -> Option { + let start = Instant::now(); + let mut set = tokio::task::JoinSet::new(); + + tracing::info!( + target_count = targets.len(), + interval_ms = probe_interval.as_millis(), + timeout_s = timeout.as_secs(), + "birthday: dialer starting spray" + ); + + // Spray connects with rate limiting + for (idx, &target) in targets.iter().enumerate() { + if start.elapsed() >= timeout { + break; + } + + let ep = endpoint.clone(); + let sni = call_sni.to_string(); + let client_cfg = wzp_transport::client_config(); + set.spawn(async move { + let result = wzp_transport::connect(&ep, target, &sni, client_cfg).await; + (idx, target, result) + }); + + // Rate limit — don't blast the NAT + if idx < targets.len() - 1 { + tokio::time::sleep(probe_interval).await; + } + } + + tracing::info!( + spawned = set.len(), + elapsed_ms = start.elapsed().as_millis(), + "birthday: all probes spawned, waiting for first success" + ); + + // Wait for first success or all failures + let deadline = start + timeout; + while let Some(join_res) = tokio::select! { + r = set.join_next() => r, + _ = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => None, + } { + match join_res { + Ok((idx, target, Ok(conn))) => { + tracing::info!( + idx, + %target, + remote = %conn.remote_address(), + elapsed_ms = start.elapsed().as_millis(), + "birthday: HIT! QUIC handshake succeeded" + ); + set.abort_all(); + return Some(wzp_transport::QuinnTransport::new(conn)); + } + Ok((idx, target, Err(e))) => { + tracing::debug!( + idx, + %target, + error = %e, + "birthday: probe failed" + ); + } + Err(_) => {} + } + } + + tracing::info!( + elapsed_ms = start.elapsed().as_millis(), + "birthday: all probes failed or timed out" + ); + None +} + +// ── Tests ────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_targets_known_ports_first() { + let ip = Ipv4Addr::new(203, 0, 113, 5); + let known = vec![10000, 10001, 10002]; + let targets = generate_dialer_targets(ip, &known, 10); + + // Known ports should be first + assert_eq!(targets[0].port(), 10000); + assert_eq!(targets[1].port(), 10001); + assert_eq!(targets[2].port(), 10002); + // Rest are random + assert!(targets.len() <= 10); + // All target the right IP + assert!(targets.iter().all(|a| a.ip() == std::net::IpAddr::V4(ip))); + } + + #[test] + fn generate_targets_no_known_all_random() { + let ip = Ipv4Addr::new(10, 0, 0, 1); + let targets = generate_dialer_targets(ip, &[], 50); + assert!(!targets.is_empty()); + assert!(targets.len() <= 50); + // All ports in valid range + assert!(targets.iter().all(|a| a.port() >= 1024)); + } + + #[test] + fn generate_targets_more_known_than_total() { + let ip = Ipv4Addr::new(10, 0, 0, 1); + let known: Vec = (10000..10100).collect(); + let targets = generate_dialer_targets(ip, &known, 50); + // All 100 known ports included even though total=50 + assert_eq!(targets.len(), 100); + } + + #[test] + fn generate_targets_dedup() { + let ip = Ipv4Addr::new(10, 0, 0, 1); + let targets = generate_dialer_targets(ip, &[], 100); + // No duplicates + let mut sorted = targets.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(sorted.len(), targets.len()); + } + + #[test] + fn default_config() { + let cfg = BirthdayConfig::default(); + assert_eq!(cfg.acceptor_ports, 32); + assert_eq!(cfg.dialer_probes, 128); + assert!(cfg.timeout.as_secs() > 0); + } + + #[test] + fn acceptor_ports_serializes() { + let result = AcceptorPorts { + external_ip: Some(Ipv4Addr::new(203, 0, 113, 5)), + ports: vec![PortMapping { local_port: 12345, external_port: 54321 }], + attempted: 32, + succeeded: 1, + }; + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("54321")); + assert!(json.contains("203.0.113.5")); + } +} diff --git a/crates/wzp-client/src/featherchat.rs b/crates/wzp-client/src/featherchat.rs index 646744a..db3bede 100644 --- a/crates/wzp-client/src/featherchat.rs +++ b/crates/wzp-client/src/featherchat.rs @@ -133,6 +133,7 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType { SignalMessage::MediaPathReport { .. } => CallSignalType::Offer, // control-plane SignalMessage::CandidateUpdate { .. } => CallSignalType::IceCandidate, // mid-call re-gather SignalMessage::HardNatProbe { .. } => CallSignalType::IceCandidate, // hard NAT coordination + SignalMessage::HardNatBirthdayStart { .. } => CallSignalType::IceCandidate, // birthday attack SignalMessage::QualityDirective { .. } => CallSignalType::Offer, // relay-initiated } } diff --git a/crates/wzp-client/src/lib.rs b/crates/wzp-client/src/lib.rs index 3c3d94e..98191ca 100644 --- a/crates/wzp-client/src/lib.rs +++ b/crates/wzp-client/src/lib.rs @@ -34,6 +34,7 @@ pub mod featherchat; pub mod handshake; pub mod dual_path; pub mod metrics; +pub mod birthday; pub mod ice_agent; pub mod netcheck; pub mod portmap; diff --git a/crates/wzp-proto/src/packet.rs b/crates/wzp-proto/src/packet.rs index bcc08e8..dc8e301 100644 --- a/crates/wzp-proto/src/packet.rs +++ b/crates/wzp-proto/src/packet.rs @@ -967,6 +967,19 @@ pub enum SignalMessage { external_ip: String, }, + /// Birthday attack coordination — Acceptor tells Dialer which + /// ports it has open. The Dialer then sprays QUIC connects to + /// these ports (and optionally random ports) on the Acceptor's IP. + HardNatBirthdayStart { + call_id: String, + /// Number of sockets the Acceptor opened. + acceptor_port_count: u16, + /// External ports discovered via STUN (the "hit list"). + acceptor_ports: Vec, + /// Acceptor's external IP. + external_ip: String, + }, + // ── Phase 4: cross-relay direct-call signaling ──────────────────── /// Phase 4: relay-to-relay envelope for forwarding direct-call diff --git a/crates/wzp-relay/src/main.rs b/crates/wzp-relay/src/main.rs index 1de7db9..539ebec 100644 --- a/crates/wzp-relay/src/main.rs +++ b/crates/wzp-relay/src/main.rs @@ -1443,8 +1443,9 @@ async fn main() -> anyhow::Result<()> { } } - // Hard NAT: forward HardNatProbe to call peer - // (same forwarding pattern as CandidateUpdate). + // Hard NAT: forward HardNatProbe + HardNatBirthdayStart + // to call peer (same pattern as CandidateUpdate). + SignalMessage::HardNatBirthdayStart { ref call_id, .. } | SignalMessage::HardNatProbe { ref call_id, .. } => { let (peer_fp, peer_relay_fp) = { let reg = call_registry.lock().await; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 20f081c..be7e76e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1350,10 +1350,62 @@ fn do_register_signal( let mut sig = signal_state.lock().await; sig.peer_hard_nat_probe = Some(PeerHardNatInfo { external_ip: ip, - port_sequence, - allocation, + port_sequence: port_sequence.clone(), + allocation: allocation.clone(), }); } + + // If peer has a random/symmetric NAT and WE are the + // Acceptor, open birthday attack ports and send + // BirthdayStart so the peer can spray us. + if allocation == "random" || allocation.starts_with("sequential") { + let state_bg = signal_state.clone(); + let app_bg = app_clone.clone(); + let call_id_bg = call_id.clone(); + tokio::spawn(async move { + let config = wzp_client::birthday::BirthdayConfig::default(); + let (result, _sockets) = wzp_client::birthday::open_acceptor_ports(&config).await; + if result.succeeded > 0 { + let ext_ports: Vec = result.ports.iter().map(|p| p.external_port).collect(); + let ext_ip = result.external_ip + .map(|ip| ip.to_string()) + .unwrap_or_default(); + emit_call_debug(&app_bg, "birthday:acceptor_ports_opened", serde_json::json!({ + "succeeded": result.succeeded, + "external_ip": ext_ip, + "ports": ext_ports, + })); + let sig = state_bg.lock().await; + if let Some(ref t) = sig.transport { + let _ = t.send_signal(&wzp_proto::SignalMessage::HardNatBirthdayStart { + call_id: call_id_bg, + acceptor_port_count: result.succeeded, + acceptor_ports: ext_ports, + external_ip: ext_ip, + }).await; + } + // Keep _sockets alive for 10s so NAT mappings persist + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + } + }); + } + } + Ok(Some(SignalMessage::HardNatBirthdayStart { call_id, acceptor_port_count, acceptor_ports, external_ip })) => { + tracing::info!( + %call_id, + acceptor_port_count, + port_count = acceptor_ports.len(), + %external_ip, + "signal: HardNatBirthdayStart from peer" + ); + emit_call_debug(&app_clone, "recv:HardNatBirthdayStart", serde_json::json!({ + "call_id": call_id, + "acceptor_port_count": acceptor_port_count, + "acceptor_ports": acceptor_ports, + "external_ip": external_ip, + })); + // TODO: trigger dialer spray when birthday attack + // is integrated into the race waterfall } Ok(Some(SignalMessage::ReflectResponse { observed_addr })) => { // "STUN for QUIC" response — the relay told us our