Skip to main content
Cipher Visualizers home
Hybrid Functions Advanced

The WireGuard Protocol

WireGuard is a VPN protocol built on the Noise IKpsk2 handshake. Learn the two handshake messages, the cookie mechanism against denial of service, the replay window, and the timers, with Python code that reproduces an official test vector and the Linux kernel self-test.

Pashalis Laoutaris 37 min read

Try it

Interactive WireGuard VPN Visualizer

🔐 WireGuard

6
Enter text and click a button to start!
–

The WireGuard Protocol

Introduction

WireGuard is a VPN that sends IP packets inside UDP packets. Its design goal is simplicity. A peer is a public key. You list the public keys of the peers you trust, and you can send packets to them. There are no cipher suites to negotiate, no certificates, and no options inside the protocol. Everything that a peer needs to know about the other side is its 32-byte Curve25519 public key.

This simplicity comes from a short cryptographic core. The handshake is the Noise pattern IKpsk2 with a fixed set of algorithms. One round trip of two messages creates a session. After that, the peers exchange encrypted transport messages, and they repeat the handshake every few minutes to get new keys. This guide follows the whitepaper and the code of the Linux kernel. It rebuilds the handshake from the formulas of the whitepaper, and it checks the result against an official Noise test vector. It also ports the replay window of the kernel and runs the self-test of the kernel on it.

Table of Contents

History and Standards

WireGuard is the work of Jason Donenfeld. The whitepaper is titled “WireGuard: Next Generation Kernel Network Tunnel”, and its first page says that a version of it appears in the proceedings of NDSS 2017. The copy that I used is a draft revision dated 1 June 2020. WireGuard is not an RFC. The protocol is described by the whitepaper and by the page “Protocol and Cryptography” of the project, and the Linux kernel code is the reference implementation.

The page of Linux Kernel Newbies for Linux 5.6 says that the release came out on 29 March 2020 and that it added WireGuard. The whitepaper states that the Linux implementation takes fewer than 4,000 lines of code, not counting the cryptographic primitives. The project website lists several formal analyses, which I summarize in the Security Status section.

The Design in Brief

A WireGuard interface looks like a normal network interface. You give it a private key and a list of peers. Each peer has a public key, the IP addresses that it may use inside the tunnel, and optionally an endpoint. The project calls this idea cryptokey routing. A packet that leaves the interface is sent to the peer whose allowed addresses contain the destination. A packet that arrives is accepted only if its source address is allowed for the peer whose key decrypted it.

Two design choices shape the cryptography.

  • The protocol is silent. A WireGuard peer does not answer a packet that it cannot authenticate. A scan of the port shows nothing. The first handshake message must contain proof that the sender knows the public key of the responder.
  • No state for strangers. The responder does not allocate memory in response to an unauthenticated message. The whitepaper treats this as the main defence against denial of service.

The roles of initiator and responder are not fixed. Either peer can start a handshake whenever it needs a new session. This is why the protocol has no puzzles or other asymmetric defences.

The Algorithms

The project page lists the primitives, and the whitepaper defines them in section 5.4.

Purpose Algorithm
Key agreement Curve25519 (X25519)
Authenticated encryption ChaCha20-Poly1305, with a 12-byte nonce of four zero bytes and a 64-bit little-endian counter
Cookie encryption XChaCha20-Poly1305, with a random 24-byte nonce
Hash BLAKE2s with a 32-byte output
MAC Keyed BLAKE2s with a 16-byte output
Key derivation HKDF built on HMAC with BLAKE2s
Hash tables SipHash24, with a secret key

The whitepaper defines Kdf_n(key, input) in three steps. The value t0 is the HMAC of the input under the key. The value t1 is the HMAC of the single byte 1 under t0. Every later value ti is the HMAC under t0 of the previous value followed by the number i. The function returns the first n of these values. This is HKDF with the extract step and the expand step in one function, and it is the same function that the Noise guide uses. The keyed BLAKE2s MAC is not the HMAC. It uses the key input of BLAKE2s, which gives a short tag.

XChaCha20 is the extended-nonce variant of ChaCha20. It uses HChaCha20 to turn the key and the first 16 bytes of the nonce into a subkey. Then it runs ChaCha20 with that subkey and the last 8 bytes of the nonce. The ChaCha20-Poly1305 guide and the XChaCha20-Poly1305 guide cover these constructions. WireGuard uses XChaCha20 only for the cookie reply, where a random nonce is needed.

The Handshake

The handshake pattern is the Noise pattern IKpsk2. The letter K means that the initiator knows the static key of the responder in advance. The letter I means that the initiator sends its own static key at once, in the first message, encrypted. The psk2 modifier adds a pre-shared key at the end of the second message.

<- s
...
-> e, es, s, ss
<- e, ee, se, psk

The whitepaper writes the construction with explicit formulas. The constants are the string Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s of 37 bytes, called the Construction, and the string WireGuard v1 zx2c4 Jason@zx2c4.com of 34 bytes, called the Identifier. In Noise terms, the Identifier is the prologue.

The initiation

The initiator starts from C = Hash(Construction) and H = Hash(C || Identifier). Then it hashes the static public key of the responder into H. This is the pre-message. After that, the steps are:

  1. Generate an ephemeral key pair. Set C = Kdf1(C, E_pub) and H = Hash(H || E_pub). The public key goes into the message in clear.
  2. Mix the first Diffie-Hellman result into the chain with (C, key) = Kdf2(C, DH(E_priv, S_pub_r)). Encrypt the static public key of the initiator with static = Aead(key, 0, S_pub_i, H). Then update the hash with H = Hash(H || static).
  3. Mix the second Diffie-Hellman result into the chain with (C, key) = Kdf2(C, DH(S_priv_i, S_pub_r)). Encrypt a TAI64N timestamp in the same way with timestamp = Aead(key, 0, Timestamp(), H), and update the hash with H = Hash(H || timestamp).

