Firezone releases sanstun, a sans-IO WireGuard library in Rust

Firezone releases sanstun, a sans-IO WireGuard library in Rust

Firezone has announced sanstun, a sans-IO Rust implementation of the WireGuard state machine. The authors say it is optimized for ease of use, security and performance through a parallel-friendly and idiomatic API. It is open source under the Apache 2.0 license and available on crates.io.

Sans-IO means designing a system so that IO and side-effects are cleanly separated from state and business logic. In Firezone's definition, IO covers more than files and network: it also covers time and randomness. The post says such components compose well, are relatively easy to test, avoid function coloring and fit Rust's ownership rules. The idea is not specific to Rust.

The library comes out of Firezone's history with boringtun. When Firezone first built its product, it used boringtun as its WireGuard implementation. Over time the company kept patching its fork with bug fixes and API additions, while trying to keep the patches upstreamable by leaving the existing API working. That became harder as product requirements changed. Two early changes illustrate the strain. The first was making boringtun fully sans-IO for time, which meant adding new APIs with an _at suffix that take an Instant; the post says this matters if you want to accurately simulate an entire system. The second was putting internal buffering behind a feature flag. Boringtun sets up a session automatically when you send a packet without one, but the post lists problems with that: the buffer has to be drained, which boringtun does by requiring a call to decapsulate with a zero-length datagram, an unintuitive step; it causes extra allocations, at least in boringtun's implementation; and buffer sizes should be configurable. Firezone's conclusion was not to buffer in the library at all. Excluding dependency bumps, the patches to the fork came to more than 70 PRs, some fixing edge cases and others adding APIs or making behaviour configurable. After more than two years of using and patching boringtun, the team wrote sanstun, and its fork at firezone/boringtun is now deprecated.

The sans-IO API is built from a few blocks. For inbound packets: Tunnel::handle_handshake_initiation, handle_handshake_response, handle_cookie_reply, and a begin_decapsulate and commit_decapsulate pair. For outbound packets: Tunnel::begin_encapsulate. For timers: handle_timeout and poll_timeout. For events: poll_event. Because nothing is buffered internally, no call has to be made in a loop. To send a packet you call begin_encapsulate, and if there is no session yet, buffering the packet is up to you. A new session means sending a handshake, which you can start at any time with Tunnel::initiate_handshake.

The post recaps the WireGuard protocol to explain the design. WireGuard has four message types: handshake initiation, handshake response, cookie reply and encrypted data. Every session starts with a Diffie-Hellman key exchange, which is moderately expensive. Anyone who knows an endpoint's public key can send bogus handshake initiations with a forged source IP, and the receiver has to do its half of the DH work before it can tell they are bogus. To limit this, WireGuard uses a cookie mechanism that makes the remote prove it owns its claimed source IP, which allows rate limiting per source IP. These limits apply to the whole WireGuard interface, not a single tunnel. After the handshake, peers exchange session indices, which allow tunnels, and several sessions within a tunnel, to be told apart.

Sanstun maps these invariants onto types. A WireGuard interface is identified by one key, and a tunnel is a connection between two interfaces, so the library has Interface and Tunnel types. Every inbound packet goes through Interface::receive, the only public API that parses a raw datagram. It applies rate limits and returns a ReceivedMessage with one enum variant per message type, or a cookie reply to send back if the interface is under load. Handshake initiations are routed by the initiator's static public key; other messages carry a TunnelId saying which tunnel should handle them. Internally the library extracts the upper 24 bits of the tunnel index, applies rate limits, generates cookie replies and validates handshake messages. Constructors for types like HandshakeInitiation are crate-private, so the only way to reach Tunnel::handle_handshake_initiation is through Interface::receive. That means the rate limiter cannot be forgotten, and routing a packet to the wrong tunnel is harder to do by accident.

The headline feature is parallel crypto. WireGuard shares no state between tunnels, so parallelising across peers is easy. Within one tunnel it is not: WireGuard works at layer 3, so one session carries many network flows, and all flows (unique 5-tuples) toward the same host or subnet are processed sequentially, bounded by the speed of ChaCha20-Poly1305. The per-session packet counter, which protects against replay, would need synchronisation if several threads touched it. Sanstun therefore splits bookkeeping from encryption. Tunnel::begin_encapsulate encrypts nothing: it checks the current session, reserves a packet counter, advances timer state and returns a PendingEncapsulate. That type implements Send, so it can move to another thread, where its encrypt method does the bulk of the work.

