Much more information may be found in the technical whitepaper. For just a quick & dirty overview, read onward here.

A formal verification of below was produced with Tamarin.

As WireGuard is still under development, this protocol document represents a draft, which means it's a work in progress and subject to change. If you're planning on implementing WireGuard, at this stage, it's best to work in tandem with the WireGuard developers to ensure that all implementations converge on the same final protocol.

Primitives

The following protocols and primitives are used:

Connection-less Protocol

Any secure protocol require some state to be kept, so there is an initial very simple handshake that establishes symmetric keys to be used for data transfer. This handshake occurs every few minutes, in order to provide rotating keys for perfect forward secrecy. It is done based on time, and not based on the contents of prior packets, because it is designed to deal gracefully with packet loss. There is a clever pulse mechanism to ensure that the latest keys and handshakes are up to date, renegotiating when needed, by automatically detecting when handshakes are out of date. It uses a separate packet queue per host, so that it can minimize packet loss during handshakes while providing steady performance for all clients.

In other words, you bring the device up, and everything else is handled for you automatically. You don't need to worry about asking it to reconnect or disconnect or reinitialize, or anything of that nature.

The following timers are at play:

Future work involves adjusting REKEY_TIMEOUT to use exponential back-off.

After a handshake is completed, with a message from initiator to responder and then responder back to initiator, the initiator may then send encrypted session packets, but the responder cannot. The responder must wait to use the new session until it has recieved one encrypted session packet from the initiator, in order to provide key confirmation. Thus, until the responder receives that first packet using the newly established session, it must either queue up packets to be sent later, or use the previous session, if one exists and is valid. Therefore, after the initiator receives the response from the responder, if it has no data packets immediately queued up to send, it should send en empty packet, so as to provide this confirmation.

Key Exchange and Data Packets

WireGuard uses the Noise_IK handshake from Noise, building on the work of CurveCP, NaCL, KEA+, SIGMA, FHMQV, and HOMQV. All packets are sent over UDP.

The key exchange has these nice properties:

If an additional layer of symmetric-key crypto is required (for, say, post-quantum resistance), WireGuard also supports an optional pre-shared key that is mixed into the public key cryptography. When pre-shared key mode is not in use, the pre-shared key value used below is assumed to be an all-zero string of 32-bytes.

For the following packet descriptions, refer to these functions:

First Message: Initiator to Responder

The initiator sends this message:

msg = handshake_initiation {
    u8 message_type
    u8 reserved_zero[3]
    u32 sender_index
    u8 unencrypted_ephemeral[32]
    u8 encrypted_static[AEAD_LEN(32)]
    u8 encrypted_timestamp[AEAD_LEN(12)]
    u8 mac1[16]
    u8 mac2[16]
}

The fields are populated as follows:

initiator.chaining_key = HASH(CONSTRUCTION)
initiator.hash = HASH(HASH(initiator.chaining_key || IDENTIFIER) || responder.static_public)
initiator.ephemeral_private = DH_GENERATE()
msg.message_type = 1
msg.reserved_zero = { 0, 0, 0 }
msg.sender_index = little_endian(initiator.sender_index)

msg.unencrypted_ephemeral = DH_PUBKEY(initiator.ephemeral_private)
initiator.hash = HASH(initiator.hash || msg.unencrypted_ephemeral)

temp = HMAC(initiator.chaining_key, msg.unencrypted_ephemeral)
initiator.chaining_key = HMAC(temp, 0x1)

temp = HMAC(initiator.chaining_key, DH(initiator.ephemeral_private, responder.static_public))
initiator.chaining_key = HMAC(temp, 0x1)
key = HMAC(temp, initiator.chaining_key || 0x2)

msg.encrypted_static = AEAD(key, 0, initiator.static_public, initiator.hash)
initiator.hash = HASH(initiator.hash || msg.encrypted_static)

temp = HMAC(initiator.chaining_key, DH(initiator.static_private, responder.static_public))
initiator.chaining_key = HMAC(temp, 0x1)
key = HMAC(temp, initiator.chaining_key || 0x2)

msg.encrypted_timestamp = AEAD(key, 0, TAI64N(), initiator.hash)
initiator.hash = HASH(initiator.hash || msg.encrypted_timestamp)

msg.mac1 = MAC(HASH(LABEL_MAC1 || responder.static_public), msg[0:offsetof(msg.mac1)])
if (initiator.last_received_cookie is empty or expired)
    msg.mac2 = [zeros]
else
    msg.mac2 = MAC(initiator.last_received_cookie, msg[0:offsetof(msg.mac2)])

When the responder receives this message, he decrypts and does all the above operations in reverse, so that the state is identical.

Second Message: Responder to Initiator

The responder sends this message, after processing the first message above and applying the same operations to arrive at an identical state:

msg = handshake_response {
    u8 message_type
    u8 reserved_zero[3]
    u32 sender_index
    u32 receiver_index
    u8 unencrypted_ephemeral[32]
    u8 encrypted_nothing[AEAD_LEN(0)]
    u8 mac1[16]
    u8 mac2[16]
}

The fields are populated as follows:

responder.ephemeral_private = DH_GENERATE()
msg.message_type = 2
msg.reserved_zero = { 0, 0, 0 }
msg.sender_index = little_endian(responder.sender_index)
msg.receiver_index = little_endian(initiator.sender_index)

msg.unencrypted_ephemeral = DH_PUBKEY(responder.ephemeral_private)
responder.hash = HASH(responder.hash || msg.unencrypted_ephemeral)

temp = HMAC(responder.chaining_key, msg.unencrypted_ephemeral)
responder.chaining_key = HMAC(temp, 0x1)

temp = HMAC(responder.chaining_key, DH(responder.ephemeral_private, initiator.ephemeral_public))
responder.chaining_key = HMAC(temp, 0x1)