The message has a type byte, three zero bytes, a 4-byte sender index chosen at random, and then the ephemeral key, the encrypted static key, and the encrypted timestamp. Two MACs follow, which the section on cookies explains. The total is 148 bytes.

The response

The responder repeats the same steps with its own private key. This tells it who the initiator is. It then builds the answer:

  1. Generate an ephemeral key pair. Set C = Kdf1(C, E_pub_r) and H = Hash(H || E_pub_r).
  2. Mix two Diffie-Hellman results into C: DH(E_priv_r, E_pub_i) and DH(E_priv_r, S_pub_i).
  3. Mix the pre-shared key with (C, tau, key) = Kdf3(C, Q), and H = Hash(H || tau). When no pre-shared key is used, Q is 32 zero bytes.
  4. Encrypt an empty payload: empty = Aead(key, 0, empty, H), and H = Hash(H || empty).

The response has 92 bytes. It is smaller than the initiation, so an attacker who sends forged initiations with a spoofed source address gains no amplification. The empty ciphertext is a 16-byte tag. It can be verified only by a party that holds the same chaining key, which proves that the responder knows its private key and the pre-shared key, if there is one.

The transport keys

Both sides compute (T_send, T_recv) = Kdf2(C, empty), with the roles reversed for the responder. The initiator’s send key is the responder’s receive key. Then the chaining key and the ephemeral keys are erased. The whitepaper says that the hash H may be kept, because it could be useful in future revisions of Noise.

After the handshake, only the initiator may send. The responder must wait for the first transport message from the initiator before it uses the new session. If the initiator has no data to send, it sends an empty message, which is called a keepalive. The first authenticated message gives the responder confirmation that the initiator holds the keys.

Message sizes

Message Type Size Content
Initiation 1 148 bytes header 8, ephemeral 32, static 48, timestamp 28, mac1 16, mac2 16
Response 2 92 bytes header 12, ephemeral 32, empty 16, mac1 16, mac2 16
Cookie reply 3 64 bytes header 8, nonce 24, encrypted cookie 32
Transport 4 32 bytes or more header 16 and the encrypted packet with a 16-byte tag

The sizes follow from the field lists of section 5.4 of the whitepaper, and the structures in the file messages.h of the kernel give the same fields. My code checks all four sizes with real messages.

Timestamps and Replayed Initiations

The first message is authenticated only by the Diffie-Hellman result of the two static keys. An attacker who records the initiation can send it again. The responder would then answer with a new response and replace its ephemeral state, which could disconnect a legitimate peer. To prevent this, the initiator encrypts a TAI64N timestamp. The responder remembers the greatest timestamp that it has accepted from each peer and drops any message whose timestamp is not greater.

A TAI64N timestamp has 12 bytes. The first 8 bytes are a big-endian count of seconds, and the last 4 bytes are a big-endian count of nanoseconds. The big-endian layout lets the code compare two timestamps with memcmp. The kernel computes the seconds as 0x400000000000000a + Unix seconds, which is 2^62 plus a constant offset of 10 seconds. The TAI64 specification defines labels of at least 2^62 as seconds after the beginning of 1970 TAI.

The kernel also rounds the nanoseconds down to a multiple of 2^24, which is 16,777,216 nanoseconds. It takes this value as the largest power of two that does not exceed the interval between two allowed initiations: the constant INITIATIONS_PER_SECOND is 50, so that interval is 20 million nanoseconds. The rounding hides the precise clock of the sender. The kernel also refuses an initiation from a known peer if the previous one was accepted less than 20 milliseconds ago.

The whitepaper points out a consequence of this design. Because WireGuard uses no signatures, the first message is authenticated only by the static-static Diffie-Hellman value. If either static key is compromised, an attacker can forge an initiation that carries the maximal timestamp. The responder accepts it, but the attacker cannot finish the handshake. From then on, every genuine initiation looks old and is refused. The whitepaper argues that this is not a real loss, because the key is compromised anyway and the peer should stop using it. My test builds such an initiation from a known static key. It decrypts correctly, and a genuine later timestamp compares lower than the forged one, so the rule of the kernel would refuse it.

The project page on limitations adds that WireGuard uses the system time as a monotonic counter. If the clock jumps forward, a user can block their own keys. If an adversary controls the clock, the adversary can save an initiation for later use.

Cookies Against Denial of Service

A Curve25519 operation is expensive. If a flood of forged initiations arrives, the responder would spend its CPU time on them. WireGuard defends against this with two MACs at the end of every handshake message.

  • mac1. The first MAC is Mac(Hash("mac1----" || S_pub_receiver), message up to mac1). The key is made from the public key of the recipient. A message without a valid mac1 is dropped in silence, and checking it costs one hash. Anyone who does not know the public key gets no answer.
  • mac2. The second MAC is Mac(cookie, message up to mac2), or 16 zero bytes if the sender has no valid cookie. The responder checks it only when it is under load.

When the responder is under load and receives a message with a valid mac1 but an invalid mac2, it does not process the message. It sends a cookie reply instead. The cookie is Mac(R, source address and port), where R is a secret value that changes every two minutes. The reply carries the cookie encrypted with XChaCha20-Poly1305. The key is Hash("cookie--" || S_pub_responder), the nonce is 24 random bytes, and the associated data is the mac1 of the message that caused the reply.

The whitepaper explains why this design improves on the cookies of DTLS and IKEv2. The cookie is encrypted, so a man in the middle who only watches cannot read it and use it. The mac1 as associated data binds the reply to one message, so an attacker who is not in the path cannot send fake cookie replies to make the initiator use a wrong cookie. The responder never replies to a message whose mac1 is invalid, so it stays silent for strangers.

The initiator that receives a valid cookie reply stores the cookie. It does not resend the message immediately. It waits for the retransmission timer, which prevents the cookie reply from being used to cause traffic. After that, it sends the initiation again with a valid mac2. In the kernel, the initiator uses a cookie only while it is younger than 115 seconds, which is the cookie lifetime of 120 seconds minus a latency margin of 5 seconds.

