Introductie van sanstun: sans-IO WireGuard

Vandaag kondigen we sanstun aan: een sans-IO Rust-implementatie van de WireGuard® state machine, geoptimaliseerd voor gebruiksgemak, veiligheid en prestaties door middel van een parallel-vriendelijke en idiomatische API.

Wat is sans-IO?

sans-IO is het idee dat je een systeem zo ontwerpt dat IO en neveneffecten strikt gescheiden zijn van de status en de "business logica".

De IO in sans-IO verwijst niet alleen naar bestands- of netwerk-IO. Het omvat ook zaken als tijd en willekeur (randomness). Vanwege het gebrek aan neveneffecten kunnen sans-IO-componenten zeer goed gecombineerd worden en zijn ze relatief eenvoudig te testen. Het ontwerp voorkomt bovendien problemen zoals function coloring en sluit goed aan bij de ownership-regels van Rust.

De codevoorbeelden en referenties in dit artikel zijn in Rust, maar sans-IO is absoluut niet specifiek voor Rust. De patronen en ideeën kunnen in elke taal worden toegepast.

Geschiedenis

Toen we Firezone voor het eerst bouwden, kozen we voor boringtun als onze WireGuard-implementatie. boringtun deed min of meer wat we wilden, maar na verloop van tijd merkten we dat we onze fork herhaaldelijk moesten patchen met bugfixes en API-toevoegingen.

Ons doel was om deze patches uiteindelijk naar de upstream-versie te kunnen brengen, dus we zorgden ervoor dat de bestaande API altijd bleef werken zoals hij was. We ontdekten echter dat het behouden van 100% API-compatibiliteit met de upstream-versie steeds moeilijker werd naarmate onze productvereisten evolueerden.

