crates/wg-appliance-core/src/hubfixture.rs
//! A **WireGuard hub fixture** — the "other end of the wire" a ceremony proof
//! (or the deploy-test panel) stands up itself: one UDP socket and one
//! responder [`Tunn`], NO tun device, NO root, NO kernel WireGuard.
//!
//! # Why it exists
//!
//! "Configured" is not "connected", and — measured in this suite — a completed
//! HANDSHAKE is not a working TUNNEL either: a sabotaged AllowedIPs router
//! still handshakes while losing every packet. A proof therefore needs a real
//! peer that can say, cryptographically, what actually happened. This fixture
//! is that peer, and its verdict vocabulary is deliberately three-valued:
//!
//! * [`HubVerdict::initiations`] — valid handshake initiations received (the
//! MAC verified against OUR key; garbage and foreign-key traffic instead
//! lands in `refused`).
//! * [`HubVerdict::responses`] — handshake responses we sent back.
//! * [`HubVerdict::sealed_packets`] — datagrams AFTER the handshake that
//! **decrypted under the session keys** (keepalives or data). Only a peer
//! holding the completed session can produce one, so this — not the
//! handshake — is the "traffic crossed the tunnel" floor. IP-layer traffic
//! (a ping THROUGH the tunnel) additionally needs an address on the far
//! side's interface, which is the suite's named address-writer remainder;
//! this fixture still counts and exposes any real payload it decrypts.
//!
//! # One dialect, one writer
//!
//! [`HubFixture::hub_conf`] renders the `hub.conf` a consumer should feed the
//! appliance — through the same constants the ceremony parses, and its own
//! test round-trips it through [`crate::ceremony::parse_hub_config`], so the
//! fixture can never drift into a private dialect.
use std::io;
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{mpsc, Arc};
use std::time::{Duration, Instant};
use boringtun::noise::handshake::parse_handshake_anon;
use boringtun::noise::{Packet, Tunn, TunnResult};
use boringtun::x25519::{PublicKey, StaticSecret};
use crate::keys::{encode_key, generate_keypair, KEY_LEN};
/// The ICMP identifier every fixture ping carries — how the pump tells its own
/// echo replies from stray decrypted traffic.
const PING_IDENT: u16 = 0xE77A;
/// How long the pump sleeps in `recv_timeout` per pass — also the timer tick.
const TICK: Duration = Duration::from_millis(100);
/// What the hub saw, cryptographically. See the module doc for the vocabulary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct HubVerdict {
/// Valid handshake initiations received (MAC verified against our key).
pub initiations: u64,
/// Handshake responses sent back.
pub responses: u64,
/// Post-handshake datagrams that DECRYPTED under the session — keepalives
/// or data. The "traffic crossed" floor.
pub sealed_packets: u64,
/// ICMP echo REPLIES to this fixture's own pings ([`HubFixture::send_ping`])
/// that came back THROUGH the tunnel — the full IP-layer proof: the far
/// kernel routed, answered, and the answer sealed its way home.
pub echo_replies: u64,
/// Datagrams boringtun refused — garbage, or a peer keyed to someone else.
pub refused: u64,
/// **The appliance this hub is talking to**, when the hub was spawned
/// OPEN ([`HubFixture::spawn_open`]) and learned it from the first valid
/// initiation — the static public key that decrypted under OUR private
/// key. `None` until then, and always `None` for a hub spawned toward a
/// known key. A proof compares it to the identity the appliance announced,
/// so an open hub still cannot green-chip the wrong box silently.
pub learned_peer: Option<[u8; KEY_LEN]>,
}
struct Counters {
initiations: AtomicU64,
responses: AtomicU64,
sealed: AtomicU64,
echo_replies: AtomicU64,
refused: AtomicU64,
/// The learned peer's public key (open hubs), published by the pump.
learned_peer: std::sync::Mutex<Option<[u8; KEY_LEN]>>,
}
/// Whom the pump's `Tunn` talks to: a peer known at spawn, or one to be
/// LEARNED from the first initiation that decrypts under the hub's key.
enum Peer {
Known(Tunn),
/// Not yet: the hub's private key, to parse the anonymous initiation with.
Open { hub_private: [u8; KEY_LEN], hub_public: [u8; KEY_LEN] },
}
/// A live hub. Dropping it stops the pump thread.
pub struct HubFixture {
/// The hub's public key, spelled for a `hub.conf` / an operator's screen.
pub public_b64: String,
port: u16,
counters: Arc<Counters>,
stop: Arc<AtomicBool>,
pings: mpsc::Sender<(Ipv4Addr, Ipv4Addr, u16)>,
thread: Option<std::thread::JoinHandle<()>>,
}
/// A hub identity + endpoint reserved BEFORE any appliance exists — what a
/// per-site ISO build needs (the hub's public key and port go into the baked
/// `hub.conf`); activated per appliance via [`HubFixture::spawn_on`].
pub struct HubReservation {
/// The hub's private key — hand it to [`HubFixture::spawn_on`].
pub hub_private: [u8; KEY_LEN],
/// The hub's public key, spelled for a `hub.conf`.
pub public_b64: String,
/// The reserved UDP port.
pub port: u16,
}
/// The ONE hub.conf renderer (the ceremony dialect) — shared by the live
/// fixture and a [`HubReservation`], so the file an ISO bakes and the file a
/// running hub would hand out can never drift.
pub fn render_hub_conf(
public_b64: &str,
endpoint_ip: Ipv4Addr,
port: u16,
allowed_ips: &str,
keepalive_secs: u16,
appliance_address: Option<(Ipv4Addr, u8)>,
) -> String {
let interface = match appliance_address {
Some((a, p)) => format!("[Interface]\nAddress = {a}/{p}\n\n"),
None => String::new(),
};
format!(
"{interface}[Peer]\nPublicKey = {public_b64}\nEndpoint = {endpoint_ip}:{port}\nAllowedIPs = {allowed_ips}\nPersistentKeepalive = {keepalive_secs}\n",
)
}
impl HubReservation {
/// Render the `hub.conf` a per-site ISO should bake to reach the hub this
/// reservation will become — same renderer as the live fixture's.
pub fn hub_conf(
&self,
endpoint_ip: Ipv4Addr,
allowed_ips: &str,
keepalive_secs: u16,
appliance_address: Option<(Ipv4Addr, u8)>,
) -> String {
render_hub_conf(&self.public_b64, endpoint_ip, self.port, allowed_ips, keepalive_secs, appliance_address)
}
}
impl HubFixture {
/// Mint a hub keypair, bind an ephemeral UDP port on all interfaces, and
/// start the responder pump toward ONE expected appliance (identified by
/// its public key — a fixture that accepted anyone could green-chip the
/// wrong box).
pub fn spawn(appliance_public: [u8; KEY_LEN]) -> io::Result<HubFixture> {
let (hub_private, _hub_public) = generate_keypair();
Self::spawn_on(0, hub_private, appliance_public)
}
/// **The two-phase shape a real fleet needs**: a hub's identity and
/// endpoint exist BEFORE any appliance is burned — the per-site ISO bakes
/// the hub's public key and address, and only when a machine first boots
/// does its OWN key exist to authorize. `reserve()` mints the keypair and
/// discovers a free port (bind-and-drop; the tiny race is a test
/// fixture's to accept), so `hub.conf` can be written and the ISO
/// mastered; [`spawn_on`](Self::spawn_on) then activates the pump for the
/// appliance key the first boot minted.
pub fn reserve() -> io::Result<HubReservation> {
let (hub_private, hub_public) = generate_keypair();
let probe = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0))?;
let port = probe.local_addr()?.port();
drop(probe);
Ok(HubReservation { hub_private, public_b64: encode_key(&hub_public), port })
}
/// Activate the responder pump on `port` (0 = ephemeral) with `hub_private`
/// as the hub's identity, toward ONE expected appliance.
pub fn spawn_on(
port: u16,
hub_private: [u8; KEY_LEN],
appliance_public: [u8; KEY_LEN],
) -> io::Result<HubFixture> {
let hub_public = crate::keys::public_from_private_bytes(&hub_private);
let socket = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port))?;
let port = socket.local_addr()?.port();
socket.set_read_timeout(Some(TICK))?;
let tunn = Tunn::new(
StaticSecret::from(hub_private),
PublicKey::from(appliance_public),
None,
None,
0,
None,
);
Self::start(socket, port, hub_public, Peer::Known(tunn))
}
/// **The pre-boot shape of a fixture**: a hub whose appliance does not
/// exist yet. A per-site ISO bakes this hub's public key and endpoint
/// ([`HubReservation`]), the appliance mints ITS key on first boot, and
/// the hub learns that key from the first initiation that decrypts under
/// its own private key (`parse_handshake_anon` — the static-key AEAD is
/// the cryptographic check; a foreign hub's initiation does not decrypt
/// and lands in `refused`). The learned key is published in
/// [`HubVerdict::learned_peer`], so a proof asserts it EQUALS the identity
/// the appliance printed — an open hub still names whom it talked to.
/// Exactly one peer is learned; later initiations from other keys are
/// refused.
pub fn spawn_open(port: u16, hub_private: [u8; KEY_LEN]) -> io::Result<HubFixture> {
let hub_public = crate::keys::public_from_private_bytes(&hub_private);
let socket = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port))?;
let port = socket.local_addr()?.port();
socket.set_read_timeout(Some(TICK))?;
Self::start(socket, port, hub_public, Peer::Open { hub_private, hub_public })
}
fn start(
socket: UdpSocket,
port: u16,
hub_public: [u8; KEY_LEN],
peer: Peer,
) -> io::Result<HubFixture> {
let counters = Arc::new(Counters {
initiations: AtomicU64::new(0),
responses: AtomicU64::new(0),
sealed: AtomicU64::new(0),
echo_replies: AtomicU64::new(0),
refused: AtomicU64::new(0),
learned_peer: std::sync::Mutex::new(None),
});
let stop = Arc::new(AtomicBool::new(false));
let (ping_tx, ping_rx) = mpsc::channel();
let t_counters = counters.clone();
let t_stop = stop.clone();
// A32-14, stays a thread: the test hub's pump, a fixture (feature hubfixture), never in a payload.
let thread = std::thread::Builder::new()
.name("wg-hub-fixture".into())
.spawn(move || pump(socket, peer, t_counters, t_stop, ping_rx))?;
Ok(HubFixture {
public_b64: encode_key(&hub_public),
port,
counters,
stop,
pings: ping_tx,
thread: Some(thread),
})
}
/// Queue an ICMP echo request `src → dst` INTO the tunnel (sealed under
/// the session by the pump). `src` should be the hub's own tunnel address
/// — the far kernel's reply dials it back, and the appliance's AllowedIPs
/// must cover it. The reply is counted in [`HubVerdict::echo_replies`].
pub fn send_ping(&self, src: Ipv4Addr, dst: Ipv4Addr, seq: u16) {
let _ = self.pings.send((src, dst, seq));
}
/// Block until an echo reply came back through the tunnel, or the
/// deadline. Returns the verdict either way.
pub fn wait_for_echo_reply(&self, timeout: Duration) -> HubVerdict {
let deadline = Instant::now() + timeout;
loop {
let v = self.verdict();
if v.echo_replies > 0 || Instant::now() >= deadline {
return v;
}
std::thread::sleep(TICK);
}
}
/// The UDP port the hub listens on.
pub fn port(&self) -> u16 {
self.port
}
/// Render the `hub.conf` an appliance should be handed to reach THIS hub —
/// the ceremony's own dialect, one writer (round-tripped through
/// `parse_hub_config` in this module's tests). `endpoint_ip` is the
/// address the APPLIANCE reaches the hub at (10.0.2.2 from a
/// slirp4netns'd container, the host's LAN address from metal, …).
/// `appliance_address` — the site-assigned `[Interface] Address`, with its
/// REAL prefix (the far kernel's connected route is what answers pings).
pub fn hub_conf(
&self,
endpoint_ip: Ipv4Addr,
allowed_ips: &str,
keepalive_secs: u16,
appliance_address: Option<(Ipv4Addr, u8)>,
) -> String {
render_hub_conf(&self.public_b64, endpoint_ip, self.port, allowed_ips, keepalive_secs, appliance_address)
}
/// The current verdict.
pub fn verdict(&self) -> HubVerdict {
HubVerdict {
initiations: self.counters.initiations.load(Ordering::Relaxed),
responses: self.counters.responses.load(Ordering::Relaxed),
sealed_packets: self.counters.sealed.load(Ordering::Relaxed),
echo_replies: self.counters.echo_replies.load(Ordering::Relaxed),
refused: self.counters.refused.load(Ordering::Relaxed),
learned_peer: *self.counters.learned_peer.lock().unwrap_or_else(|e| e.into_inner()),
}
}
/// Block until at least one valid initiation arrived (the hub answered a
/// real appliance), or the deadline. Returns the verdict either way.
pub fn wait_for_initiation(&self, timeout: Duration) -> HubVerdict {
let deadline = Instant::now() + timeout;
loop {
let v = self.verdict();
if v.initiations > 0 || Instant::now() >= deadline {
return v;
}
std::thread::sleep(TICK);
}
}
/// Block until at least one post-handshake packet DECRYPTED (the
/// traffic-crossed floor), or the deadline passes. Returns the verdict
/// either way — the caller asserts, this only waits.
pub fn wait_for_sealed(&self, timeout: Duration) -> HubVerdict {
let deadline = Instant::now() + timeout;
loop {
let v = self.verdict();
if v.sealed_packets > 0 || Instant::now() >= deadline {
return v;
}
std::thread::sleep(TICK);
}
}
/// Stop the pump. Also runs on drop.
pub fn stop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
impl Drop for HubFixture {
fn drop(&mut self) {
self.stop();
}
}
/// The responder pump: one socket, one `Tunn`, counters instead of a tun.
fn pump(
socket: UdpSocket,
mut peer: Peer,
counters: Arc<Counters>,
stop: Arc<AtomicBool>,
pings: mpsc::Receiver<(Ipv4Addr, Ipv4Addr, u16)>,
) {
let mut inbuf = vec![0u8; 65536];
let mut outbuf = vec![0u8; 65536];
let mut last_src: Option<SocketAddr> = None;
while !stop.load(Ordering::Relaxed) {
// Queued pings → sealed into the tunnel. Only meaningful once a
// session exists; encapsulate before that queues inside boringtun and
// flushes with the handshake, which is fine too. An OPEN hub with no
// peer yet has nothing to seal under; the ping is dropped.
while let Ok((src, dst, seq)) = pings.try_recv() {
let Peer::Known(tunn) = &mut peer else { continue };
let packet = craft_icmp_echo_request(src, dst, PING_IDENT, seq);
if let TunnResult::WriteToNetwork(p) = tunn.encapsulate(&packet, &mut outbuf) {
if let Some(to) = last_src {
let _ = socket.send_to(p, to);
}
}
}
match socket.recv_from(&mut inbuf) {
Ok((n, src)) => {
// WireGuard message type 1 = handshake initiation — recorded
// BEFORE decapsulation so the counter distinguishes "a valid
// initiation we answered" from "sealed traffic" below.
let is_initiation = n >= 4 && inbuf[0] == 1;
// An OPEN hub learns its peer here: the initiation's encrypted
// static must decrypt under OUR key, and the key it reveals
// becomes the one peer this hub talks to. Anything else before
// a peer is known is refused — counted, not silent.
if let Peer::Open { hub_private, hub_public } = &peer {
let learned = match Tunn::parse_incoming_packet(&inbuf[..n]) {
Ok(Packet::HandshakeInit(init)) => parse_handshake_anon(
&StaticSecret::from(*hub_private),
&PublicKey::from(*hub_public),
&init,
)
.ok()
.map(|half| half.peer_static_public),
_ => None,
};
match learned {
Some(appliance_public) => {
let tunn = Tunn::new(
StaticSecret::from(*hub_private),
PublicKey::from(appliance_public),
None,
None,
0,
None,
);
*counters.learned_peer.lock().unwrap_or_else(|e| e.into_inner()) =
Some(appliance_public);
peer = Peer::Known(tunn);
}
None => {
counters.refused.fetch_add(1, Ordering::Relaxed);
continue;
}
}
}
let Peer::Known(tunn) = &mut peer else { unreachable!("a peer is known here") };
let mut accepted = false;
let mut responded = false;
// The feed idiom from the data plane: hand the datagram over,
// then flush with empty datagrams until Done.
let mut first = true;
loop {
let r = tunn.decapsulate(
if first { Some(src.ip()) } else { None },
if first { &inbuf[..n] } else { &[] },
&mut outbuf,
);
first = false;
match r {
TunnResult::WriteToNetwork(packet) => {
accepted = true;
responded = true;
let _ = socket.send_to(packet, src);
}
// Decrypted payload. A fixture has no tun to write it
// to; counting it IS the point — and an echo REPLY to
// our own ping is the full IP-through-the-tunnel rung.
TunnResult::WriteToTunnelV4(packet, _) => {
accepted = true;
counters.sealed.fetch_add(1, Ordering::Relaxed);
if is_icmp_echo_reply_to(packet, PING_IDENT) {
counters.echo_replies.fetch_add(1, Ordering::Relaxed);
}
}
TunnResult::WriteToTunnelV6(..) => {
accepted = true;
counters.sealed.fetch_add(1, Ordering::Relaxed);
}
TunnResult::Done => {
accepted = true;
break;
}
TunnResult::Err(_) => {
if !accepted {
counters.refused.fetch_add(1, Ordering::Relaxed);
}
break;
}
}
}
if accepted {
last_src = Some(src);
if is_initiation {
counters.initiations.fetch_add(1, Ordering::Relaxed);
if responded {
counters.responses.fetch_add(1, Ordering::Relaxed);
}
} else {
// A non-initiation datagram boringtun accepted: it
// decrypted under the session (keepalive or data) —
// the WriteToTunnel arm above already counted real
// payload; a keepalive surfaces as Done, counted here.
counters.sealed.fetch_add(1, Ordering::Relaxed);
}
}
}
Err(e)
if e.kind() == io::ErrorKind::WouldBlock
|| e.kind() == io::ErrorKind::TimedOut => {}
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(_) => break,
}
// Timers each pass — the responder side rekeys and expires too.
if let Peer::Known(tunn) = &mut peer {
if let TunnResult::WriteToNetwork(packet) = tunn.update_timers(&mut outbuf) {
if let Some(dst) = last_src {
let _ = socket.send_to(packet, dst);
}
}
}
}
}
/// One's-complement checksum over 16-bit words (RFC 1071) — IPv4 header and
/// ICMP both use it.
fn inet_checksum(data: &[u8]) -> u16 {
let mut sum: u32 = 0;
for chunk in data.chunks(2) {
let word = if chunk.len() == 2 {
u16::from_be_bytes([chunk[0], chunk[1]])
} else {
u16::from_be_bytes([chunk[0], 0])
};
sum += u32::from(word);
}
while sum >> 16 != 0 {
sum = (sum & 0xffff) + (sum >> 16);
}
!(sum as u16)
}
/// A minimal IPv4 + ICMP echo-request packet, checksums computed — what the
/// fixture seals into the tunnel as its ping.
pub fn craft_icmp_echo_request(src: Ipv4Addr, dst: Ipv4Addr, ident: u16, seq: u16) -> Vec<u8> {
let payload = b"wg-appliance-hub-fixture";
let icmp_len = 8 + payload.len();
let total_len = 20 + icmp_len;
let mut p = vec![0u8; total_len];
// IPv4 header.
p[0] = 0x45; // version 4, IHL 5
p[2..4].copy_from_slice(&(total_len as u16).to_be_bytes());
p[8] = 64; // TTL
p[9] = 1; // protocol: ICMP
p[12..16].copy_from_slice(&src.octets());
p[16..20].copy_from_slice(&dst.octets());
let hcs = inet_checksum(&p[..20]);
p[10..12].copy_from_slice(&hcs.to_be_bytes());
// ICMP echo request.
p[20] = 8; // type
p[24..26].copy_from_slice(&ident.to_be_bytes());
p[26..28].copy_from_slice(&seq.to_be_bytes());
p[28..].copy_from_slice(payload);
let ics = inet_checksum(&p[20..]);
p[22..24].copy_from_slice(&ics.to_be_bytes());
p
}
/// Is `packet` an IPv4 ICMP echo REPLY carrying `ident`?
fn is_icmp_echo_reply_to(packet: &[u8], ident: u16) -> bool {
if packet.len() < 28 || packet[0] >> 4 != 4 || packet[9] != 1 {
return false;
}
let ihl = usize::from(packet[0] & 0x0f) * 4;
let icmp = &packet[ihl..];
icmp.len() >= 8
&& icmp[0] == 0 // echo reply
&& u16::from_be_bytes([icmp[4], icmp[5]]) == ident
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ceremony::parse_hub_config;
/// Drive a MANUAL client `Tunn` over real localhost UDP — no tun device,
/// no root: initiation out, response decapsulated, then a keepalive sealed
/// under the session. The fixture's verdict must climb every rung.
#[test]
fn a_real_handshake_and_a_sealed_keepalive_reach_the_verdict() {
let (app_private, app_public) = generate_keypair();
let hub = HubFixture::spawn(app_public).unwrap();
let mut client = Tunn::new(
StaticSecret::from(app_private),
PublicKey::from(crate::keys::decode_key(&hub.public_b64).unwrap()),
None,
None,
0,
None,
);
let sock = UdpSocket::bind("127.0.0.1:0").unwrap();
sock.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
let hub_addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, hub.port());
// Initiation → hub.
let mut buf = [0u8; 2048];
let init = match client.format_handshake_initiation(&mut buf, false) {
TunnResult::WriteToNetwork(p) => p.to_vec(),
other => panic!("no initiation: {other:?}"),
};
sock.send_to(&init, hub_addr).unwrap();
// Response ← hub; feed it through the client to complete the session.
let mut resp = [0u8; 2048];
let (n, _) = sock.recv_from(&mut resp).expect("the hub answers an initiation");
let mut out = [0u8; 2048];
match client.decapsulate(None, &resp[..n], &mut out) {
// Completing the handshake makes the client emit its first
// post-handshake packet (the queue flush / keepalive) — send it.
TunnResult::WriteToNetwork(p) => {
sock.send_to(p, hub_addr).unwrap();
}
TunnResult::Done => {
// No queued packet: seal one explicitly (an empty payload is
// a keepalive).
if let TunnResult::WriteToNetwork(p) = client.encapsulate(&[], &mut buf) {
sock.send_to(p, hub_addr).unwrap();
}
}
other => panic!("the response did not complete the handshake: {other:?}"),
}
let v = hub.wait_for_sealed(Duration::from_secs(5));
assert!(v.initiations >= 1, "the hub saw the initiation: {v:?}");
assert!(v.responses >= 1, "the hub answered it: {v:?}");
assert!(
v.sealed_packets >= 1,
"the traffic-crossed floor: a packet DECRYPTED under the session: {v:?}"
);
assert_eq!(v.refused, 0, "nothing was refused in the green run: {v:?}");
// ── the full IP rung, hand-driven: fixture pings; this "kernel" (the
// test) decapsulates the request, crafts the reply, seals it back;
// the fixture must count exactly the reply — not the request, not a
// keepalive.
hub.send_ping("10.77.0.1".parse().unwrap(), "10.77.0.7".parse().unwrap(), 1);
let mut request = None;
let deadline = Instant::now() + Duration::from_secs(5);
while request.is_none() && Instant::now() < deadline {
let mut wire = [0u8; 2048];
let Ok((n, _)) = sock.recv_from(&mut wire) else { continue };
let mut plain = [0u8; 2048];
match client.decapsulate(None, &wire[..n], &mut plain) {
TunnResult::WriteToTunnelV4(p, _) if p.len() >= 28 && p[20] == 8 => {
request = Some(p.to_vec());
}
TunnResult::WriteToNetwork(p) => {
sock.send_to(p, hub_addr).unwrap();
}
_ => {}
}
}
let request = request.expect("the fixture's echo request reached the far side");
// Reply: swap src/dst, type 0, recompute both checksums.
let mut reply = request.clone();
let (src, dst) = (reply[12..16].to_vec(), reply[16..20].to_vec());
reply[12..16].copy_from_slice(&dst);
reply[16..20].copy_from_slice(&src);
reply[10] = 0;
reply[11] = 0;
let hcs = inet_checksum(&reply[..20]);
reply[10..12].copy_from_slice(&hcs.to_be_bytes());
reply[20] = 0; // echo reply
reply[22] = 0;
reply[23] = 0;
let ics = inet_checksum(&reply[20..]);
reply[22..24].copy_from_slice(&ics.to_be_bytes());
let mut sealed = [0u8; 2048];
if let TunnResult::WriteToNetwork(p) = client.encapsulate(&reply, &mut sealed) {
sock.send_to(p, hub_addr).unwrap();
}
let v = hub.wait_for_echo_reply(Duration::from_secs(5));
assert!(v.echo_replies >= 1, "the reply crossed back and was recognized: {v:?}");
}
/// The ICMP craft/parse pair agree with themselves and with the shapes
/// they must reject — the controls that keep `echo_replies` honest.
#[test]
fn icmp_craft_and_recognition_agree() {
let req = craft_icmp_echo_request(
"10.77.0.1".parse().unwrap(),
"10.77.0.7".parse().unwrap(),
PING_IDENT,
7,
);
assert_eq!(inet_checksum(&req[..20]), 0, "a valid IPv4 header sums to zero");
assert_eq!(inet_checksum(&req[20..]), 0, "a valid ICMP body sums to zero");
assert!(!is_icmp_echo_reply_to(&req, PING_IDENT), "a REQUEST is not a reply");
let mut reply = req.clone();
reply[20] = 0;
assert!(is_icmp_echo_reply_to(&reply, PING_IDENT));
assert!(!is_icmp_echo_reply_to(&reply, 0x1234), "a foreign ident is not ours");
assert!(!is_icmp_echo_reply_to(&reply[..10], PING_IDENT), "a runt is not a reply");
}
/// RED — an appliance keyed to the WRONG hub never completes: the fixture
/// refuses its initiation (the MAC is keyed to the hub's real public key),
/// and nothing seals. The green test above is this test's control.
#[test]
fn a_client_aimed_at_the_wrong_hub_key_is_refused_and_nothing_seals() {
let (app_private, app_public) = generate_keypair();
let (_, wrong_hub_public) = generate_keypair();
let hub = HubFixture::spawn(app_public).unwrap();
let mut client = Tunn::new(
StaticSecret::from(app_private),
PublicKey::from(wrong_hub_public),
None,
None,
0,
None,
);
let sock = UdpSocket::bind("127.0.0.1:0").unwrap();
sock.set_read_timeout(Some(Duration::from_millis(500))).unwrap();
let hub_addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, hub.port());
let mut buf = [0u8; 2048];
if let TunnResult::WriteToNetwork(p) = client.format_handshake_initiation(&mut buf, false) {
sock.send_to(p, hub_addr).unwrap();
}
let mut resp = [0u8; 2048];
assert!(
sock.recv_from(&mut resp).is_err(),
"the hub must not answer an initiation keyed to someone else"
);
let v = hub.wait_for_sealed(Duration::from_millis(700));
assert_eq!(v.initiations, 0, "{v:?}");
assert_eq!(v.sealed_packets, 0, "{v:?}");
assert!(v.refused >= 1, "the refusal is COUNTED, not silent: {v:?}");
}
/// An OPEN hub (no appliance key at spawn — the pre-boot fixture shape)
/// learns the peer from the first initiation that decrypts under its
/// key, answers it, seals, and NAMES the learned key; a foreign-keyed
/// initiation before that is refused and learns nothing (the red).
#[test]
fn an_open_hub_learns_the_peer_from_the_first_valid_initiation_and_names_it() {
let (hub_private, _) = generate_keypair();
let hub = HubFixture::spawn_open(0, hub_private).unwrap();
let hub_addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, hub.port());
let hub_public = crate::keys::decode_key(&hub.public_b64).unwrap();
// RED first: an initiation aimed at a DIFFERENT hub key does not
// decrypt under ours — refused, nothing learned.
let (stranger_private, _) = generate_keypair();
let (_, other_hub_public) = generate_keypair();
let mut stranger = Tunn::new(
StaticSecret::from(stranger_private),
PublicKey::from(other_hub_public),
None,
None,
0,
None,
);
let sock = UdpSocket::bind("127.0.0.1:0").unwrap();
sock.set_read_timeout(Some(Duration::from_millis(500))).unwrap();
let mut buf = [0u8; 2048];
if let TunnResult::WriteToNetwork(p) = stranger.format_handshake_initiation(&mut buf, false) {
sock.send_to(p, hub_addr).unwrap();
}
let mut resp = [0u8; 2048];
assert!(sock.recv_from(&mut resp).is_err(), "a foreign-keyed initiation is not answered");
let v = hub.wait_for_initiation(Duration::from_millis(700));
assert_eq!(v.initiations, 0, "{v:?}");
assert_eq!(v.learned_peer, None, "nothing learned from a stranger: {v:?}");
assert!(v.refused >= 1, "and the refusal is counted: {v:?}");
// GREEN: the real appliance, keyed to THIS hub, minted after the hub.
let (app_private, app_public) = generate_keypair();
let mut client = Tunn::new(
StaticSecret::from(app_private),
PublicKey::from(hub_public),
None,
None,
0,
None,
);
sock.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
let init = match client.format_handshake_initiation(&mut buf, false) {
TunnResult::WriteToNetwork(p) => p.to_vec(),
other => panic!("no initiation: {other:?}"),
};
sock.send_to(&init, hub_addr).unwrap();
let (n, _) = sock.recv_from(&mut resp).expect("the open hub answers the initiation");
let mut out = [0u8; 2048];
match client.decapsulate(None, &resp[..n], &mut out) {
TunnResult::WriteToNetwork(p) => {
sock.send_to(p, hub_addr).unwrap();
}
TunnResult::Done => {
if let TunnResult::WriteToNetwork(p) = client.encapsulate(&[], &mut buf) {
sock.send_to(p, hub_addr).unwrap();
}
}
other => panic!("the response did not complete the handshake: {other:?}"),
}
let v = hub.wait_for_sealed(Duration::from_secs(5));
assert!(v.initiations >= 1 && v.responses >= 1, "{v:?}");
assert!(v.sealed_packets >= 1, "traffic crossed: {v:?}");
assert_eq!(
v.learned_peer,
Some(app_public),
"the hub NAMES the appliance it learned — the proof compares this to the identity line"
);
}
/// The rendered hub.conf is the ceremony's own dialect — round-tripped
/// through the shared parser, so the fixture cannot drift into a private
/// spelling.
#[test]
fn the_rendered_hub_conf_round_trips_through_the_ceremony_parser() {
let (_, app_public) = generate_keypair();
let hub = HubFixture::spawn(app_public).unwrap();
let addr = Some(("10.77.0.7".parse().unwrap(), 16));
let text = hub.hub_conf("10.0.2.2".parse().unwrap(), "10.77.0.0/16", 1, addr);
let peer = parse_hub_config("fixture", &text).unwrap();
assert_eq!(encode_key(&peer.public_key), hub.public_b64);
assert_eq!(
peer.endpoint,
Some(SocketAddrV4::new("10.0.2.2".parse().unwrap(), hub.port()))
);
assert_eq!(peer.allowed_ips, vec![("10.77.0.0".parse().unwrap(), 16)]);
assert_eq!(peer.persistent_keepalive, Some(1));
assert_eq!(peer.appliance_address, addr, "the [Interface] Address rode along");
// And the address-free form stays legal.
let bare = hub.hub_conf("10.0.2.2".parse().unwrap(), "10.77.0.0/16", 1, None);
assert_eq!(parse_hub_config("fixture", &bare).unwrap().appliance_address, None);
}
}