temp = HMAC(responder.chaining_key, DH(responder.ephemeral_private, initiator.static_public))
responder.chaining_key = HMAC(temp, 0x1)

temp = HMAC(responder.chaining_key, preshared_key)
responder.chaining_key = HMAC(temp, 0x1)
temp2 = HMAC(temp, responder.chaining_key || 0x2)
key = HMAC(temp, temp2 || 0x3)
responder.hash = HASH(responder.hash || temp2)

msg.encrypted_nothing = AEAD(key, 0, [empty], responder.hash)
responder.hash = HASH(responder.hash || msg.encrypted_nothing)

msg.mac1 = MAC(HASH(LABEL_MAC1 || initiator.static_public), msg[0:offsetof(msg.mac1)])
if (responder.last_received_cookie is empty or expired)
    msg.mac2 = [zeros]
else
    msg.mac2 = MAC(responder.last_received_cookie, msg[0:offsetof(msg.mac2)])

When the initiator receives this message, he decrypts and does all the above operations in reverse, so that the state is identical.

Data Keys Derivation

After the above two messages have been exchanged, keys are calculated by the initiator and responder for sending and receiving data:

temp1 = HMAC(initiator.chaining_key, [empty])
temp2 = HMAC(temp1, 0x1)
temp3 = HMAC(temp1, temp2 || 0x2)
initiator.sending_key = temp2
initiator.receiving_key = temp3
initiator.sending_key_counter = 0
initiator.receiving_key_counter = 0

temp1 = HMAC(responder.chaining_key, [empty])
temp2 = HMAC(temp1, 0x1)
temp3 = HMAC(temp1, temp2 || 0x2)
responder.receiving_key = temp2
responder.sending_key = temp3
responder.receiving_key_counter = 0
responder.sending_key_counter = 0

And then all previous chaining keys, ephemeral keys, and hashes are zeroed out.

Subsequent Messages: Exchange of Data Packets

The initiator and the responder exchange this packet for sharing encapsulated packet data:

msg = packet_data {
    u8 message_type
    u8 reserved_zero[3]
    u32 receiver_index
    u64 counter
    u8 encrypted_encapsulated_packet[]
}

The fields are populated as follows:

msg.message_type = 4
msg.reserved_zero = { 0, 0, 0 }
msg.receiver_index = little_endian(responder.sender_index)
encapsulated_packet = encapsulated_packet || zero padding in order to make the length a multiple of 16
counter = initiator.sending_key_counter++
msg.counter = little_endian(counter)
msg.encrypted_encapsulated_packet = AEAD(initiator.sending_key, counter, encapsulated_packet, [empty])

The responder uses his responder.receiving_key to read the message.

DoS Mitigation

We require authentication in the first handshake message sent because it does not require allocating any state on the server for potentially unauthentic messages. In fact, the server does not even respond at all to an unauthorized client; it is silent and invisible. The handshake avoids a denial of service vulnerability created by allowing any state to be created in response to packets that have not yet been authenticated.

This, however, introduces the issue of having authentication in the first packet: it is always open to a replay attack. An attacker could replay initial handshake messages to trick the server into regenerating its ephemeral key, thereby disconnecting the legitimate client connection (though not affecting the security of any messages). For that reason, we include a TAI64N timestamp in the first message. The server keeps track of the greatest timestamp received per client and discards packets containing timestamps less than or equal to it. If the server restarts and looses this state, that is not a problem: an initial packet from earlier can be replayed, but it could not possibly disrupt any ongoing sessions, since the server has just restarted. Once clients reconnect to the server after its restart, they will be using greater timestamps, invalidating the previous ones. This timestamp ensures that an attacker can't disrupt a current session between client and server.

Furthermore, computing the DH() function is CPU intensive. In order to fend off a CPU-exhaustion attack, if the server is under load, it may choose to not process handshake messages, but instead respond with a cookie reply packet. In order for the server to remain silent unless it receives a valid packet, while under load, all messages are required to have a MAC that combines the receiver's public key and optionally the PSK as the MAC key. When the server is under load, it will only accept packets that additionally have a second MAC of the prior bytes of the message that utilize the cookie as the MAC key. We therefore compute msg.mac1 and msg.mac2 as seen in the handshake messages above. Cookies expire after two minutes and are a MAC of the sender's IP address using a changing (every two minutes) server secret as the MAC key. This allows for proof of IP ownership, which can then be rate limited properly. The server, after computing these MACs as well and comparing them to the ones received in the message, must reject messages with an invalid msg.mac1 and when under load must reject messages with an invalid msg.mac2.

As mentioned above, when a message with a valid msg.mac1 is received, but msg.mac2 is all zeros or invalid and the server is under load, the server may send a cookie reply packet as follows:

msg = packet_cookie_reply {
    u8 message_type
    u8 reserved_zero[3]
    u32 receiver_index
    u8 nonce[24]
    u8 encrypted_cookie[AEAD_LEN(16)]
}

msg.message_type = 3
msg.reserved_zero = { 0, 0, 0 }
msg.receiver_index = little_endian(initiator.sender_index)
msg.nonce = RAND(24)
cookie = MAC(responder.changing_secret_every_two_minutes, initiator.ip_address)
msg.encrypted_cookie = XAEAD(HASH(LABEL_COOKIE || responder.static_public), msg.nonce, cookie, last_received_msg.mac1)

Nonce Reuse & Replay Attacks

Nonces are never reused. A 64bit counter is used, and cannot be wound backward. UDP, however, sometimes delivers messages out of order. For that reason we use a sliding window, in which we keep track of the greatest counter received and a window of roughly 2000 prior values. This avoids replay attacks while ensuring nonces are never reused and that UDP can maintain out-of-order delivery performance.