The kernel makes the following choices, which I read in receive.c, cookie.c, and ratelimiter.c.

  • The responder counts itself under load when at least 512 handshake messages are queued, which is one eighth of the queue limit of 4,096. It stays in that state for one second after the queue drains.
  • A message with a valid mac2 is subject to a token-bucket rate limit per source address: 20 packets per second, with a burst of 5. For IPv6, the limit applies to the whole /64 network.
  • The cookie is made from the source address and the UDP source port. The kernel hashes the port as two bytes in network order.

The project page on known limitations admits that, before mac2 is required, the Diffie-Hellman computations may use considerable CPU time. In practice, mac2 is usually enough.

Transport Data and the Replay Window

A transport message has a 16-byte header: the type 4, three zero bytes, the receiver index, and the 64-bit counter. The payload is the inner IP packet, padded with zeros to a multiple of 16 bytes, and encrypted with ChaCha20-Poly1305. The nonce is the counter. The associated data is empty. A message with an empty packet is a keepalive, and it has 32 bytes: the header and a 16-byte tag.

UDP can reorder packets, so the receiver cannot just require that counters grow. It keeps the highest counter that it has accepted, and a window of earlier counters that it has seen. The check happens only after the tag has been verified, so an attacker cannot move the window with forged packets. The whitepaper refers to RFC 2401, appendix C, and to RFC 6479, which uses a bitmap without bit shifts.

The kernel implements RFC 6479 in the function counter_validate. The bitmap has 8,192 bits, in 128 words of 64 bits. The window size is the number of bits minus 64, which is 8,128. A counter that is more than 8,128 below the highest is too old. A counter that is already marked is a replay. When a new highest counter arrives, the words that the window passes are cleared.

There is also a limit on the counter itself. The kernel refuses counters from REJECT_AFTER_MESSAGES, which it defines as U64_MAX - COUNTER_WINDOW_SIZE - 1. In decimal, the result is 18446744073709543486. The whitepaper writes the constant as 2^64 - 2^13 - 1, which is 18446744073709543423. The two values differ, because the kernel code ties the limit to the window size. The kernel, which is what runs, is the more precise source, and I use its value in my code.

Timers and Key Rotation

Rekeying is time-based and automatic. The constants appear in section 6.1 of the whitepaper, and the kernel file messages.h has the same values.

Constant Value Meaning
Rekey-After-Messages 2^60 messages Start a new handshake after this many messages
Reject-After-Messages about 2^64 Refuse more messages on this session
Rekey-After-Time 120 seconds The initiator starts a new handshake after sending
Reject-After-Time 180 seconds A session older than this is refused
Rekey-Attempt-Time 90 seconds Give up trying to start a handshake
Rekey-Timeout 5 seconds Wait before retransmitting an initiation
Keepalive-Timeout 10 seconds Send an empty message after receiving data

Some rules are worth noting.

  • Only the initiator rekeys by time. This avoids the case in which both peers start a handshake at the same moment. If the initiator receives a message when the session is Reject-After-Time - Keepalive-Timeout - Rekey-Timeout seconds old, it also starts a handshake.
  • Retries have jitter. A retransmitted initiation is delayed by an extra random time. The project page gives the range as 0 to 333 milliseconds. The kernel limits the number of timed attempts to 90 divided by 5, which is 18.
  • Three sessions. The peer keeps a current session, a previous session, and a next session. Messages from the previous session can still arrive after a new one starts. If no new session appears within three times Reject-After-Time, all of them are erased.
  • Silence is possible. If neither side has data, nothing is sent. The link is silent. A keepalive is sent only in answer to received data.

The whitepaper lists exponential backoff for the retransmission as future work. The project page repeats that note.

Interactive Visualizer

The visualizer above has three variants.

  • Handshake. Runs the full exchange. The fields are the two static private keys, the pre-shared key, the two ephemeral private keys, the time of the initiation, and a packet. The log shows each step: the hash and chaining key, the message with its MACs, the responder reading it, the response, the transport keys, one data message, its replay, and a keepalive.
  • Cookie reply. Shows a responder under load. The fields include the source address of the initiator, the secret R, and a damage option. You can damage the initiation, the cookie reply, or move the retry to another address, and watch the exchange stop at a different place.
  • Replay window. Takes a list of counters in the order they arrive, and shows which ones the receiver accepts and why it refuses the others. The default list includes a repeated counter, a jump of 8,200, and counters that are too old.

A key is 64 hexadecimal digits or a word that the page hashes with SHA-256. The defaults of the handshake variant are the keys of the official test vector, so you can compare the first bytes with the test file. The page has its own BLAKE2s with a key, HChaCha20, XChaCha20-Poly1305, ChaCha20-Poly1305, and X25519.

A Worked Example

The keys are the ones of the official vector for Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s, with the time 1700000000 seconds and 123456789 nanoseconds, which the code rounds to 7 x 2^24 nanoseconds. These are the public keys.

Key Public key
Initiator static 6bc3822a2aa7f4e6981d6538692b3cdf3e6df9eea6ed269eb41d93c22757b75a
Responder static 31e0303fd6418d2f8c0e78b91f22e8caed0fbe48656dcf4767e4834f701b8f62
Initiator ephemeral ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c7944
Responder ephemeral 95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f144808843

The chain of the initiation, step by step:

Step Chaining key C Hash H
Start 60e26daef327efc02ec335e2a025d2d016eb4206f87277f52d38d1988b78cd36 2211b361081ac566691243db458ad5322d9c6c662293e8b70ee19c65ba079ef3
Pre-message (responder static key) same a9f77c015fcfea334f85d970f2a3a236933390e7ee79dc23af7bf87ac348aeb5
Ephemeral key db23ac7e550fc8451f91cdd0f45eee14ec7ec880c758da0b986601596ca49e69 a7e87600e119b34ff8c0fd2a47c26ffd59f79aae1875f24290989a6bad6b7590
Static key, after es cd660ff2d45c8410170f185cde4c98d1df154dfa398ba86a533ac6b9221d7dff d0ed0db7a05cae461ce6b9835440634bc1f8bbe9dd196d848e7baa8eb4b7b8fb
Timestamp, after ss 6eaa7c1429137171870245587d78b1c533b57140a829eb7490f5ed9ca665af2a fec97cc9e0b57a65bc7abe2532989c9d8b2efd65f09374060364df70e23f3e8f