The post shows cargo bench data_path results from the sanstun repository. begin_encapsulate takes about 20.8 ns for both 64-byte and 1280-byte packets (middle value 20.828 ns). Encrypt takes about 226 ns for 64 bytes and about 934 ns for 1280 bytes (226.22 ns and 933.54 ns). Decrypt is about 206 ns and 915 ns for the same sizes; begin_decapsulate is about 37.7 ns and commit_decapsulate about 17.5 to 17.7 ns. The bookkeeping steps do not scale with packet size, while encrypt and decrypt do. At 1280 bytes the bookkeeping part of encrypting a new packet is only 2% of the cost, which the post says hints at a potential speedup of more than 45x if the encryption moves to another thread. It adds that 45x is hard to achieve in practice, because it ignores the cost of handing PendingEncapsulate to another thread and assumes no other overhead. Offloading to worker threads should still give good throughput gains, the post says.

Sanstun does not dictate how to parallelise. Callers who want simplicity can call encrypt and decrypt inline. The stated design goal was to allow parallel crypto while keeping everything around Interface single-threaded, which lets the internal rate limiter work without locks or atomic counters and use Rust's borrow checker while combining state from multiple tunnels. Firezone closes by saying that anyone building on WireGuard, whether a VPN, a mesh network or something else, may find sanstun makes life easier, and invites issues and pull requests on GitHub.

Key facts

  • Firezone announced sanstun, an open-source (Apache 2.0) sans-IO WireGuard state machine in Rust, available on crates.io.
  • It grew out of more than 2 years of patching boringtun; the fork had more than 70 PRs excluding dependency bumps, and the firezone/boringtun fork is now deprecated.
  • Tunnel::begin_encapsulate only does bookkeeping and returns a Send-able PendingEncapsulate, so encryption can run on another thread; callers can also encrypt inline.
  • In the post's benchmarks, bookkeeping is about 20.8 ns for any packet size, while encrypting 1280 bytes takes about 934 ns, so bookkeeping is 2% of the cost and the theoretical speedup is more than 45x (hard to reach in practice).
  • The library does no internal buffering, and all inbound packets pass through Interface::receive, which applies the rate limiter and cannot be bypassed because handshake types have crate-private constructors.

Why it matters

Firezone's pitch is that a userspace WireGuard library can be designed so the protocol's invariants are enforced by types. Forgetting the rate limiter or routing a packet to the wrong tunnel are bugs that, in the post's words, would not show up under normal operation; sanstun makes the first impossible to skip by funnelling all raw datagrams through one entry point. The second idea is splitting counter and session bookkeeping from the cipher work, which opens a path to multi-threaded encryption inside a single tunnel, where flows to the same host or subnet are otherwise processed one after another. The post also shows what a team learned after more than two years of patching a fork: it concluded that keeping 100% upstream API compatibility was not worth it.

Who it affects

The post addresses anyone building on WireGuard, naming VPNs and mesh networks as examples. Rust developers who use boringtun, or the firezone/boringtun fork in particular, are directly affected, since that fork is now deprecated. Teams that care about deterministic simulation and testing may find the sans-IO treatment of time and randomness relevant, because Firezone says sans-IO time handling is important for accurately simulating an entire system.

How to use it

Sanstun is available on crates.io and on GitHub under the Apache 2.0 license; issues and pull requests are welcome. Inbound: pass each datagram to Interface::receive, then hand the resulting message to the matching Tunnel function, using the TunnelId for non-handshake messages or the initiator's static public key for handshake initiations. Outbound: call Tunnel::begin_encapsulate and then PendingEncapsulate::encrypt, either inline or on a worker thread. You must buffer packets yourself if there is no session yet, and can start a handshake with Tunnel::initiate_handshake. Drive timers with handle_timeout and poll_timeout, and read events with poll_event. Running cargo bench data_path in the repository reproduces the benchmark output shown in the post.

How solid is it

This is a first-party announcement from Firezone, and the design rationale is tied to its experience of more than 70 PRs against boringtun. The evidence given is per-function microbenchmarks in criterion-style output. The 45x figure is explicitly labelled a theoretical upper bound that Firezone itself says is hard to achieve in practice. The post gives no end-to-end throughput numbers, no comparison with boringtun or other WireGuard implementations, no hardware details for the benchmarks, no release date or version number, and no mention of a security audit. It also does not say whether Firezone's own product already runs on sanstun.

Risks and caveats

Not buffering in the library shifts work to the caller, who must hold packets until a session exists. The parallel design leaves the threading strategy entirely to the user, and the post notes that handing work to another thread has a cost it does not measure. Per-flow ordering is not addressed beyond the statement that without parallelism, flows toward the same host or subnet run sequentially. Dropping 100% API compatibility with boringtun means moving to sanstun is a different API, not a drop-in swap. Only the Firezone fork of boringtun is said to be deprecated; the post says nothing about upstream boringtun.

“Just don't buffer in the library.”

— Firezone, in the sanstun announcement post