Een van de eerste grote wijzigingen die we in boringtun aanbrachten, was om het volledig sans-IO te maken wat betreft tijdbeheer (firezone/boringtun#5). Dit betekende dat er nieuwe API's werden toegevoegd voor alles met een _at suffix, die een Instant accepteren. sans-IO tijdbeheer is cruciaal als je een volledig systeem nauwkeurig wilt simuleren.

Een ander aspect dat we via feature-flags hebben aangepast, was interne buffering. In eerste instantie lijkt het handig dat boringtun automatisch een sessie opzet als je die nog niet (meer) hebt en een pakket probeert te verzenden. Interne buffering brengt echter verschillende problemen met zich mee:

  • Op een gegeven moment moet de buffer worden geleegd. boringtun lost dit op door te vereisen dat je decapsulate aanroept met een datagram van nul lengte, wat onintuïtief is.
  • Het veroorzaakt extra allocaties (althans, in de manier waarop het in boringtun was geïmplementeerd).
  • Buffergroottes zouden configureerbaar of tunable moeten zijn.

Al deze problemen wijzen naar dezelfde oplossing: buffer simpelweg niet in de bibliotheek. Dit vereenvoudigt zowel de interface als de implementatie aan beide zijden.

De volledige lijst met patches en fixes is vrij lang; zelfs zonder dependency-updates gaat het om meer dan 70 PR's. Sommige zijn fixes voor edge-cases die we ontdekten, anderen voegen nieuwe API's toe of maken bepaalde aspecten configureerbaar.

Na meer dan twee jaar boringtun te hebben gebruikt en gepatcht, hadden we een goed beeld van hoe de algehele API van een userspace WireGuard-implementatie eruit zou moeten zien. Dat resultaat is nu beschikbaar als sanstun.

Een betere sans-IO API

De kernbouwstenen van de sans-IO API zijn nu:

Het afhandelen van inkomende pakketten:

  • Tunnel::handlehandshakeinitiation
  • Tunnel::handlehandshakeresponse
  • Tunnel::handlecookiereply
  • Tunnel::begindecapsulate & Tunnel::commitdecapsulate

Het afhandelen van uitgaande pakketten:

  • Tunnel::begin_encapsulate

Het afhandelen van timer-updates:

  • Tunnel::handletimeout & Tunnel::polltimeout

Het afhandelen van events:

  • Tunnel::poll_event

Zonder interne buffering is er geen API-contract dat vereist dat je bepaalde API's in een loop aanroept, wat veel beter past in het algemene sans-IO-model. Om een pakket te verzenden, roep je Tunnel::beginencapsulate aan. Het is aan jou om het pakket te bufferen als je nog geen sessie hebt. Het maken van een nieuwe sessie betekent het verzenden van een handshake. Je kunt op elk gewenst moment een nieuwe handshake initiëren met Tunnel::initiatehandshake.

Het ontwerp van deze API's is zeer weloverwogen en wordt duidelijker als we kort het WireGuard-protocol recapituleren.

Het WireGuard-protocol

Het berichtformaat van WireGuard ondersteunt vier soorten berichten: handshake init, handshake response, cookie reply en encrypted data.

Elke WireGuard-sessie begint met een Diffie-Hellman (DH) sleuteluitwisseling, wat een relatief kostbare operatie is. Het ontwerp van WireGuard zorgt ervoor dat het openstellen van een eindpunt geen kans biedt voor een DoS-aanval. Iedereen die de publieke sleutel van het eindpunt kent, kan eenvoudig valse handshake-initiaties maken en het bron-IP van het inkomende UDP-pakket vervalsen. De ontvangende WireGuard-implementatie moet zijn deel van de DH-operaties uitvoeren voordat hij kan vaststellen dat de aangeleverde handshake-data vals is. Tegen die tijd zijn de CPU-bronnen echter al verbruikt.

Om dit te voorkomen, specificeert WireGuard een cookie-mechanisme dat de externe partij dwingt te bewijzen dat hij de geclaimde bron-IP bezit. Dit maakt het mogelijk om handshakes per bron-IP te limiteren (rate-limiting). Deze limieten zijn van toepassing op een volledige WireGuard-interface en niet alleen op een enkele tunnel.

Zodra een handshake is voltooid, kunnen beide peers versleutelde datapakketten uitwisselen met een symmetrische cipher. Als onderdeel van de handshake wisselen de peers ook hun lokaal gekozen sessie-indices uit. Deze indices maken demultiplexing van tunnels en meerdere sessies binnen een tunnel mogelijk.

Protocolinvarianten koppelen aan API-ontwerp

Als je in een taal als Rust werkt, gebruik je waarschijnlijk graag types. Het goed gebruiken van types betekent dat je invarianten afdwingt tijdens het compileren, zodat bepaalde bugs simpelweg niet kunnen voorkomen. Zo zou het vergeten om de rate-limiter toe te passen op een bericht erg problematisch zijn, omdat dit onder normale omstandigheden niet opvalt. Op dezelfde manier is het routeren van een inkomend pakket naar de verkeerde tunnel iets wat pas in productie aan het licht zou komen.

Een goed API-ontwerp maakt het onmogelijk om kapotte zaken te bouwen en helpt je te begrijpen hoe het protocol werkt.

Eén interface, meerdere tunnels

Een WireGuard-interface wordt geïdentificeerd door een enkele sleutel en een tunnel is een verbinding tussen twee interfaces. Elke keer dat we een pakket willen verzenden, moeten we dus specificeren via welke tunnel dit moet gebeuren, en wanneer we een pakket ontvangen, moeten we bepalen bij welke tunnel het hoort.

In sanstun worden deze concepten direct gekoppeld aan de types Interface en Tunnel. Alle inkomende pakketten gaan via Interface::receive. Dit is de enige publieke API die een raw datagram parseert. Het parseert het pakket, past rate-limits toe en geeft een ReceivedMessage terug met één enum-variant per WireGuard-berichttype, of een cookie reply om terug te sturen als de interface onder zware belasting staat.

Deze mappen vervolgens direct naar de corresponderende functies op een Tunnel: Tunnel::handlehandshakeinitiation, etc. Handshake-initiaties worden gerouteerd op basis van de statische publieke sleutel van de initiator. Alle andere berichten geven aan naar welke Tunnel ze verzonden moeten worden, geïdentificeerd door een TunnelId.

Onder de motorkap doet sanstun hier veel voor je. Het parseert de tunnel-index en extraheert de bovenste 24 bits, past rate-limits toe, genereert cookie-replies en valideert handshake-berichten. Cruciaal is dat de constructors voor types zoals HandshakeInitiation allemaal crate-private zijn. Hoewel Tunnel een publieke handlehandshakeinitiation functie exposeert, is de enige manier om deze aan te roepen via Interface::receive om zo een dergelijk type te verkrijgen. Hiermee beschermt Interface::receive je applicatie door de rate-limiter toe te passen, zonder dat je daarover na hoeft te denken.

Parallelle cryptografie

Een van de beste functies van sanstun is de parallel-vriendelijke API voor het versleutelen en ontsleutelen van pakketten. Op protocolniveau deelt WireGuard geen status tussen tunnels. Daarom is het paralleliseren van een WireGuard-implementatie over verschillende peers zeer eenvoudig.

Maar hoe zit het met het paralleliseren van werk binnen één enkele tunnel? WireGuard opereert op laag 3, en dus draagt een enkele sessie met een andere peer het verkeer van veel individuele netwerkstromen. Zelfs als we tunnels naar verschillende peers op verschillende threads afhandelen, zullen alle netwerkstromen (d.w.z. unieke 5-tuples) naar dezelfde host of subnet sequentieel worden verwerkt, effectief begrensd door de snelheid van de CPU bij ChaCha20-Poly1305, de cipher van WireGuard voor datapakketten.

Om replay-aanvallen door kwaadaardige waarnemers op het pad te voorkomen, hebben WireGuard-datapakketten een monotonisch oplopende pakketteller. Deze teller is gebonden aan één enkele sessie en zou daarom synchronisatie vereisen als we deze status vanaf meerdere threads tegelijk zouden benaderen.

Om dit te voorkomen, scheidt sanstun de administratie (bookkeeping) van de eigenlijke versleuteling en ontsleuteling. Het aanroepen van Tunnel::begin_encapsulate versleutelt in feite niets. Het controleert de huidige sessie, reserveert een pakketteller, update de timer-status en geeft je vervolgens een PendingEncapsulate:

impl PendingEncapsulate {
    pub fn encrypt<'a>(self, plaintext: &[u8], dst: &'a mut [u8]) -> Result<DataMessage<'a>, Error> {
        // ...
    }
}