The timestamp bytes are 400000006553f10a07000000. The mac1 key is 49f9a27543e44e87740826853a4d1090a5e4b149ddc1096b9ff31db725d4f4ce. The value of mac1 is 0a50efa213aa44f8301f1a7ae942c397. The initiation has 148 bytes and starts with 010000004d3c2b1aca35def5ae56cec3: the type 1, three zero bytes, the sender index 1a2b3c4d in little-endian order, and the first bytes of the ephemeral key. The response has 92 bytes and starts with 0200000081706f5e4d3c2b1a95ebc60d. Its empty ciphertext is 7e1b5d3dba46fc4404925471bf44b966.

After the response, both sides hold the handshake hash:

e6dd317e16dac531de5769c934622900b6ffca20264728b25549a7354e6069df

The initiator’s send key starts with 7a74d0adaa1882492a8eddc15272b5a5a230e111d86d430a5988596f2c4238ae. A packet of 39 bytes is padded to 48 bytes, so the transport message has 80 bytes, and it begins with 0400000081706f5e0000000000000000. A keepalive has 32 bytes.

Python Implementation

The first file is BLAKE2s from RFC 7693, with an optional key and a digest length from 1 to 32 bytes. WireGuard needs both the 32-byte hash and the 16-byte keyed MAC.

# blake2s.py
# BLAKE2s of RFC 7693, written from the specification: optional key, digest length from 1 to 32 bytes.
# Teaching code. It is not constant time.
MASK = 0xFFFFFFFF
IV = [0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19]
SIGMA = [
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], [14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3],
    [11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4], [7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8],
    [9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13], [2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9],
    [12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11], [13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10],
    [6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5], [10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0],
]


def rotr(x, n):
    return ((x >> n) | (x << (32 - n))) & MASK


def compress(h, block, t, last):
    m = [int.from_bytes(block[4 * i:4 * i + 4], "little") for i in range(16)]
    v = h[:] + IV[:]
    v[12] ^= t & MASK
    v[13] ^= (t >> 32) & MASK
    if last:
        v[14] ^= MASK

    def g(a, b, c, d, x, y):
        v[a] = (v[a] + v[b] + x) & MASK
        v[d] = rotr(v[d] ^ v[a], 16)
        v[c] = (v[c] + v[d]) & MASK
        v[b] = rotr(v[b] ^ v[c], 12)
        v[a] = (v[a] + v[b] + y) & MASK
        v[d] = rotr(v[d] ^ v[a], 8)
        v[c] = (v[c] + v[d]) & MASK
        v[b] = rotr(v[b] ^ v[c], 7)

    for r in range(10):
        s = SIGMA[r]
        g(0, 4, 8, 12, m[s[0]], m[s[1]])
        g(1, 5, 9, 13, m[s[2]], m[s[3]])
        g(2, 6, 10, 14, m[s[4]], m[s[5]])
        g(3, 7, 11, 15, m[s[6]], m[s[7]])
        g(0, 5, 10, 15, m[s[8]], m[s[9]])
        g(1, 6, 11, 12, m[s[10]], m[s[11]])
        g(2, 7, 8, 13, m[s[12]], m[s[13]])
        g(3, 4, 9, 14, m[s[14]], m[s[15]])
    return [h[i] ^ v[i] ^ v[i + 8] for i in range(8)]


def blake2s(data, key=b"", outlen=32):
    assert 1 <= outlen <= 32 and len(key) <= 32
    h = IV[:]
    h[0] ^= 0x01010000 ^ (len(key) << 8) ^ outlen          # parameter block: digest length, key length, fanout 1, depth 1
    if key:
        data = key.ljust(64, b"\x00") + data               # a keyed hash starts with the key padded to one block
    if not data:
        data = b""
    blocks = [data[i:i + 64] for i in range(0, len(data), 64)] or [b""]
    for index, block in enumerate(blocks):
        last = index == len(blocks) - 1
        t = len(data) if last else 64 * (index + 1)
        h = compress(h, block.ljust(64, b"\x00"), t, last)
    return b"".join(x.to_bytes(4, "little") for x in h)[:outlen]

The second file is the protocol. It does not call the Noise engine of the previous guide. Every function follows the formulas of the whitepaper, so that the two implementations can be compared. It also holds HChaCha20, XChaCha20-Poly1305, the cookie functions, and a port of the replay window of the kernel. The ChaCha20-Poly1305 and X25519 functions come from the Noise guide.

# wireguard.py
# The WireGuard protocol as the whitepaper (Donenfeld, 2017) writes it, section 5: the two handshake messages, the cookie
# MACs and the cookie reply, the transport data message, the timestamp, and the replay window of the Linux kernel.
# It does not use the Noise engine of noise.py: every line follows the formulas of the whitepaper, so that the two can be compared.
# Teaching code. It is not constant time, and the random values (ephemeral keys, indexes, nonces) come from the caller.
import chacha_poly
from blake2s import blake2s
from dh import X25519

CONSTRUCTION = b"Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s"
IDENTIFIER = b"WireGuard v1 zx2c4 Jason@zx2c4.com"
LABEL_MAC1 = b"mac1----"
LABEL_COOKIE = b"cookie--"

