Deep dive
How it works
In: inner IP packets, local private key, peers' public keys with AllowedIPs, optional 32-byte pre-shared key · Out: UDP datagrams carrying encrypted packets; on receive, authenticated inner packets · State: per peer: static key, AllowedIPs, endpoint, current/previous/next session (keys, 64-bit counters, replay window), latest initiation timestamp, cookie · Cost: handshake 4 DH per side, then one AEAD per packet
- Send routing. The destination IP is looked up by longest-prefix match in the cryptokey table; the owner is the peer. No match: drop with "no route to host".
- Initiation (148 B). The initiator sends type 1, a sender index, an ephemeral public key, its static key encrypted (48 B), a TAI64N timestamp encrypted (28 B),
mac1andmac2(16 B each). Noise patterne, es, s, ss; DH results chain through HMAC-BLAKE2s key derivation and the running hash is the AAD. - Response (92 B). The responder looks up the initiator's static key in its peer list, checks the timestamp is greater than the last one seen from that peer, and answers type 2 with an ephemeral key and an empty AEAD payload (
e, ee, se, psk). The pre-shared key defaults to 32 zero bytes. - Keys. Both derive two 32-byte transport keys, one per direction, and zero the handshake state. The responder waits for the first data packet (key confirmation); the initiator sends a keepalive if it has none.
- Data (type 4). Header: type, 3 zero bytes, receiver index, 64-bit little-endian counter (16 B); then the inner packet zero-padded to a multiple of 16, sealed with ChaCha20-Poly1305, nonce = 32 zero bits plus the counter, plus a 16-byte tag. An empty payload is a 32-byte keepalive.
- Receive. The receiver index finds the session, the replay window is checked, the AEAD is opened, then the window is updated. The inner source IP must match the decrypting peer's AllowedIPs, else drop.
- Roaming. After a correctly authenticated packet, the peer's endpoint becomes that packet's outer source IP and port; replies go there.
- Timers. Rekey-After-Time 120 s (initiator), Rekey-After-Messages 2^60, Reject-After-Time 180 s, Reject-After-Messages 2^64 − 2^13 − 1, Rekey-Timeout 5 s, Rekey-Attempt-Time 90 s, Keepalive-Timeout 10 s (whitepaper §6.1).
Silence and load. mac1 is a keyed BLAKE2s over the message, keyed with a hash of the receiver's public key: a sender without it gets no reply and no state is allocated. Under load a valid mac1 without a valid mac2 gets a 64-byte cookie reply (smaller than an initiation, so no amplification): a MAC of the sender's outer IP and port under a secret changed every 2 minutes. A retry with mac2 proves address ownership and can be rate limited, like a SYN cookie.
Code
Rust std only, no crypto: cryptokey routing (IPv4 trie, longest prefix wins), the inbound source check and a per-peer receive window (RFC 6479 ring, W = 8128 as in WireGuard).
#[derive(Default)]
struct Node { kid: [Option<usize>; 2], peer: Option<usize> } // binary trie over IPv4 bits
#[derive(Default)]
struct Routing { nodes: Vec<Node> }
impl Routing {
/// `net/len` belongs to `peer` (re-inserting a prefix moves it).
fn insert(&mut self, net: u32, len: u32, peer: usize) {
if self.nodes.is_empty() { self.nodes.push(Node::default()); }
let mut at = 0;
for i in 0..len {
let b = (net >> (31 - i)) as usize & 1;
at = self.nodes[at].kid[b].unwrap_or_else(|| {
self.nodes.push(Node::default());
self.nodes[at].kid[b] = Some(self.nodes.len() - 1);
self.nodes.len() - 1
});
}
self.nodes[at].peer = Some(peer);
}
/// Longest-prefix match: keep the last peer seen on the walk down.
fn lookup(&self, ip: u32) -> Option<usize> {
let (mut at, mut best) = (0, self.nodes.first()?.peer);
for i in 0..32 {
let Some(n) = self.nodes[at].kid[(ip >> (31 - i)) as usize & 1] else { break };
at = n;
best = self.nodes[at].peer.or(best);
}
best
}
}
const M: usize = 128; // ring words, window W = (M-1)*64 = 8128 counters
const W: u64 = (M as u64 - 1) * 64;
/// Window over n = counter + 1 (RFC 6479 ring, no bit shifting).
struct Window { top: u64, ring: [u64; M] }
impl Window {
fn check(&self, c: u64) -> bool {
let n = c + 1;
n > self.top || (self.top - n < W && self.ring[(n / 64) as usize % M] >> (n % 64) & 1 == 0)
}
/// Call only after the AEAD tag verified; false = duplicate.
fn update(&mut self, c: u64) -> bool {
let n = c + 1;
if n > self.top {
for i in 1..=(n / 64 - self.top / 64).min(M as u64) {
self.ring[(self.top / 64 + i) as usize % M] = 0; // clear words the window enters
}
self.top = n;
}
let (w, bit) = ((n / 64) as usize % M, 1u64 << (n % 64));
let fresh = self.ring[w] & bit == 0;
self.ring[w] |= bit;
fresh
}
}
/// `peer` owns the key that opened the packet; `src` is the inner source IP.
fn receive(r: &Routing, w: &mut Window, peer: usize, counter: u64, src: u32) -> Result<(), &'static str> {
if !w.check(counter) { return Err("replay"); }
// ... AEAD open with the peer's receiving key goes here; on failure drop silently ...
if !w.update(counter) { return Err("replay"); }
if r.lookup(src) != Some(peer) { return Err("wrong source"); }
Ok(())
}
fn ip(a: u8, b: u8, c: u8, d: u8) -> u32 { u32::from_be_bytes([a, b, c, d]) }
fn main() {
let mut r = Routing::default();
for (peer, net, len) in [(1, ip(10, 192, 122, 3), 32), (1, ip(10, 192, 124, 0), 24),
(2, ip(192, 168, 0, 0), 16), (3, ip(0, 0, 0, 0), 0), (3, ip(192, 168, 7, 0), 24)] {
r.insert(net, len, peer);
}
assert_eq!(r.lookup(ip(192, 168, 87, 21)), Some(2));
assert_eq!(r.lookup(ip(192, 168, 7, 9)), Some(3)); // /24 beats /16
assert_eq!(r.lookup(ip(8, 8, 8, 8)), Some(3)); // 0.0.0.0/0 catches the rest
let mut w = Window { top: 0, ring: [0; M] }; // peer 1's receive window
assert_eq!(receive(&r, &mut w, 1, 0, ip(10, 192, 124, 5)), Ok(()));
assert_eq!(receive(&r, &mut w, 1, 1, ip(192, 168, 1, 1)), Err("wrong source")); // peer 2's range
assert_eq!(receive(&r, &mut w, 1, 0, ip(10, 192, 124, 5)), Err("replay"));
assert_eq!(receive(&r, &mut w, 1, 9000, ip(10, 192, 122, 3)), Ok(()));
assert_eq!(receive(&r, &mut w, 1, 100, ip(10, 192, 122, 3)), Err("replay")); // 9001-101 >= W
assert_eq!(receive(&r, &mut w, 1, 8999, ip(10, 192, 122, 3)), Ok(())); // reordered, in window
}
Compiled with stable rustc; all asserts pass. A timing harness with the same algorithms (1000 peers, 4 random /16-/32 prefixes each, 4096 random addresses, 20 million lookups) gave 21 ns per lookup and 1.1 ns per window check + update on an Apple M4 Pro (two runs).
Complexity and constants
- Lookup O(32) trie steps for IPv4 (O(128) for IPv6), independent of peer count. Measured: order of 20 ns on a warm 4,000-prefix trie; a cold trie costs up to one cache miss per level.
- Window check + update O(1), 1 KiB of ring per peer (measured 1.1 ns).
- Wire sizes: initiation 148 B, response 92 B, cookie reply 64 B, data 32 B + padded payload; with IPv4 and UDP, a keepalive is 60 B on the wire.
- Cost drivers: one ChaCha20-Poly1305 pass and one UDP syscall per datagram unless batched; one handshake per ~120 s per peer pair.
Efficiency
- Cost: per data packet one AEAD plus a ~20 ns lookup and ~1 ns window test (measured here); per session 4 Curve25519 DH per side plus one ephemeral key pair; the bound is AEAD throughput and per-packet syscall and cache cost, not the handshake.
- Levers:
- GSO "super packets" — encrypt and send a group, route cached; whitepaper reports 35% faster sending from fewer cache misses (§7.1) — Linux kernel path
- parallel crypto — kernel padata spreads AEAD over cores, order kept (whitepaper §7.2) — high-bandwidth links
- 16-byte aligned header and padding — in-place decryption with natural alignment (§5.4.6) — any CPU
- radix trie with lockless RCU reads — no lock on the per-packet routing lookup (§7.4) — many cores
- Naive → standard → state of the art:
- userspace tunnel, one read/write syscall per packet through a TUN device · one syscall pair per packet
- in-kernel virtual interface, per-peer queues · no TUN copy, one core per flow
- GSO batching and multi-core crypto in the kernel (whitepaper §7.1-7.2) · fewer syscalls and cache misses per byte
Implementation notes
- Cryptokey table: one prefix belongs to one peer, so moving
10.0.0.0/24from A to B is aninsert;0.0.0.0/0makes a peer the default gateway. Keep IPv4 and IPv6 in separate tries. - Index tables (32-bit session indexes, like IPsec SPIs) use a keyed hash against collision attacks; the kernel uses SipHash-2-4 (whitepaper §7.4).
- Update the window only after the AEAD opens; a forged counter must not move
top. Counters start at 0. - Without the inner-source check any peer could inject packets claiming another peer's address.
- Endpoints are learned automatically; set
PersistentKeepalive(the quickstart suggests 25 s) on peers behind NAT that must stay reachable. - Compare MACs in constant time (constant-time code).
Pitfalls
- Overlapping AllowedIPs are legal; a forgotten
/32silently takes traffic from a/16. 0.0.0.0/0on two peers: the prefix has one owner, the last one inserted.- A peer behind NAT becomes unreachable once its mapping expires, unless keepalives run.
- A stuck handshake retries only every 5 s (plus up to 333 ms jitter) for 90 s, then waits for new traffic.
- MTU: tunnel overhead (at least 60 bytes over IPv4, 80 over IPv6) shrinks the inner MTU; black holes stall large transfers.
- No key distribution, expiry or revocation inside the protocol.
Variants and alternatives
In production
- Linux kernel, ports for Windows, macOS/iOS and Android;
wireguard-goand Rust userspace implementations. - Tailscale: a mesh of point-to-point WireGuard tunnels, with its clients shipping the
wireguard-gopackage. - Cilium and Calico: encryption of pod traffic between Kubernetes nodes (Cilium uses UDP port 51871).
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| WireGuard (Linux) | C | WireGuard/wireguard-linux | kernel built-in (ip link add wg0 type wireguard) | — | — | — | — | reference implementation |
| wireguard-tools | C | WireGuard/wireguard-tools | brew install wireguard-tools | — | — | — | — | wg and wg-quick configuration tools |
| wireguard-go | Go | WireGuard/wireguard-go | brew install wireguard-go | — | — | — | — | userspace; used by macOS/iOS/Android apps and Tailscale |
| BoringTun | Rust | cloudflare/boringtun | cargo add boringtun | — | — | — | — | userspace library and CLI |
| GotaTun | Rust | mullvad/gotatun | cargo add gotatun | — | — | — | — | userspace, portability and speed |
| Tailscale | Go | tailscale/tailscale | brew install tailscale | — | — | — | — | mesh and key distribution on top of WireGuard |
Sources
- Donenfeld, WireGuard: Next Generation Kernel Network Tunnel, NDSS 2017. paper · DOI · code
- Donenfeld, WireGuard: Protocol & Cryptography. wireguard.com/protocol
- Perrin, The Noise Protocol Framework. spec
- Zhang, Tsou, IPsec Anti-Replay Algorithm without Bit Shifting, RFC 6479, 2012. RFC
- Cilium docs, WireGuard Transparent Encryption. docs
- Tigera, Encrypt in-cluster pod traffic (Calico). docs
- Tailscale, How Tailscale works. blog