Browse WireGuard specs (32 of 38)

Proofs / WireGuard / Earlier, longer properties

The tag before the counter

With the kernel's AEAD decryption and counter_validate run in receive.c's order (assumed, L15), for any sequence of received data packets (authentic, corrupted, replayed or random bytes), a packet with a bad tag leaves the replay window unchanged and delivers nothing, and an authentic one gets the window model's verdict.

  • The spec calls the decryption before the replay check, in the order receive.c runs them. That receive.c wires them in this order is assumed, as its code does. The pipeline around them (sk_buff, scatterlists, NAPI, queues) cannot be extracted.
  • The property runs the linear form of the AEAD. The data path decrypts with the scatterlist form, chacha20poly1305_decrypt_sg_inplace, which is not covered.
spec
x_transport_order
length
132 lines of C
own proof
2,502 lines of Rocq
status
Proved
32 of 38
On this page

The spec

The property is stated by the C program harness/x_transport_order.c, 132 lines. Here is its main cut down to the inputs it draws, the calls into the code under test and the checks (lines 36–132; ... marks lines left out). The checks call helpers and the model in the spec and in harness/common.h.

C
int main(void)
{
	...
	nondet_bytes(key, 32);
	...
	steps = draw_steps();
	for (i = 0; i < steps; i++) {
		...
		kind = nondet_u8() % 4;
		L = draw_size();
		...
		nonce = (kind == 2 && have_auth) ? last_nonce : draw_nonce(wm.top);
		...
		le32_store(pkt + 4, nondet_u32());
			...
			nondet_bytes(pt, L);
			chacha20poly1305_encrypt(pkt + MD_HDR, pt, L, NULL, 0, nonce, key);
				...
				j = nondet_size();
					...
					pkt[MD_HDR + j] ^= (u8)(1 + nondet_u8() % 255);
			...
			nondet_bytes(pkt + MD_HDR, enc_len);
		...
		ok = chacha20poly1305_decrypt(pt_out, pkt + MD_HDR, enc_len, NULL, 0, nonce, key);
		...
		CHECK(ok == ref_ok);
			...
			CHECK(rc_same(&snap, &rc));       // the replay counter is byte-for-byte unchanged
			...
			int r = wg_counter_validate(&rc, nonce);
			CHECK(r == pred);
				...
				CHECK(bytes_eq(pt_out, ref_pt, enc_len - 16));  // delivered plaintext == reference
			...
			CHECK(rc.counter == wm.top);        // the counter advanced exactly to the model's top
...
}

The full spec at commit 3c1fc13 ↗

The property as the spec's first comment states it
Law `transport_order`: on the receive data path the replay counter is validated only AFTER the
AEAD tag verifies, and a packet with a bad tag is dropped with NO change to the replay counter and NO
plaintext consumed.

One receiving keypair (one receiving key, a fresh RFC 6479 replay counter) and a sequence of ANY length of
received MESSAGE_DATA packets: authentic ones (the real chacha20poly1305_encrypt under the key at a drawn
nonce), authentic ones with one byte of the ciphertext flipped, replays of an authentic nonce, and fully
random bytes.  The harness runs receive.c's two stages in order on each packet, composing the REAL functions
the pipeline composes (see transport.h):
  stage 1 (decrypt_packet): ok = chacha20poly1305_decrypt(...);   the counter is untouched here;
  stage 2 (rx_poll):        only if ok, r = wg_counter_validate(&receiving_counter, nonce);  deliver iff r.
Shadow model: ref_aead_decrypt (common.h) for whether the tag verifies and the plaintext; a window model
(transport.h) for the counter verdict.
CHECKs after every packet:
  - ok == (the reference tag verifies);
  - BAD TAG  => the whole replay counter is byte-for-byte unchanged (no counter advance, no bit set) and
                nothing is delivered  [the ordering invariant: no state change / no consume on a bad tag];
  - GOOD TAG => wg_counter_validate is reached, its verdict == the window model, the counter advances to the
                model's top, and delivered iff accepted; a delivered packet's plaintext == the reference.

The code it is about

chacha20poly1305_decrypt in lib/crypto/chacha20poly1305.c, lines 170–194:

C
bool chacha20poly1305_decrypt(u8 *dst, const u8 *src, const size_t src_len,
			      const u8 *ad, const size_t ad_len,
			      const u64 nonce,
			      const u8 key[at_least CHACHA20POLY1305_KEY_SIZE])
{
	struct chacha_state chacha_state;
	u32 k[CHACHA_KEY_WORDS];
	u8 iv[16]; /* QUICKPROOF P16: was __le64 iv[2], read through (u8 *)iv (VST: typed memory) */
	bool ret;

	chacha_load_key(k, key);

	/* QUICKPROOF P16: iv[0] = 0; iv[1] = cpu_to_le64(nonce) as bytes */
	put_unaligned_le64(0, iv);
	put_unaligned_le64(nonce, iv + 8);

	chacha_init(&chacha_state, k, iv);
	ret = __chacha20poly1305_decrypt(dst, src, src_len, ad, ad_len,
					 &chacha_state);

	chacha_zeroize_state(&chacha_state);
	memzero_explicit(iv, sizeof(iv));
	memzero_explicit(k, sizeof(k));
	return ret;
}

This is the code under test as the proof sees it: Linux at 551c722f with the two lib/crypto patches, and with the idiom rewrites that VST needs, each marked QUICKPROOF in the code and listed in DEVIATIONS.md.

The theorem

QuickProof generated this statement from the spec in src/contracts/x_transport_order.v:

Rocq
Theorem x_transport_order : harness_safe whole_prog.

whole_prog is the spec linked with the code under test. The 19 x_ specs are proved together, as the theorem suite_x, from one specification of every function of the code. src/contracts/laws.v states each spec on its own program and takes it from suite_x:

Rocq
Theorem x_transport_order : harness_safe WgNoise.contracts.x_transport_order.whole_prog.
Proof. exact (proj1 (proj2 (proj2 (proj2 (proj2 (proj2 (proj2 (proj2 (proj2 (proj2 (proj2 (proj2 (proj2 (proj2 (proj2 (proj2 (proj2 (proj2 WgNoise.contracts.suite.suite_x)))))))))))))))))). Qed.

qp verify checks that this statement is the one generated from the spec at the contract commit, and that its proof uses no axiom beyond the 13 that every spec here uses: standard logical axioms and VST's abstract malloc library.

The C files (units) its program links
  • drivers/net/wireguard/noise.c
  • drivers/net/wireguard/cookie.c
  • patch/counter_validate.c
  • patch/data_header.c
  • lib/crypto/blake2s.c
  • lib/crypto/chacha.c
  • lib/crypto/chacha-block-generic.c
  • lib/crypto/poly1305.c
  • lib/crypto/poly1305-donna32.c
  • lib/crypto/chacha20poly1305.c
  • lib/crypto/curve25519.c
  • lib/crypto/curve25519-fiat32.c
  • patch/wg_shim.c
  • quickproof/libc/libc.c
  • quickproof/libc/malloc_unit.c

What it leaves out

  • Like every spec of this proof: runs of more than one thread (locks and RCU are those of a single thread), termination (a run that never returns is not a counterexample), and binaries built by a compiler (the theorem is about the code as CompCert defines C for 64-bit x86).
  • The kernel around the code is a shim of plain C for one thread, and the code carries rewrites of idioms that VST cannot handle. Both are tested against the kernel's code, not proved.

Recorded for this property in DEVIATIONS.md (section L, what the properties exclude or assume):

  • L13. The specs x_prim_blake2s, x_prim_poly1305 and x_prim_aead end the run when a drawn buffer length (message, associated data, the bytes given to decrypt) exceeds PTRDIFF_MAX (2^63 - 1), and so do x_transport_order (payload length above PTRDIFF_MAX - 32) and x_transport_len (packet length above PTRDIFF_MAX - 32). The lengths are otherwise unbounded. The ae_ specs draw their lengths up to PTRDIFF_MAX (PTRDIFF_MAX - 16 for a message that gets a 16-byte tag) Why: no C implementation creates an object larger than PTRDIFF_MAX (pointer differences inside it must fit ptrdiff_t; glibc's malloc returns NULL beyond it), but CompCert's memory model and VST's malloc_spec' allow objects of up to 2^64 - 1 bytes, which would make the specs cover buffers that cannot exist: blake2s_update's DIV_ROUND_UP(inlen, 64) wraps for inlen of 2^64 - 63 or more. This is an assumption about the environment, not about the code
  • L15. The specs x_transport_order and x_transport_keepalive, and the admitted-packet half of x_transport_len, assume that receive.c runs the AEAD check and the replay counter in this order: decrypt_packet (in wg_packet_decrypt_worker) verifies the tag and marks the packet PACKET_STATE_CRYPTED or PACKET_STATE_DEAD without touching the counter; wg_packet_rx_poll calls counter_validate(&keypair->receiving_counter, nonce) only for a CRYPTED packet and delivers only an accepted one (wg_packet_consume_data_done). The specs compose the real functions of the two stages (the kernel's chacha20poly1305_decrypt, the contiguous-buffer form of what decrypt_packet runs through chacha20poly1305_decrypt_sg_inplace, and wg_counter_validate of S19) in that order and check the invariants: a bad tag leaves the replay counter unchanged byte for byte and delivers nothing; a good tag reaches the counter, whose verdict and new state agree with the window model; a keepalive delivers 0 bytes. The wiring itself is assumed: decrypt_packet, wg_packet_rx_poll and wg_packet_consume_data_done use sk_buff, scatterlists, skb_cow_data, NAPI, RCU and the per-peer queues, which are not modelled; likewise how the type word and the length reach validate_header_len (S24) Why: the pipeline cannot be extracted to Clight

The summary page lists what the proof as a whole does not cover.

How it was proved

src/proof/harness/transport_order/ holds 2,502 lines of Rocq (formerly Coq) that are specific to this spec. It builds on the specifications and proofs of the functions of noise.c, cookie.c and lib/crypto, the protocol model, and the Diffie-Hellman agreement of X25519. The whole proof of the 38 specs is about 300,000 lines. AI agents wrote it, Rocq checks all of it, and nobody has to read it. What people have to read is the spec above, with harness/common.h, and the kernel shim that DEVIATIONS.md describes.

Other specs about receive.c: see the list on the left, or the summary.