REKEY_AFTER_MESSAGES = 1 << 60
COUNTER_BITS_TOTAL = 8192
BITS_PER_LONG = 64
COUNTER_WINDOW_SIZE = COUNTER_BITS_TOTAL - BITS_PER_LONG                 # 8128, from messages.h of the Linux kernel
REJECT_AFTER_MESSAGES = (1 << 64) - 1 - COUNTER_WINDOW_SIZE - 1           # U64_MAX - COUNTER_WINDOW_SIZE - 1
REKEY_AFTER_TIME = 120
REJECT_AFTER_TIME = 180
REKEY_ATTEMPT_TIME = 90
REKEY_TIMEOUT = 5
KEEPALIVE_TIMEOUT = 10
COOKIE_SECRET_MAX_AGE = 120


# ---------------------------------------------------------------- the functions of section 5.4
def hash_(data):
    return blake2s(data, b"", 32)


def mac(key, data):
    return blake2s(data, key, 16)


def hmac(key, data):
    key = key.ljust(64, b"\x00")
    inner = blake2s(bytes(b ^ 0x36 for b in key) + data)
    return blake2s(bytes(b ^ 0x5C for b in key) + inner)


def kdf(n, key, data):
    t0 = hmac(key, data)
    out, previous = [], b""
    for i in range(1, n + 1):
        previous = hmac(t0, previous + bytes([i]))
        out.append(previous)
    return tuple(out)


def dh(private, public):
    return X25519.scalar_mult(private, public)


def public_key(private):
    return X25519.public_key(private)


def aead(key, counter, plaintext, aad):
    return chacha_poly.aead_encrypt(key, bytes(4) + counter.to_bytes(8, "little"), aad, plaintext)


def aead_open(key, counter, ciphertext, aad):
    return chacha_poly.aead_decrypt(key, bytes(4) + counter.to_bytes(8, "little"), aad, ciphertext)


def hchacha20(key, nonce16):
    state = [0x61707865, 0x3320646E, 0x79622D32, 0x6B206574]
    state += [int.from_bytes(key[4 * i:4 * i + 4], "little") for i in range(8)]
    state += [int.from_bytes(nonce16[4 * i:4 * i + 4], "little") for i in range(4)]
    for _ in range(10):
        chacha_poly.quarter_round(state, 0, 4, 8, 12)
        chacha_poly.quarter_round(state, 1, 5, 9, 13)
        chacha_poly.quarter_round(state, 2, 6, 10, 14)
        chacha_poly.quarter_round(state, 3, 7, 11, 15)
        chacha_poly.quarter_round(state, 0, 5, 10, 15)
        chacha_poly.quarter_round(state, 1, 6, 11, 12)
        chacha_poly.quarter_round(state, 2, 7, 8, 13)
        chacha_poly.quarter_round(state, 3, 4, 9, 14)
    return b"".join(state[i].to_bytes(4, "little") for i in (0, 1, 2, 3, 12, 13, 14, 15))


def xaead(key, nonce24, plaintext, aad):
    subkey = hchacha20(key, nonce24[:16])
    return chacha_poly.aead_encrypt(subkey, bytes(4) + nonce24[16:], aad, plaintext)


def xaead_open(key, nonce24, ciphertext, aad):
    subkey = hchacha20(key, nonce24[:16])
    return chacha_poly.aead_decrypt(subkey, bytes(4) + nonce24[16:], aad, ciphertext)


def tai64n(seconds, nanoseconds, truncate_bits=0):
    # the seconds label of the kernel: 2^62 + 10 + Unix seconds; the low nanosecond bits can be dropped
    nanoseconds = nanoseconds >> truncate_bits << truncate_bits
    return (0x400000000000000A + seconds).to_bytes(8, "big") + nanoseconds.to_bytes(4, "big")


# ---------------------------------------------------------------- section 5.4.4: the cookie MACs
def mac1_key(static_public):
    return hash_(LABEL_MAC1 + static_public)


def add_macs(message_without_macs, receiver_static_public, cookie=None):
    # msg_alpha is every byte before mac1, msg_beta every byte before mac2
    mac1 = mac(mac1_key(receiver_static_public), message_without_macs)
    with_mac1 = message_without_macs + mac1
    mac2 = mac(cookie, with_mac1) if cookie is not None else bytes(16)
    return with_mac1 + mac2


def check_mac1(message, own_static_public):
    return mac(mac1_key(own_static_public), message[:-32]) == message[-32:-16]


def check_mac2(message, cookie):
    return mac(cookie, message[:-16]) == message[-16:]


def make_cookie(secret, source_address, source_port):
    # tau := Mac(R, A), with A the source address and the UDP source port (two bytes, big-endian, as the kernel hashes it)
    return mac(secret, source_address + source_port.to_bytes(2, "big"))


def create_cookie_reply(responder_static_public, receiver_index, nonce24, secret, source_address, source_port, mac1_of_message):
    cookie = make_cookie(secret, source_address, source_port)
    key = hash_(LABEL_COOKIE + responder_static_public)
    return b"\x03\x00\x00\x00" + receiver_index.to_bytes(4, "little") + nonce24 + xaead(key, nonce24, cookie, mac1_of_message)


def consume_cookie_reply(message, responder_static_public, mac1_sent):
    assert message[0] == 3 and len(message) == 64
    key = hash_(LABEL_COOKIE + responder_static_public)
    return xaead_open(key, message[8:32], message[32:], mac1_sent)


# ---------------------------------------------------------------- sections 5.4.2 and 5.4.3: the handshake
def initial_state(responder_static_public, prologue=IDENTIFIER):
    c = hash_(CONSTRUCTION)
    h = hash_(c + prologue)
    h = hash_(h + responder_static_public)
    return c, h


