Zostera, our new implementation of WireGuard in OCaml
2026-10-08Since June, we have received confirmation from NLnet that we are working on a WireGuard implementation in OCaml. And today we are delighted to announce that our implementation is entering its final phase, as we have reached the stage where we are currently using our implementation to communicate with WireGuard nodes. We will therefore be starting an audit of our implementation with Jason’s help. In this article, we will explain what is involved in implementing a VPN in OCaml and how one can take advantage of a typed functional language.
Not the first!
This is not the first time an attempt has been made to implement a cryptographic protocol. In addition, we have developed:
- ocaml-tls, which has been widely used for more than a decade for communication via TLS in the OCaml ecosystem (providing lwt, async, eio, miou, mirage layers);
- MirageVPN, which is a reimplementation of OpenVPN™ in OCaml that we use to route an IP subnet to our server;
- spoke, which is a draft implementation of SPAKE2+EE;
- and we can now add zostera, our new implementation of WireGuard in OCaml.
Concepts such as handshakes, ciphers and even timing attacks hold no secrets for us! The advantage is that we have virtually complete mastery of all the software building blocks relating to cryptography (we are, in particular, maintainers of mirage-crypto and digestif). If you’re satisfied with our work (either directly or indirectly), you can sponsor us in our maintenance work (which is becoming increasingly time-consuming with the arrival of security issues generated by AI which need to be evaluated).
In short, beyond simply knowing how to implement a cryptographic protocol, we also know how to maintain it over the long term.
The WireGuard protocol
We won't describe the WireGuard protocol in detail, as its white paper is very well written. The C implementation of WireGuard in the Linux kernel is also very clear once you start to recognise the concepts. There is, however, one interesting feature of the WireGuard protocol. The handshake is limited (if all goes well) to just two packets:
- a packet sent by the initiator to the responder
- a packet sent by the responder to the initiator
And the handshake takes place between the initiator and the responder! However, the responder has not yet confirmed the handshake on its end, and it is only upon receiving a packet from the initiator that the handshake can be confirmed on the responder's side. In this case, we can have a bit of fun with the types:
type identity
(** Our private key *)
type initiator = private [ `initiator ]
type responder = private [ `responder ]
type 'role role =
| Initiator : initiator role
| Responder : responder role
type pending
type confirmed
type ('role, 'state) handshake
type ('role, 'state) session
Here, we define some fairly basic types, but what's interesting is how we manipulate them:
val step0 : identity -> remote -> (initiator, pending) handshake * msg1
(** Let's send the first packet [msg1] to the responder as an initiator. *)
val step1 : identity -> msg1 -> (responder, confirmed) handshake * msg2
(** Let's send the second packet from [msg1] to the initiator. *)
val step2 :
identity
-> (initiator, pending) handshake
-> msg2
-> (initiator, confirmed) session
(** The initiator can confirms the handshake from the [msg2] received. *)
val session_of_responder :
(responder, confirmed) handshake
-> (responder, pending) session
(** Here, we create a **not confirmed** session from the responder's handshake. *)
val confirm :
(responder, pending) session
-> string
-> (responder, confirmed) session
(** When we receive something from the initiator, we can confirm our handshake
and get a confirmed session. *)
(** Reading and Writing through the WireGuard tunnel! *)
val recv : ('role, confirmed) session -> string -> string
val send : ('role, confirmed) session -> string -> string
The use of types here allows us to closely follow the execution path described
by WireGuard and to confirm our handshake only at a specific stage (for the
initiator upon receipt of the msg2 packet and for the responder upon receipt
of a WireGuard packet). At a higher level, this enables us to validate the path
using a slightly more advanced state machine that incorporates the concept of
time (and which triggers states to renegotiate sessions, for example).
This is, in particular, what I have already described in relation to spoke and bob at ICFP 2024.
Free from schedulers
As has been mentioned several times, when it comes to implementing protocols and formats, we ensure that we do not rely on any particular scheduler (such as our own, Miou). Above all, this allows us to focus on the protocol itself. However, some protocols, such as WireGuard, require the concept of time.
It is for this reason that all the operations introduced above take a ~now
value, which corresponds to a precise value from a monotonically increasing
clock.
Our state machine, introduced by the Bruit module, is described as follows:
type t
(** Our state machine. *)
val create : identity -> t
val add : t -> now:int -> address -> public -> action list
(** Here we can add a new WireGuard peer via its public key. *)
val rem : t -> public -> unit
(** We can also delete it. *)
val recv : t -> now:int -> string -> action list
(** We received something from the network, what [action] does this involve? *)
val send : t -> now:int -> public -> string -> action list
(** We want to send something to the network, what [action] should I take? *)
val tick : t -> now:int -> action list
(** Based on the time given now, what action should I take? *)
val deadline : t -> int option
(** Can you tell me how long the state remains unchanged? After that, I will
call [tick]. *)
What is interesting here is that all interactions with a potential system (such as checking the time) are delegated to the user. In this way, we can use our library with any system (such as unikernels) and scheduler (such as Miou). This approach to developing the protocol also allows us to 'simulate' situations (such as, for example, determining the actions we should take after a 25-second jump).
Time
A brief digression on time also allows me to clarify one point regarding this value. At present - and this has always been the case with Solo5 - we have two sources for obtaining the time:
- a so-called monotonically increasing clock, which has the advantage of being quick to obtain (~6 ns) but which does not recalibrate itself. It can therefore drift and no longer correspond to the true time as we know it
- a so-called 'wall' clock, which corresponds to the host system’s clock (but which can be costly to obtain). The other disadvantage is that it may jump in order to recalibrating.
The WireGuard protocol also distinguishes between the two, requiring that now
be monotonic whilst also ensuring there is another source corresponding to the
true time, capable of protecting a peer against a DDoS attack.
The usefulness of these two clocks has always been obvious to us, and allowing the user to choose between them remains fundamental; Rust, in this instance, also makes this choice explicit - and it is all the more important when it comes to cryptography.
However, this stance has been the subject of debate with regard to schedulers.
Furthermore, Miou has no concept of time, and only miou.unix (for simple
applications) or mkernel (for our unikernels) provide a way to sleep. This
ensures, in particular, that we can distinguish between Mkernel.sleep and
Mkernel.wakeup. And it is precisely this that has put us at opposite ends of
the argument with picos, which wanted to place the concept of time
(and therefore a single clock) at the very heart of the scheduler.
Tests
We have, of course, started writing unit tests (which can be found in the
wireguard-go implementation). The fact that we are
independent of schedulers also allows us to simulate scenarios (without relying
on a network or even a clock) in order to check how our state machine reacts.
You can them here and here
Next steps!
At this stage, we are also very fortunate to be able to work with Jason, the creator of WireGuard, to carry out an audit of our implementation.
We have also already taken the next step by offering (already!) a unikernel that can replace a WireGuard server. Setting it up is fairly straightforward, but the network configuration can be complex. We are currently testing the VPN to assess its memory usage and the throughput achievable when several people are connected.
This will also enable us to begin work on utcp, mnet and Solo5 to achieve better throughput performance, as we have also secured funding for these projects.
In short, you can follow these various projects and get in touch with us if you’re interested in a WireGuard VPN in the form of a unikernel!