PendingEncapsulate implementeert Send en kan dus worden overgedragen aan een andere thread om het zware werk uit te voeren. Het uitvoeren van cargo bench data_path in de sanstun-repository toont het effect van deze splitsing:

  • datapath/64B/beginencapsulate: ~20.8 ns
  • data_path/64B/encrypt: ~226.2 ns
  • datapath/64B/begindecapsulate: ~37.7 ns
  • data_path/64B/decrypt: ~205.8 ns
  • datapath/64B/commitdecapsulate: ~17.5 ns
  • datapath/1280B/beginencapsulate: ~20.8 ns
  • data_path/1280B/encrypt: ~933.5 ns
  • datapath/1280B/begindecapsulate: ~37.7 ns
  • data_path/1280B/decrypt: ~914.9 ns
  • datapath/1280B/commitdecapsulate: ~17.7 ns

Zoals verwacht schalen de administratieve delen (beginencapsulate, begindecapsulate en commit_decapsulate) niet mee met de pakketgrootte. Wat wel schaalt, zijn de encrypt en decrypt functies. Bij een pakketgrootte van 1280 bytes is het administratieve deel van het versleutelen van een nieuw pakket slechts 2%. Als we de feitelijke versleuteling naar een andere thread verplaatsen, wijst dit op een potentiële versnelling van meer dan 45x.

Een effectieve versnelling van 45x is in de praktijk moeilijk te behalen, omdat het de kosten van het doorgeven van PendingEncapsulate aan een andere thread negeert en ervan uitgaat dat er geen andere overhead is. Desalniettemin maakt het uitbesteden van cryptografie aan worker-threads aanzienlijke verbeteringen in doorvoer mogelijk.

Omdat sanstun een sans-IO bibliotheek is, bemoeit het zich niet met de exacte manier waarop je je cryptografie wilt paralleliseren. Als je het eenvoudig wilt houden, kun je encrypt en decrypt gewoon inline aanroepen en helemaal geen parallelisatie gebruiken. Het belangrijkste doel van het API-ontwerp van sanstun was om parallelle cryptografie mogelijk te maken, terwijl alles rondom de Interface single-threaded blijft. Hierdoor kan de interne rate-limiter werken zonder locks of atomic counters en profiteren van de borrow checker van Rust, terwijl statusinformatie van meerdere tunnels wordt gecombineerd.

Afsluiting

Met de komst van sanstun is onze fork op firezone/boringtun nu verouderd. Of je nu bouwt aan een VPN, een mesh-netwerk of iets geheel anders op basis van WireGuard, we hopen dat sanstun je leven iets gemakkelijker maakt.

Het is beschikbaar op crates.io. sanstun is open source onder de Apache 2.0-licentie. Je kunt het vinden op GitHub; issues en pull requests zijn welkom!