def create_initiation(static_private_i, static_public_r, ephemeral_private_i, sender_index, timestamp, prologue=IDENTIFIER):
    static_public_i, ephemeral_public_i = public_key(static_private_i), public_key(ephemeral_private_i)
    c, h = initial_state(static_public_r, prologue)
    (c,) = kdf(1, c, ephemeral_public_i)
    h = hash_(h + ephemeral_public_i)
    c, k = kdf(2, c, dh(ephemeral_private_i, static_public_r))
    encrypted_static = aead(k, 0, static_public_i, h)
    h = hash_(h + encrypted_static)
    c, k = kdf(2, c, dh(static_private_i, static_public_r))
    encrypted_timestamp = aead(k, 0, timestamp, h)
    h = hash_(h + encrypted_timestamp)
    body = b"\x01\x00\x00\x00" + sender_index.to_bytes(4, "little") + ephemeral_public_i + encrypted_static + encrypted_timestamp
    state = {"c": c, "h": h, "e_priv": ephemeral_private_i, "s_priv": static_private_i, "sender": sender_index}
    return body, state


def consume_initiation(static_private_r, body, prologue=IDENTIFIER):
    # body is the message without its two MACs; returns the responder state, the static key of the initiator and the timestamp
    static_public_r = public_key(static_private_r)
    assert body[0] == 1 and body[1:4] == bytes(3)
    sender = int.from_bytes(body[4:8], "little")
    ephemeral_public_i, encrypted_static = body[8:40], body[40:88]
    encrypted_timestamp = body[88:]
    c, h = initial_state(static_public_r, prologue)
    (c,) = kdf(1, c, ephemeral_public_i)
    h = hash_(h + ephemeral_public_i)
    c, k = kdf(2, c, dh(static_private_r, ephemeral_public_i))
    static_public_i = aead_open(k, 0, encrypted_static, h)
    h = hash_(h + encrypted_static)
    c, k = kdf(2, c, dh(static_private_r, static_public_i))
    timestamp = aead_open(k, 0, encrypted_timestamp, h)
    h = hash_(h + encrypted_timestamp)
    state = {"c": c, "h": h, "ephemeral_i": ephemeral_public_i, "static_i": static_public_i, "sender_i": sender}
    return state, static_public_i, timestamp


def create_response(state, ephemeral_private_r, sender_index, psk, payload=b""):
    # WireGuard sends an empty payload; the argument exists so that the official Noise vectors, which carry one, can be reproduced
    c, h = state["c"], state["h"]
    ephemeral_public_r = public_key(ephemeral_private_r)
    (c,) = kdf(1, c, ephemeral_public_r)
    h = hash_(h + ephemeral_public_r)
    (c,) = kdf(1, c, dh(ephemeral_private_r, state["ephemeral_i"]))
    (c,) = kdf(1, c, dh(ephemeral_private_r, state["static_i"]))
    c, tau, k = kdf(3, c, psk)
    h = hash_(h + tau)
    encrypted_nothing = aead(k, 0, payload, h)
    h = hash_(h + encrypted_nothing)
    body = b"\x02\x00\x00\x00" + sender_index.to_bytes(4, "little") + state["sender_i"].to_bytes(4, "little") + ephemeral_public_r + encrypted_nothing
    t_recv_r, t_send_r = kdf(2, c, b"")                # (T_send_i = T_recv_r, T_recv_i = T_send_r)
    return body, {"h": h, "send": t_send_r, "recv": t_recv_r, "their_index": state["sender_i"], "my_index": sender_index}


def consume_response(state, body, psk):
    assert body[0] == 2 and body[1:4] == bytes(3) and len(body) >= 60
    sender, receiver = int.from_bytes(body[4:8], "little"), int.from_bytes(body[8:12], "little")
    assert receiver == state["sender"], "this response is for another session"
    ephemeral_public_r, encrypted_nothing = body[12:44], body[44:]
    c, h = state["c"], state["h"]
    (c,) = kdf(1, c, ephemeral_public_r)
    h = hash_(h + ephemeral_public_r)
    (c,) = kdf(1, c, dh(state["e_priv"], ephemeral_public_r))
    (c,) = kdf(1, c, dh(state["s_priv"], ephemeral_public_r))
    c, tau, k = kdf(3, c, psk)
    h = hash_(h + tau)
    payload = aead_open(k, 0, encrypted_nothing, h)
    h = hash_(h + encrypted_nothing)
    t_send_i, t_recv_i = kdf(2, c, b"")
    return {"h": h, "send": t_send_i, "recv": t_recv_i, "their_index": sender, "my_index": receiver, "payload": payload}


# ---------------------------------------------------------------- section 5.4.6: transport data
def pad16(packet):
    return packet + bytes(-len(packet) % 16)


def create_data(keypair, counter, packet, pad=True):
    ciphertext = aead(keypair["send"], counter, pad16(packet) if pad else packet, b"")
    return b"\x04\x00\x00\x00" + keypair["their_index"].to_bytes(4, "little") + counter.to_bytes(8, "little") + ciphertext


def consume_data(keypair, message):
    assert message[0] == 4 and message[1:4] == bytes(3)
    assert int.from_bytes(message[4:8], "little") == keypair["my_index"], "wrong receiver index"
    counter = int.from_bytes(message[8:16], "little")
    return counter, aead_open(keypair["recv"], counter, message[16:], b"")


