feat(nat): birthday attack module + HardNatBirthdayStart signal (#86, #87)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 25s
Build Release Binaries / build-amd64 (push) Failing after 3m43s

Birthday attack for random symmetric NATs:
- birthday.rs: open_acceptor_ports() opens N sockets, STUN-probes
  each to learn external ports. generate_dialer_targets() builds
  hit list (known ports first, then random fill). spray_dialer()
  sprays QUIC connects with rate limiting, first success wins.
- Default: 32 acceptor ports, 128 dialer probes, 20ms interval

Signal coordination:
- HardNatBirthdayStart { acceptor_ports, external_ip } sent by
  Acceptor when peer's HardNatProbe shows random/sequential NAT
- Relay forwards it like other call signals
- Desktop recv loop handles and logs it

Hybrid waterfall integration:
- On receiving HardNatProbe with non-cone allocation, Acceptor
  auto-opens birthday ports and sends BirthdayStart
- Sockets kept alive 10s for NAT mapping persistence
- Dialer spray integration into race() pending (needs transport
  hot-swap for background upgrade)

6 new tests, 599 total, 0 regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-14 16:44:36 +04:00
parent 6c49d7436f
commit f06f9073ae
6 changed files with 422 additions and 4 deletions

View File

@@ -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<u16> = 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