class ReplayWindow:
    # counter_validate of drivers/net/wireguard/receive.c, which is RFC 6479: a bitmap indexed by the counter, no bit shifts
    def __init__(self):
        self.counter = 0
        self.backtrack = [0] * (COUNTER_BITS_TOTAL // BITS_PER_LONG)

    def validate(self, their_counter):
        if self.counter >= REJECT_AFTER_MESSAGES + 1 or their_counter >= REJECT_AFTER_MESSAGES:
            return False
        their_counter += 1
        if COUNTER_WINDOW_SIZE + their_counter < self.counter:
            return False
        index = their_counter >> 6
        if their_counter > self.counter:
            index_current = self.counter >> 6
            top = min(index - index_current, COUNTER_BITS_TOTAL // BITS_PER_LONG)
            for i in range(1, top + 1):
                self.backtrack[(i + index_current) & (COUNTER_BITS_TOTAL // BITS_PER_LONG - 1)] = 0
            self.counter = their_counter
        index &= COUNTER_BITS_TOTAL // BITS_PER_LONG - 1
        bit = 1 << (their_counter & (BITS_PER_LONG - 1))
        seen = self.backtrack[index] & bit
        self.backtrack[index] |= bit
        return not seen

The test file runs every check that follows.

HChaCha20: the test vector of section 2.2.1 (subkey 82413b4227b27bfe...)
XChaCha20-Poly1305: the AEAD example of appendix A.3.1 (114 bytes) is reproduced, with its tag, and a changed bit is rejected
XChaCha20: 2 keystream and ciphertext vectors of appendix A.3.2
Official vector Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s (cacophony): both handshake messages, the handshake hash,
  and four transport messages are reproduced byte for byte by the code written from the WireGuard whitepaper
40 random handshakes: the whitepaper code and the Noise engine (checked against 1,352 official vectors) give the same bytes, hash, and keys
message sizes: initiation 148, response 92, cookie reply 64, keepalive 32 bytes; a 100-byte packet is padded to 112 and becomes 144
cookies: mac1 binds the message to the responder key, the cookie reply opens only with the mac1 it answers,
  and mac2 is valid only from the source address and port the cookie was made for
tampering: a changed byte of the initiation is rejected, and a response made with another pre-shared key is rejected
transport: padded to a multiple of 16, the keepalive is 32 bytes, both directions use their own key
replay window: all 48813 steps of the self-test in drivers/net/wireguard/selftest/counter.c, with the window of 8128 counters
replay window: 200 random reorderings of 600 packets (jumps of up to 12,000) agree with a set-and-highest model
timestamp: TAI64N orders by bytes; a forged maximal timestamp (needs the initiator's static key) makes every later genuine initiation look old
constants: Rekey-After-Messages 2^60; Reject-After-Messages is 18446744073709543486 in the kernel (2^64 - 8128 - 2), the whitepaper writes 2^64 - 2^13 - 1 = 18446744073709543423

I checked the code in layers, and I report what each layer proves.

  • BLAKE2s. My code matches all 512 known-answer tests for BLAKE2s in the repository of the BLAKE2 authors, half of them keyed, and 688 random cases against the standard library, including the 16-byte output.
  • HChaCha20 and XChaCha20-Poly1305. The code reproduces the vectors of the Internet-Draft draft-irtf-cfrg-xchacha-03: the HChaCha20 subkey, the AEAD example, and the keystream vectors. The draft has expired. It is still the only public source of vectors for this construction.
  • The handshake against an official vector. The cacophony collection of Noise test vectors contains a vector for the exact protocol name Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s. It uses the prologue John Galt and a pre-shared key. My code, with that prologue instead of the WireGuard identifier, produces both handshake messages, the handshake hash, and four transport messages byte for byte. The vector carries payloads of arbitrary length, and WireGuard fixes them as a timestamp and an empty payload. The test passes the payloads as arguments for this purpose.
  • The handshake against the Noise engine. In 40 random handshakes with and without a pre-shared key, the messages, the hash, and the transport keys of the whitepaper code equal those of the Noise engine of the previous guide. That engine reproduces 1,352 official vectors.
  • The replay window. My port of counter_validate passes all 48,813 steps of the self-test file selftest/counter.c of the kernel, and it agrees with a plain model of a set and a maximum on 200 random reorderings of 600 packets.
  • Sizes, cookies, and tampering. The four message sizes are 148, 92, 64, and 32 bytes. A changed byte of the initiation is rejected. A response made with another pre-shared key is rejected. A cookie reply opens only with the mac1 of the message it answers and under the key of the right responder. A mac2 is valid only from the address and port that the cookie was made for.

The page code has its own versions of these functions. Its BLAKE2s matches the same 512 known answers, it matches hashlib on random cases, and the unkeyed hash matches OpenSSL. Its HChaCha20 and XChaCha20-Poly1305 match the vectors of the draft. Its replay window gives the same answers as the kernel self-test. For 82 runs of the three variants, including random keys, damaged messages, and edge counters, its result equals the result of the Python code. In 48 of those runs I also checked 162 message strings that the log shows against the bytes of the Python code. I ran the page in a simulated browser.

What I could not check against an official source is the whole wire format. Neither the whitepaper nor the kernel publishes end-to-end vectors for WireGuard messages with MACs. The mac1 and mac2 rules, the cookie reply, and the replay window are checked against the text of the whitepaper and against my reading of the kernel files, plus my own consistency tests. I did not run an interoperability test against a real WireGuard peer.

Where It Stands

  • Part of the Linux kernel. WireGuard has been in the kernel since version 5.6. The project site says that it is now cross-platform, with Windows, macOS, BSD, iOS, and Android named.
  • A different design from IPsec. The IPsec and IKEv2 guide covers the protocol that WireGuard compares itself with. IKE negotiates many parameters. WireGuard fixes all of them.
  • Built on Noise. The Noise guide explains the framework, the 38 patterns, and the pre-shared key modifiers.
  • A modern alternative to TLS tunnels. The TLS guide describes the protocol behind most VPNs that run over TCP or UDP and use certificates.

Limitations

This is a teaching implementation, not a production one:

  • Not constant time. The Python code branches on secret values and uses integers of variable size for X25519.
  • No random source and no clock. The ephemeral keys, the indexes, the nonce of the cookie reply, and the time are arguments, so that the tests are repeatable.
  • No sockets and no timers. The code builds and reads messages. It does not run the state machine of the sessions, the queues, or the retransmission timers.
  • No rate limiter and no load detection. The cookie functions are there, but the choice of when to demand a cookie is not. The kernel’s real policy lives in receive.c (the queue threshold) and ratelimiter.c (the token bucket), summarized in the section on cookies.
  • No checks of the key. A low-order public key forces a Diffie-Hellman result of all zeros, a value anyone can predict, so that step adds nothing secret to the chaining key. This check is security-critical. The kernel’s noise.c aborts the handshake whenever a Diffie-Hellman result is all zeros, and my code does not check.
  • Padding without the MTU. The padding to a multiple of 16 does not take the maximum packet size into account.

Security Status

The project site lists several analyses of the protocol.

  • A symbolic verification in Tamarin by Jason Donenfeld and Kevin Milner. The site lists these properties: correctness, strong key agreement and authenticity, resistance to key-compromise impersonation, resistance to unknown key-share attacks, key secrecy, forward secrecy, session uniqueness, and identity hiding.
  • A computational proof by Benjamin Dowling and Kenneth Paterson in the eCK model. The proof covers a variant of the protocol that is morally equivalent, because the key confirmation message is part of the transport layer.
  • A mechanised proof by Benjamin Lipp in CryptoVerif, which covers the transport messages as well. The list includes message secrecy, mutual authentication, and replay resistance of the first message.
  • A symbolic ProVerif model of the Noise IK pattern in the Noise Explorer project.

The page on known limitations names the trade-offs, and I repeat the main ones.

  • No obfuscation, and no TCP. WireGuard leaves obfuscation to a higher layer, and it does not tunnel over TCP.
  • Roaming. The endpoint of a peer is updated from the source of an authenticated packet, with no extra round trip. An active attacker in the path can change the source address.
  • Identity hiding and forward secrecy. The initiator’s static key is encrypted under the static key of the responder. A later compromise of the responder key, together with a log of old handshakes, reveals who sent the handshakes. It does not reveal the data inside. The mac1 also lets a passive observer guess for which public key a message is intended, although a correct guess is not a cryptographic proof.
  • Not post-quantum by default. The pre-shared key can add a layer of post-quantum secrecy. The recommended route is to run a post-quantum handshake on top of WireGuard and insert its output into the pre-shared key slot.
  • Denial of service. The cookie mechanism works, but the Diffie-Hellman operations that happen before mac2 is demanded use CPU time.
  • The clock. The monotonic timestamp depends on a clock that an adversary must not control.

FAQ

Does WireGuard use certificates?

No, WireGuard has no certificates. A peer is identified by its public key. You exchange the keys out of band, as with SSH keys. How you distribute them is outside the protocol.

Can WireGuard negotiate algorithms?

No, the algorithms are fixed in the construction string Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s. The Identifier string contains v1, which marks the version of the protocol.

What does the pre-shared key add?

It is mixed into the chaining key in the second message. An attacker who breaks Curve25519 later still needs the pre-shared key to read recorded traffic. If you do not use it, the code uses 32 zero bytes.

Why is the server silent when I send garbage to its port?

Every handshake message needs a mac1 that depends on the public key of the recipient. A message with a wrong mac1 is dropped at the cost of one hash. A transport message that fails to authenticate is dropped as well.

Why does the initiation contain a timestamp and not a counter?

The responder keeps only the greatest timestamp per peer. The project page argues that losing this state in a restart is harmless. An old initiation can be replayed after the restart, but it cannot disrupt a session, because the clients reconnect with greater timestamps.

How often does the key change?

The initiator starts a new handshake about every 120 seconds when it sends data. A session older than 180 seconds is refused. The long-term static keys stay.

Is a replayed data packet accepted?

No, the receiver drops it. The tag is verified first, and then the counter is checked against the window. A counter that has been seen, or that is more than 8,128 below the highest, is dropped.

References

  1. Donenfeld, Jason. “WireGuard: Next Generation Kernel Network Tunnel,” draft revision dated 1 June 2020. A version appears in NDSS 2017. Available at: https://www.wireguard.com/papers/wireguard.pdf

  2. WireGuard project: “Protocol and Cryptography,” “Known Limitations,” and “Formal Verification.” Available at: https://www.wireguard.com/protocol/

  3. The WireGuard code of the Linux kernel, directory drivers/net/wireguard: https://github.com/torvalds/linux/tree/master/drivers/net/wireguard

  4. Linux 5.6 release notes at Linux Kernel Newbies: https://kernelnewbies.org/Linux_5.6

  5. Perrin, Trevor. “The Noise Protocol Framework,” revision 34. Available at: https://noiseprotocol.org/noise.html

  6. Arciszewski, Scott. “XChaCha: eXtended-nonce ChaCha and AEAD_XChaCha20_Poly1305,” draft-irtf-cfrg-xchacha-03, 2020. Available at: https://datatracker.ietf.org/doc/draft-irtf-cfrg-xchacha/

  7. RFC 7693, “The BLAKE2 Cryptographic Hash and Message Authentication Code,” by Saarinen and Aumasson, November 2015. Available at: https://www.rfc-editor.org/rfc/rfc7693

  8. RFC 6479, “IPsec Anti-Replay Algorithm without Bit Shifting,” by Zhang and Tsou, January 2012. Available at: https://www.rfc-editor.org/rfc/rfc6479

  9. The BLAKE2 known-answer tests: https://github.com/BLAKE2/BLAKE2/tree/master/testvectors

  10. The test vectors of the cacophony project: cacophony.txt

  11. Bernstein, Daniel J. “TAI64, TAI64N, and TAI64NA.” Available at: https://cr.yp.to/libtai/tai64.html

Hybrid Functions Advanced

The Noise Protocol Framework

Noise is a framework for building secure channels from Diffie-Hellman exchanges. A protocol is a short pattern of tokens and a name. Learn the three state objects, the 38 handshake patterns, pre-shared keys, and the security properties, with a Python engine that reproduces 1,352 official test vectors.

34 min read

Hybrid Functions Advanced

The SSH Protocol

SSH secures remote logins with a binary packet protocol, a curve25519-sha256 key exchange, and an Ed25519 host key. Learn the handshake and the chacha20-poly1305@openssh.com cipher, with Python code that reproduces the IETF draft's own worked example byte for byte.

29 min read

Hybrid Functions Advanced

IPsec and IKEv2

IPsec protects IP traffic with ESP, and IKEv2 negotiates the keys. Learn the SKEYSEED key derivation, pre-shared-key authentication, and AES-GCM, with Python code checked against 280 of NIST's own known-answer test vectors.

26 min read