QUIC for Zig: RFC 9000, 9001 and 9002. Early — the sans-I/O TLS seam and packet protection so far.
  • Zig 95.1%
  • Nix 3.2%
  • Python 1.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 4cb753b81c
All checks were successful
test / test (push) Successful in 8m48s
test / docs (push) Successful in 5m37s
test / interop (push) Successful in 15m11s
Take the tls.zig whose PKCS#8 keys reach the CRT components
tls.zig 9a669ab. The commit before it taught the RSA code to use the second
representation and the handshake did not move, because PrivateKey.parseDer
walks PKCS#8 by hand and had never read the five integers it needs. Measured
through zig-http against curl, the handshake for a server certificate:

            before CRT   after
  RSA-2048       15 ms    6 ms
  RSA-3072       44 ms   15 ms

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141M41NZ9P4WQsMW41VYZGs
2026-09-20 01:48:16 -05:00
.forgejo/workflows Publish the documentation, and say what the state actually is 2026-09-18 10:46:19 -05:00
LICENSES The seam onto TLS, and the packet protection that hangs off it 2026-09-16 22:41:42 -05:00
src Buy a connection's memory when a connection arrives 2026-09-19 17:45:26 -05:00
tests Buy a connection's memory when a connection arrives 2026-09-19 17:45:26 -05:00
tools The seam onto TLS, and the packet protection that hangs off it 2026-09-16 22:41:42 -05:00
.gitignore The seam onto TLS, and the packet protection that hangs off it 2026-09-16 22:41:42 -05:00
build.zig Rewrite doqd on quic_io, which is what proves the extraction 2026-09-18 14:22:47 -05:00
build.zig.zon Take the tls.zig whose PKCS#8 keys reach the CRT components 2026-09-20 01:48:16 -05:00
build.zig.zon.nix Take the tls.zig whose PKCS#8 keys reach the CRT components 2026-09-20 01:48:16 -05:00
flake.lock Put TLS behind the seam, and run a handshake inside real packets 2026-09-17 01:24:58 -05:00
flake.nix Against somebody else's implementation, over a real socket 2026-09-17 21:59:36 -05:00
package.nix Against somebody else's implementation, over a real socket 2026-09-17 21:59:36 -05:00
README.md Buy a connection's memory when a connection arrives 2026-09-19 17:45:26 -05:00
REUSE.toml Put TLS behind the seam, and run a handshake inside real packets 2026-09-17 01:24:58 -05:00

zig-quic

QUIC — RFC 9000, RFC 9001 and RFC 9002 — for Zig, with the unreliable datagram extension of RFC 9221.

The API documentation is generated from the doc comments, which is where most of the explanation in this project lives: nearly every rule is quoted from the RFC beside the code that obeys it, and the reasoning for the ones that were not obvious is written down next to them.

Status

Complete, for the transport. There is nothing left in RFC 9000 or RFC 9001 that an endpoint needs, and the pieces below say what each of them cost. It has not been deployed anywhere, and the interoperability testing is against one other implementation rather than the half-dozen a mature stack is checked against — so "complete" means the specification is implemented, not that the specification is all there is to getting it right.

It started much smaller, and the shape of the list below is the order things were built in. A QUIC Initial packet arriving as an unknown datagram is parsed, has its header protection removed, its packet number reconstructed, and its payload decrypted. Both of RFC 9001 Appendix A's sample packets go through that path and come out octet for octet, in both directions.

That works with no TLS at all, which is not a shortcut — Initial keys come from the client's first connection ID (§5.2), so the whole receive path can be built and tested before anything sits behind the TLS seam. tls.failing stands in.

The payload is then parsed into frames, so RFC 9001's Appendix A reads all the way down: the client's Initial comes out as a CRYPTO frame carrying a TLS ClientHello and the 917 PADDING frames after it, and the server's as an ACK and a ServerHello.

And it goes the other way. Both of those packets are rebuilt from their frames and come out octet for octet identical to what the RFC prints, which is a stronger claim than a round trip: a reader and a writer that were wrong in the same way would agree with each other and disagree with this.

Two pieces of connection bookkeeping are in as well — acknowledgement tracking and stream reassembly — which between them are most of what a receiver has to remember.

Two connections complete a handshake by exchanging datagrams. Connection holds the three packet number spaces, each with its own keys, acknowledgement tracker and CRYPTO stream, and a TLS handshake behind the seam being fed by all three. Datagrams in one end and out the other; nothing above it knows about encryption levels or key derivation. It takes four datagrams, which is what coalescing packets of different encryption levels into one buys.

And it survives losing one. Loss recovery — RFC 9002 §5 and §6.1 — reads the acknowledgements that used to be parsed and discarded, estimates the round trip time from them, works out which packets are gone, and sends their contents again. The same handshake run four times, dropping a different datagram each time, still completes.

Being able to put a lost piece back is also what makes it safe to send a message in pieces, so a handshake flight larger than one datagram now works: a CRYPTO frame carries an offset and a length and §19.6 says nothing about message boundaries, so a ClientHello too big for a packet goes in two.

And it backs off when the path is loaded. §7's NewReno controller is here: slow start, congestion avoidance, recovery periods, persistent congestion and the peer's ECN-CE counter. A sender is held to its window, a probe is not, and an acknowledgement is never held to it at all.

And the two ends now agree on the terms. Transport parameters (§18) are encoded and parsed rather than carried as an opaque blob, so the limits the handshake settles are real: what a peer will buffer, how many streams it will accept, how long it may sit on an acknowledgement. With them come §7.3's connection ID checks, which is what those parameters exist for.

And a connection carries them. A client opens a bidirectional stream, writes a request and ends its half; the server is told about a stream it did not open, reads what arrived and answers on the same one. Flow control works in both directions, a sender stops at the credit it was given and resumes when the reader releases more, and a lost STREAM frame comes back — by acknowledgement when a later packet arrives, and by probe timeout when none does. That is the shape of DNS over QUIC (RFC 9250).

And it ends properly. A protocol violation now reaches the peer as a CONNECTION_CLOSE with §20.1's code for it, the closing and draining states work as §10.2 describes, and a connection nobody speaks on times out silently.

And it changes its keys. RFC 9001 §6's key update works in both directions, with the AEAD usage limits of §6.6 that make it mandatory rather than optional.

And a connection outlives its stream limit. Closed streams give their slots back and MAX_STREAMS grants the peer more, which is what lets one connection carry many queries — twenty on a single connection in each direction against aioquic.

And it works against somebody else's implementation, both ways round. A DNS-over-QUIC client and server ship with the library, and a NixOS test runs each against aioquic's other half over a real socket, with a certificate generated in the guest so the verification path is the real one.

And a connection has more than one name. Both ends issue connection IDs (§5.1), retire them, and replace what the other retires — so a connection can be addressed by a name that only existed after the handshake, which is what §9's migration will need and what §9.5's unlinkability rests on.

And a peer that moves is followed, and made to prove it. Path validation (§8.2) and migration (§9): a non-probing packet from a new address moves the connection there, a challenge of eight unpredictable octets goes out on the new path, and until the echo comes back the new address gets no more than three times what it sent.

And a server can refuse to keep state for an address it has not checked. Retry and address validation (§8.1): a token, a round trip, and no state committed until the token comes back from the same address.

And an endpoint that has forgotten a connection can say so. Stateless reset (§10.3): a datagram indistinguishable from ordinary traffic, whose last sixteen octets are a value only the two endpoints can compute.

And it finds out how large a datagram the path will carry. DPLPMTUD (§14.3): probes of PING and PADDING, a binary search between what is known to work and what is known not to, and a ceiling of 1200 octets lifted to whatever the path actually supports.

And a second connection need not start over. Session resumption (RFC 8446 §4.6.1): a server hands out a ticket that is the resumption secret sealed under a key only it holds, so it remembers nothing; a client offers that ticket back with the binder of §4.2.11.2, and the second handshake carries no certificate and no signature. Both halves are proved against aioquic — our binder computation accepted by its server, its binder accepted by ours.

And a request can arrive before the handshake does. 0-RTT (RFC 9001 §4.6): a resuming client writes its request into the same datagram as its ClientHello, protected by a key derived from the pre-shared key alone, and the server answers it without having said a word. Both halves are proved against aioquic, in both directions. A server may refuse, and then §4.6.3's other half runs: the data is put back and sent again under 1-RTT keys, and the application cannot tell.

And a stream can run one way. Unidirectional streams (RFC 9000 §2.1 and §3.1), with their own identifier space and their own limit, so that opening one costs nothing from the bidirectional allowance and neither kind can starve the other. DNS over QUIC never uses one; HTTP/3 cannot start without three.

And it carries what it will not repeat. Unreliable datagrams (RFC 9221), for applications that want QUIC's handshake, congestion control and path validation without its retransmissions — real-time media, and the tunnelling of somebody else's unreliable protocol.

And a server can ask to be talked to somewhere else. The preferred address (RFC 9000 §9.6): a server names an address at the end of the handshake, hands over the connection ID to use once the client gets there, and the client validates the new path before it trusts it.

What is left: nothing in RFC 9000 or RFC 9001. What is here is a complete QUIC transport with TLS, plus RFC 9221's datagram extension.

Where this lives

The repository lives in three places that carry the same history. The Forgejo instance at https://git.jcollie.dev/jeff/zig-quic is the web-visible one:

$ git clone https://git.jcollie.dev/jeff/zig-quic.git

it is mirrored on Tangled at https://tangled.org/jcollie.dev/zig-quic, and it is also on the Radicle network, where the repository's identifier is

rad:zuEbDUHm86sgB9SfddbBdzwj54ra

and rad clone rad:zuEbDUHm86sgB9SfddbBdzwj54ra fetches it from any node that seeds it. Any of the three is the whole project.

Why this is not part of zig-http

Because DNS over QUIC (RFC 9250) has no HTTP in it anywhere: a QUIC connection, a two-octet length prefix, and an ALPN of doq. A QUIC implementation living inside an HTTP server library would have to be depended on by a DNS client in order to send a DNS query, which is the reductio.

The direction is the opposite of zig-websocket's, and the asymmetry is the whole argument. WebSocket needs HTTP — its opening handshake is literally an HTTP request — so that library depends on zig-http and the arrow points one way. QUIC needs nothing from HTTP; HTTP/3 needs QUIC. Same principle, other direction.

HTTP/3 itself (RFC 9114) and QPACK (RFC 9204) will go in zig-http rather than here, because they are HTTP semantics and because QPACK uses the same Huffman code as HPACK — RFC 9204 §5 defers to RFC 7541 for it outright, so it belongs beside the implementation that is already there.

What a connection does

var connection: quic.Connection = undefined;
connection.init(.{
    .role = .client,
    .handshake = tls_client.handshake(),
    .local_id = &my_connection_id,
    .destination_id = &chosen_id,
});

try connection.receive(datagram);              // in
if (try connection.send(&buffer)) |wire| { }   // out

Three packet number spaces, because §12.3 keeps them separate in every way that matters: each numbers from zero, and a packet in one can only be acknowledged in a packet of the same kind. That is cryptographic separation rather than bookkeeping convenience — the keys differ, so a number reused across two spaces is protected by two different keys and means two different things.

A packet that cannot be read is dropped rather than answered. §5.2 of RFC 9000 forbids responding to a packet that could not be processed, because anything sent to an unvalidated address is an amplification vector, and §5.5 of RFC 9001 says a packet failing authentication is simply discarded — anyone can send rubbish to a UDP port.

The seam

tls.Handshake is the whole of what RFC 9001 §4.1 asks of a TLS implementation, written down so that whatever is behind it can be replaced without anything above noticing:

pub const VTable = struct {
    provide: *const fn (ctx, level: Level, data: []const u8) Error!void,
    emit: *const fn (ctx, level: Level) Error!?[]const u8,
    readSecret: *const fn (ctx, level: Level) ?Secret,
    writeSecret: *const fn (ctx, level: Level) ?Secret,
    peerTransportParameters: *const fn (ctx) ?[]const u8,
    isComplete: *const fn (ctx) bool,
    alert: *const fn (ctx) ?u8,
};

Nothing there reads or writes anything, and that is the point. QUIC owns the loop: it decides when to read, what goes in a packet, when to retransmit a CRYPTO frame that was lost, and which encryption level to send at. §4.1.4 is explicit that "the availability of new keys is always a result of providing inputs to TLS", so a caller polls after every provide rather than on a schedule of its own.

tls.failing implements it by refusing everything, the way std.Io.failing does. That is more useful than it sounds: Initial packets need no TLS at all, so a great deal of an endpoint can be exercised with nothing behind the seam, and anything that reaches for TLS anyway fails loudly.

What is implemented

RFC 9000 §16 — the variable-length integer. Worth one note: a non-minimal encoding is legal here (§16 says so explicitly), which is the opposite of the rule this project's siblings enforce for HTTP/2 frame lengths and WebSocket payload lengths. QUIC can afford it because every field it matters for is inside an AEAD; an attacker who can rewrite a length has already broken packet protection.

RFC 9000 §17 — long and short headers, every packet kind, packet number encoding and the Appendix A.3 decoding algorithm. Reading a packet is two passes and the protocol forces it: the bits saying how long the packet number is are themselves protected, so a receiver cannot know where the number ends until it has removed header protection, and cannot remove header protection until it knows where the number begins. Header.parse is the first pass and open is all of it.

A short header's connection ID has no length field, so parse has to be told how long the local connection IDs are. That is deliberate in the protocol — it is what stops a middlebox following a connection — and there is no way to recover it from the octets.

RFC 9000 §19 — all twenty frame types, with §12.4's framing rules. Two of those rules are worth knowing because they run against the grain of the sibling libraries:

  • A frame type must use the shortest varint encoding. That is §12.4's sole exception to §16's general tolerance, and it exists so that frame parsing can be cheap rather than for any security reason.
  • An unknown frame type is a connection error, where HTTP/2 requires an unknown frame to be discarded. The difference is structural: an HTTP/2 frame carries its own length so a reader can skip one it does not understand, while a QUIC frame's length is implied by its type — so an unknown type means the rest of the packet cannot be located at all.

A packet payload is a sequence of frames with no length, no count and no terminator, which is why those rules matter: a parser that mistakes one frame's extent does not find out, it reads the next frame's octets as part of this one.

ACK ranges are not decoded until asked for, and their arithmetic is subtractive — §19.3.1 makes a range that would describe a negative packet number a connection error, and every subtraction is checked. PADDING is counted rather than repeated: the Appendix A client Initial has 917 of them in a row, and yielding those one at a time would be faithful and useless.

Building packets, which is the same steps in the one order that works. A long header's Length field covers the packet number and the payload, neither of which is known when the header is written, so it is reserved at a fixed width and filled in at the end — which §16 makes legal by permitting non-minimal varints, and which is the one thing that tolerance is really for. Then the payload is encrypted with the header as associated data, and only then is header protection applied, because §5.4.1 samples the mask out of the ciphertext. Getting that order wrong in either direction produces a packet that is well-formed and unreadable.

Builder also enforces §5.4.2's floor: a packet needs at least three octets of frames behind a one-octet packet number, or there is nothing for header protection to sample and no receiver could unprotect it. That is a distinct error from running out of buffer, because the fix is different — pad the packet, not enlarge the buffer.

Acknowledgement tracking (ack.Tracker), one per packet number space because §12.3 keeps the three entirely separate — each numbers from zero, and acknowledging a packet in the wrong space is a statement about a different packet. Received numbers are kept as disjoint ranges, which is also the shape §19.3 wants, and the list is fixed at 32. That bound is the point: gaps are what make the list grow, a peer can manufacture gaps by sending every other packet number, and an endpoint that kept all of them would have its memory decided by whoever is talking to it. Dropping the oldest range costs one retransmission; not dropping it costs the connection.

Stream reassembly (reassembly.Reassembler), which is what will feed tls.Handshake.provide. CRYPTO frames carry an offset and no promise about order or overlap; TLS needs a contiguous run starting where the last one ended. All three obvious implementations are wrong, and each in a way a peer can exploit: growing a buffer to fit any offset, keeping a list of fragments, or trusting that fragments do not overlap. What is here is a fixed window with a bitmap, where data beyond it is refused — §19.6 calls that CRYPTO_BUFFER_EXCEEDED — and the first arrival of an octet wins, because the first is the one that may already have been handed to TLS.

RFC 9001 §5, which is everything about keys given a secret from somewhere:

  • §5.1 the quic key, quic iv and quic hp derivation, and the HKDF-Expand-Label framing underneath it
  • §5.2 Initial secrets, from the client's first Destination Connection ID and a fixed salt — no TLS involved, which is why either endpoint can read the other's first packet before agreeing anything, and equally why an Initial packet has no confidentiality worth the name
  • §5.3 packet protection, with the header as associated data and the packet number exclusive-ored into the IV to form the nonce
  • §5.4 header protection, both AES-ECB and ChaCha20, for all three cipher suites

The order is the part that is easy to get backwards, and §5.4.1 settles it: protect the payload first, then the header, because the header protection mask is sampled from the encrypted payload.

Loss recovery (recovery.zig), RFC 9002, which is the document that turns a handshake that works into one that works over a real network.

QUIC can do this better than TCP for one reason: a packet number is never reused, so an acknowledgement names exactly one transmission and the retransmission ambiguity TCP lives with does not exist. Every RTT sample here is therefore unambiguous, which is what makes the timers built from them worth having.

  • §5 the estimator: min_rtt, smoothed_rtt, rttvar and the latest sample, each load-bearing in a different way. The floor at min_rtt is a defence rather than an optimisation — without it a peer claiming to have sat on an acknowledgement for most of the round trip drives the estimate, and every timer built from it, towards zero.
  • §6.1 loss detection, by packet threshold (three newer packets acknowledged) and by time threshold (9/8 of the larger of the smoothed and latest RTT). Both need a later packet to have been acknowledged, so neither says anything about the newest thing sent.
  • §6.2 the probe timeout, which is what finds out about the newest thing sent. It doubles on each expiry and resets when anything is acknowledged.

Two things are deliberately not in that file. It does not read a clock — every entry point takes the time, for the same reason the TLS handshake takes now_sec — and it does not retransmit anything. §13.3 of RFC 9000 is precise about why: QUIC does not resend packets, it resends the frames that were in them, in new packets with new numbers. A lost CRYPTO frame goes again; a lost ACK frame does not, because a newer ACK says more than the lost one did. So loss detection reports what is gone and the connection decides what that is worth.

What the connection decides, for now, is one range per space: the union of the CRYPTO offsets the lost packets carried. Two losses with a delivered packet between them produce a range covering all three, so the middle travels twice. That is wasteful and never wrong — the receiving reassembler takes the first arrival and ignores the rest — and it bounds the bookkeeping to two integers for a handshake a few thousand octets long.

Congestion control (congestion.zig), RFC 9002 §7 and Appendix B: the NewReno controller QUIC specifies, which is also the one it expects to be replaced — the signals are generic enough that CUBIC or BBR reads the same inputs.

It counts bytes rather than packets, and only the bytes of packets that will be acknowledged. §7 exempts a packet carrying nothing but an ACK, and the reason is worth keeping in mind: a sender that throttled its own acknowledgements would be throttling the feedback its controller runs on, and would stall a peer waiting for one. PADDING is not exempt, so a client's 1200-octet first datagram costs the window all 1200.

  • §7.2 an initial window of ten packets, and a floor of two. The floor is what lets a sender in trouble still put a packet on the path while another is outstanding.
  • §7.3 the three states — slow start, recovery, congestion avoidance — and the transitions between them. A recovery period lasts a round trip, not a packet, which is what stops a burst of loss halving the window five times.
  • §7.5 a probe is exempt from the window. A sender whose window was full of packets nobody has acknowledged is exactly the sender who most needs to send something to find out why.
  • §7.6 persistent congestion: when nothing has arrived for three probe timeouts, the window goes to the floor rather than being halved again.
  • §7.1 the peer's ECN-CE counter, which is the path saying it is loaded rather than an inference drawn from a missing packet. Nothing here can set the codepoint — that lives in the IP header, which a QUIC library never touches — so this only fires for a caller that marks its datagrams.

One controller for the connection, because §7 makes it per path; loss detection stays per space, because a packet number means nothing outside its own. Pacing (§7.7) is not here: it needs a timer the caller drives, and belongs above this.

The rule that pays for the rest is §B.5's: a sender that has said everything it has to say is application limited, and the window it did not use is no evidence the path would have carried it. A handshake is almost entirely application limited, so without that rule four acknowledged packets would double the window on no evidence at all — which the test suite asserts does not happen.

Transport parameters (parameters.zig), RFC 9000 §18 — everything two endpoints have to agree before they can use a connection, and not a negotiation: each end states what it will accept and the other obeys. There is no reply and nothing to converge on, which is why the whole exchange fits in one TLS extension in each direction.

Two rules in it are worth stating because they are the kind that produce a connection working against one implementation and stalling against another:

  • Absent is not zero, except when it is. §18.2 says "transport parameters have a default value of 0 if the transport parameter is absent, unless otherwise stated", and four of them state otherwise — an absent max_udp_payload_size means 65527, an absent ack_delay_exponent means a multiplier of 8, an absent max_ack_delay means 25ms, an absent active_connection_id_limit means 2. They live in the struct's field initialisers, where they cannot be forgotten.
  • An unknown parameter is skipped, not refused. §18.1 reserves a whole arithmetic progression of identifiers — 31 * N + 27 — purely so an endpoint can send one and find out whether its peer really does ignore what it does not understand. A parser that rejected them would pass every test until the day QUIC gained an extension. The test that a handshake flight larger than one datagram works now inflates its ClientHello with exactly such a parameter, so the two claims are checked together.

Authenticating connection IDs (§7.3), which is what the parameters are for. An Initial packet is readable and writable by anyone who saw the client's first datagram, so the connection IDs in the headers of a handshake are not trustworthy on their own. Each end therefore repeats them inside the encrypted handshake and the other end checks: the server echoes the Destination Connection ID the client invented, each end proves the Source Connection ID it used, and a mismatch or an absence ends the connection. As §7.3 puts it, this "ensures that an attacker cannot influence the choice of connection ID for a successful connection by injecting packets carrying attacker-chosen connection IDs during the handshake."

The check runs before the keys it would authorize are installed, so an endpoint that rejects a handshake is never afterwards in a state claiming it succeeded.

Streams (stream.zig and the connection that carries them), RFC 9000 §2, §3 and §4.

The two halves of a stream are genuinely separate here, one state machine for sending and one for receiving, because §4.4 says "RESET_STREAM terminates one direction of a stream abruptly. For a bidirectional stream, RESET_STREAM has no effect on data flow in the opposite direction." An implementation that fused them into one machine has to invent a rule for what a reset does to the other side, and every such rule is wrong.

Flow control is a limit, not a window: §4.1 has a receiver advertise "the limit of total bytes it is prepared to receive", an absolute offset rather than an allowance to be spent. That is what makes MAX_DATA and MAX_STREAM_DATA idempotent — a retransmitted one grants nothing extra, and one that arrives out of order and says less than the last is ignored, which is why §4.1 can simply say "a sender MUST ignore any MAX_STREAM_DATA or MAX_DATA frames that do not increase flow control limits" rather than having to sequence them. It also decides what a frame costs: the highest offset it reaches, not the octets that crossed the network, so a peer cannot exhaust a receiver's credit by retransmitting.

The final size (§4.5) is the piece that looks like bookkeeping and is not. It travels on the frame that ends the stream — or in a RESET_STREAM's Final Size field, which is why that frame carries one at all — so that both ends agree on how much credit a stream consumed even when most of it was thrown away.

Sending a stream and sending the handshake turned out to be the same code. §19.6 says a CRYPTO frame "is similar to a STREAM frame, except that it does not have a Stream ID, it is not flow controlled, and it does not have a FIN", and outgoing.zig is what is left when those three differences are taken out: a buffer, a watermark of what has been put in a packet, and a range of what has to go again. The connection's handshake uses it too.

The connection's side of it is a bounded table — sixteen streams, each with a buffer in each direction — because §4.6 has an endpoint advertise how many streams a peer may open, and the memory behind that number has to be decided somewhere. A peer that exceeds the limit gets STREAM_LIMIT_ERROR; a peer that stays inside it and still overruns a buffer is this endpoint having advertised more than it kept room for, which is a different answer because it is a different mistake.

How much memory that is belongs to the caller, and Connection.Sizes is where it says so. The library still allocates nothing: a caller hands over slices — from a heap if it has one, from a Connection.Storage(.{...}) declared wherever it likes if it does not — and the connection takes its limits from what it was given rather than from a constant in this file. Three numbers, and they are not independent:

  • recv_window is the promise. It goes in the transport parameters, §4.1 lets a window grow and never shrink, and a peer takes it at its word, so init asserts that what is advertised is what the buffers hold.
  • send_window is the ceiling on throughput rather than a promise, because §13.3 has a retransmission carry the contents of a lost frame: an octet is held until the peer says it arrived, so a stream can have no more than this in the air. It is what bounds a download and the one to raise first.
  • tracked is how many unacknowledged packets a space remembers, and has to cover what send_window puts in the air — the same packets counted from the other end. init asserts the floor, because a connection that forgets a packet for every packet it sends does not slow down, it stops.

Measured through zig-http against curl on the loopback interface, a megabyte over HTTP/3 moves at about 40 MB/s with the defaults and about 70 with a two hundred and fifty-six kilobyte send window and five hundred and twelve tracked packets. What that costs is sixteen streams' worth of buffer per connection, which is why quic_io.Endpoint buys it when a connection arrives rather than when the endpoint starts. A slot keeps what it was given once it has been used, so the memory an endpoint holds follows the connections it has actually had at once and not the number it is willing to serve — the difference, for a megabyte-per-stream window on a hundred permitted connections, is between ten megabytes resident and two and a half gigabytes.

A bounded table needs a way to give slots back, and §3.4 says when: "closed when both sending and receiving streams are in terminal states". Both halves are terminal is a statement about the application as much as the network — the sending half waits for its data to be acknowledged, and the receiving half waits for what arrived to be read, since §3.2's terminal state is "Data Read" and not "Data Recvd". So a closed stream frees its slot, and §4.6's limit grows to match: "implementations might choose to increase limits as streams are closed, to keep the number of streams available to peers roughly consistent."

Without that, a connection answers initial_max_streams_bidi queries and then refuses — which for DNS over QUIC, where §4.2 of RFC 9250 puts every query on its own stream, is a hard cap on the number of questions a connection can ever be asked. The interoperability test could not see it, because every query there opened a fresh connection; it takes twenty queries on one connection to notice, which is what the suite does now in both directions.

Reclaiming eagerly has one awkward consequence worth knowing about: a stream that has just been read is gone, so streamFinished on it would find nothing. It answers from the retirement watermark instead — a stream that has been forgotten is a stream that completed, and an application asking about one after reading its last octet should be told so. §4.5 is what permits the forgetting: remembering the final size of every stream that ever existed "could mean a significant state commitment".

Flow control updates are sent when half a window has been read, which §4.2 leaves open and which has a liveness argument worth writing down: a peer can only stall after filling the window, which means the application was given a window's worth to read, which means reading half of it crosses the threshold. A MAX_DATA that is lost is never resent — the next one says everything the lost one did and more, which is the same reason a lost ACK is not worth recovering.

Connection termination (close.zig), RFC 9000 §10 and §20 — three ways a connection ends, and they are not variations on one theme. It can be closed immediately, which means saying so; it can time out, which means both ends forgetting it without a word; or it can be stateless reset, which is what an endpoint that has lost its state says to a peer that has not. The first two are here.

There are two states after the end because packets are still arriving. Closing is what an endpoint enters after sending a CONNECTION_CLOSE: it answers any packet it can attribute to the connection by repeating the frame, in case the first was lost, and does nothing else. Draining is what it enters after receiving one, and it sends nothing at all — §10.2.2 warns that an endpoint answering a close with a close produces "a constant exchange of CONNECTION_CLOSE frames until one of the endpoints exits the closing state". Draining is the state that cannot do that.

The rate limit in the closing state is not politeness. §10.2.1 asks an endpoint to "wait for a progressively increasing number of received packets" before answering, because otherwise a peer that ignores the close has turned the connection into a packet amplifier — a closed connection is exactly the one with nothing left to lose by answering.

Choosing the error code is the part that is easy to do badly. PROTOCOL_VIOLATION is §20.1's catch-all for "an error with protocol compliance that was not covered by more specific error codes", and reaching for it when a specific code exists tells the peer less than the truth. A failed handshake becomes CRYPTO_ERROR carrying the TLS alert, which is how a QUIC endpoint reports that a certificate was refused without QUIC knowing what a certificate is.

The idle timeout (§10.1) has one rule worth repeating: zero does not mean a short timeout, it means the endpoint did not ask for one, so the effective value is the minimum over the non-zero values rather than over both. And it is floored at three probe timeouts, without which a connection on a slow path times out while its own retransmissions are still in flight.

Key update (RFC 9001 §6), which is the one part of QUIC's cryptography that is not TLS's. §6: "Initiating a key update results in both endpoints updating keys. This differs from TLS where endpoints can update keys independently." There is one Key Phase bit for the connection, so there is one phase to be in — an endpoint that sees its peer update has no choice but to follow.

Three things make it more delicate than a ratchet:

  • The Key Phase bit is under header protection, and §6.1 says "the header protection key is not updated" — which is exactly why. A receiver has to unmask the header before it can know which packet protection keys the payload used, so the key that unmasks it cannot be one of the keys that changed. Removing header protection is in place, so open is split into reveal and decrypt and the two halves use different keys.
  • A differing bit means one of two things. The bit has two values and a connection has more than two phases, so a packet whose phase differs from the current one is either a straggler protected with the previous keys or the peer initiating an update with the next ones (§6.5). Both are tried, oldest first. Dropping stragglers would look like loss, and loss looks like congestion — an endpoint that discarded them would punish itself for its own key update.
  • The rotation happens after the packet authenticates, never on the bit alone. A Key Phase bit is one bit anyone can flip, and rotating on an unauthenticated one would let a passer-by desynchronise a connection with a forgery that never had to decrypt.

§6.1 also forbids a second update until something sent in the current phase has been acknowledged, which is not politeness either: an endpoint that ratcheted twice would be encrypting with keys its peer cannot derive, because the packet that would have told it never arrived.

AEAD usage limits (§6.6) are what make all of this compulsory. A key may encrypt at most 2^23 packets before the confidentiality margin is spent, and a connection may fail to authenticate at most 2^36 before the integrity margin is. The first forces a key update; the second closes the connection, because the count is of forgery attempts and anyone who can reach the socket can make them.

Connection identifiers (connection_id.zig), RFC 9000 §5.1 — a connection has more than one name, and both ends choose the names the other must use.

Migration is the obvious reason and the less interesting one. §9.5 is the other: a connection ID is a stable value visible in the clear on every packet, so an observer who sees the same one on two paths knows they are the same connection and the same person. "An endpoint MUST NOT reuse a connection ID when sending from more than one local address" — and obeying that requires having a spare, which requires the peer to have issued one in advance. A pool is what makes changing network unobservable rather than merely possible.

The ordering rule in §5.1.2 is the one worth reading twice: on an increased Retire Prior To, an endpoint "MUST stop using the corresponding connection IDs and retire them with RETIRE_CONNECTION_ID frames before adding the newly provided connection ID". Before, so that an endpoint can replace its whole pool at once without the count ever exceeding the limit in between — get the order wrong and a legal peer looks like one that broke the limit.

Three answers to what looks like one question, all from §19.15: the same frame arriving twice is not an error, the same sequence number naming a different identifier is a PROTOCOL_VIOLATION, and a Retire Prior To above the Sequence Number is a FRAME_ENCODING_ERROR. And one rule that reads like a riddle until you picture it — §19.16 forbids retiring the identifier that the packet carrying the frame is addressed to, which is why rotating moves to the new name first and retires the old one from there.

All of an endpoint's own identifiers are the same length, because a short header carries no length field and §5.1 requires an endpoint to find its own identifier in one. An endpoint chooses its own, so choosing one length and keeping it is the simplest way always to know.

Path validation and migration (path.zig), RFC 9000 §8.2 and §9. A QUIC connection is not tied to an address, which is the feature that lets a phone walk out of a building without dropping its downloads — and the feature that would let anyone with a forged source address point somebody else's connection at a victim.

§8.2 is what tells those apart: eight unpredictable octets that must come back. An attacker who can write a source address but cannot read the packets sent to it has nothing to echo. §8.2.3 is blunt about the weaker alternative — "receipt of an acknowledgment for a packet containing a PATH_CHALLENGE frame is not adequate validation, since the acknowledgment can be spoofed by a malicious peer."

Three rules that look alike and are not:

  • A responder is strict about where it replies; an initiator is lax about where the reply arrives. §8.2.2 requires a PATH_RESPONSE to go out on the path its challenge came in on, and then warns that "this requirement MUST NOT be enforced by the endpoint that initiates path validation, as that would enable an attack on migration". §8.2.3 says why the laxity is safe: the entropy validates, not the route it returned by.
  • Only a non-probing packet is a migration. §9.1 makes PATH_CHALLENGE, PATH_RESPONSE, NEW_CONNECTION_ID and PADDING "probing frames" — probing is what an endpoint does to a path it is not using, so treating a probe as a move would let anyone relocate a connection by sending one.
  • Only the highest-numbered non-probing packet moves it. §9.3, so that a reordered straggler does not drag a connection back to an address the peer has left — which is also what stops an attacker replaying an old packet from an address of its choosing.

§9.4 then requires the thing that is easy to forget: "on confirming a peer's ownership of its new address, an endpoint MUST immediately reset the congestion controller and round-trip time estimator for the new path to initial values". The capacity of the new path is not the capacity of the old one, and carrying the estimate over is how a sender floods a path it has never measured.

A path here is a small integer the caller chooses. This library has no sockets, so it has no opinion about addresses: the caller decides which 2-tuples exist and what to number them, and send returns the path a datagram belongs to because §8.2.2 gives it no choice. That is what lets migration be tested without a network — the tests move a peer by passing a different number.

Retry and address validation (retry.zig), RFC 9000 §8.1 and RFC 9001 §5.8. A server that answers an Initial packet is answering an address it has not checked; §8's three-times limit is what makes that survivable, and Retry is how a server stops paying it at all. Instead of completing a handshake with whoever sent the packet, it sends back a token and waits to see the token return from the same address.

The trade is the exact reverse of an amplification attack's: it costs the server one small packet and the client a round trip.

Two properties, at two layers, and neither does the other's job:

  • §5.8's integrity tag proves the sender saw the client's Initial. The tag covers the connection ID the client invented, which never appears in the Retry packet — so an off-path attacker cannot compute it. The key is a published constant, so the tag authenticates nobody: an on-path attacker can compute it too, and §21.2 says so.
  • §8.1.4's token has a real key, and it is the server's alone. It binds the address, so the token is worthless from anywhere else, and it carries an issue time, because §8.1.4 requires replay be "prevented or limited" and a short window is the limit that costs no state.

Which is the point: the token is how a server offloads its own state to the client. It carries the connection ID the client originally chose, because after a Retry that value exists nowhere else — and §7.3 has the server echo it back in original_destination_connection_id, with the Retry's own identifier in retry_source_connection_id, so the client can check that nobody substituted a Retry of their own.

The address the token binds to is opaque octets the caller supplies. This library has never seen an address; the server program formats one and hands it in.

Stateless reset (stateless_reset.zig), RFC 9000 §10.3 — the one message an endpoint can send about a connection it has entirely forgotten. No packet number, no encryption, no frame: an endpoint that lost its state has no keys and no numbers. What it has is a static key and the connection ID off the packet that just arrived, and HMAC(key, connection_id) turns those two into the one value the peer will believe.

Everything odd about the packet follows from being unforgeable and unrecognisable at once. §10.3: "this design ensures that a Stateless Reset is — to the extent possible — indistinguishable from a regular packet with a short header." Two fixed bits, then random octets, then the token. And the same reasoning runs backwards in a way that is easy to miss: §10.3 asks an endpoint to make its own packets at least 22 octets longer than the connection ID length it requires, "so that any Stateless Reset sent by the peer is indistinguishable from a valid packet sent to the endpoint". The disguise only works if there is something to hide among.

A reset is also always one octet shorter than what provoked it, which is not politeness: §10.3.3's loop is two endpoints answering each other's resets forever, and strictly shrinking cannot go round more than a few times.

The token is derived rather than remembered because §10.3.2 says a random token per connection is the wrong answer — "stateless reset specifically exists to handle the case where state is lost, so this approach is suboptimal". A static key gives the same token after a crash as before it, and the same token on every machine in a cluster.

Path MTU discovery (mtu.zig), RFC 9000 §14 and the DPLPMTUD of RFC 8899. Until this, every datagram was 1200 octets — the size §14.1 guarantees and §14.2 requires an endpoint to stay at "in the absence of these mechanisms". Correct, and slow: every header, acknowledgement and frame boundary is paid on 1200 octets instead of 1400, and a stream that would have fitted in one datagram takes two.

There is no way to ask a path how large a datagram it will carry. ICMP would tell an endpoint, and §14.2.1 spends its length explaining why that cannot be trusted — forgeable, often filtered, sometimes about a different path. So the only reliable question is the empirical one: send a datagram of the size in question and see whether it comes back acknowledged.

Two rules make the search safe rather than merely possible:

  • A probe carries nothing worth keeping. §14.4: "endpoints could limit the content of PMTU probes to PING and PADDING frames, since packets that are larger than the current maximum datagram size are more likely to be dropped." A probe carrying stream data would make every failed probe a retransmission — and probes are expected to fail, since that is how the upper bound is found.
  • A lost probe is not news about congestion. §14.4: its loss "SHOULD NOT trigger a congestion control reaction". An endpoint that halved its window on each failed probe would be punishing itself for asking. That exemption is a property of the packet rather than of what is outstanding, because a probe can be declared lost long after the search has stopped waiting for it — by which time "is this the probe?" has the wrong answer.

A probe that goes unanswered is also retried before its size is written off: RFC 8899 is clear that a probe can be lost for all the ordinary reasons, so one failure is not an answer and three are.

Session resumption (RFC 8446 §4.6.1 and §4.2.11), which is the half that 0-RTT rests on. A full QUIC handshake costs a certificate chain, a signature and a verification of both; resumption replaces all of it with a symmetric secret the two ends already agree on.

The mechanism is a NewSessionTicket, and the interesting decision is what a ticket is. §4.6.1 leaves that to the server: it may be "a database lookup key" or "a self-encrypted and self-authenticated value". This takes the second — a ticket here is the resumption secret sealed under a server-held key, so the server remembers nothing at all between the two connections and a machine that has restarted, or a different machine in the same cluster, can still resume. The same reasoning as the stateless reset token above: state a server does not keep is state it cannot lose.

What a client offers back is a binder, and §4.2.11.2 is precise about it in a way that is easy to get wrong: the binder is an HMAC over "a partial ClientHello up to and including the PreSharedKeyExtension.identities field", so the message has to be serialised, truncated at a boundary that depends on the size of the very extension being written, and then have the hole filled in afterwards. That is why pre_shared_key "MUST be the last extension" — the truncation point is only well defined if nothing follows.

The binder is also the reason this had to be proved against another implementation rather than against itself. Computing a binder wrongly and verifying it the same wrong way passes every test either end could write alone.

0-RTT (RFC 9001 §4.6), which is what the session ticket was for. A full handshake costs a round trip before the first request; resumption removes the certificate from it; 0-RTT removes the round trip.

The key is §7.1's client_early_traffic_secret, and where it comes from is the whole of the difficulty. It is derived from the Early Secret and a transcript of the ClientHello and nothing else — which is exactly as much as the client has when it sends early data, and exactly as much as the server has when it reads it. Both ends must take it at one instant: after the ClientHello has been absorbed, before anything else has been, and before the handshake secret is derived, because the Handshake Secret is written over the Early Secret in the same field.

That instant is also why this needed another implementation to prove. Every later secret comes down a different branch of §7.1's schedule, so an endpoint that derived this one wrongly would still complete the handshake, still agree on the application keys, and still be writing early data under a key nobody could read. Nothing it could test against itself would notice.

A 0-RTT packet is a long-header packet sharing the application packet number space (§12.3 of RFC 9000) and sharing none of its keys, which is why they are held beside that space rather than in it. What it may carry is narrower: §12.4 permits no ACK and no CRYPTO there — there is nothing to acknowledge yet and the handshake does not run at this level — so what goes in one here is stream data and the flow control that belongs with it.

Two things follow from sending before being spoken to. The client acts on the limits the server set last time (§7.4.1 of RFC 9000), remembered in the session ticket alongside the resumption secret; and the server is then held to them, because "a server MUST NOT reduce any limits or alter any values that might be violated by the client with its 0-RTT packets" — a reduction would make the client's own behaviour retroactively illegal, and is a connection error.

A refusal is not a failure. A server may resume the session and still decline the early data, and §4.6.3 says what follows: the client "needs to consider the data as lost and retransmit it". Nothing was lost on the path — the server simply never derived the key — so no ordinary loss signal will ever fire for those packets and a client waiting for one would wait forever. They are taken out of the loss tables on the refusal itself and put back into the send buffers, and what the application sees is the same answer either way.

It is off by default at both ends, and §2.3 of RFC 8446 says why in one sentence: early data "does not provide forward secrecy" and "is not guaranteed to be protected against replay". §9.2 of RFC 9001 makes that the application's decision rather than the transport's, because whether a request may happen twice is a property of the request. A DNS query is one that may, which is why doqd has the option at all.

Unidirectional streams (RFC 9000 §2.1, §3.1 and §4.6), which is the shape an application protocol needs for a channel that is not a request and a response. DNS over QUIC has no use for one — §4.2 of RFC 9250 puts every query on a bidirectional stream — but HTTP/3 opens three before it sends anything: a control stream and the two QPACK streams.

What makes them different is that they have one half. §3.1: the stream "has a sending part only at the end that opened it", so every operation on the half that does not exist needs an answer, and the answers are not all the same one. Writing to a stream the peer opened is refused, because there is no half to write to — a different thing from a half that has closed. Reading from one this endpoint opened returns nothing, because "nothing has arrived" is the honest answer and a caller's loop is the same either way.

Three of §19's rules exist only because of that asymmetry, and each is a connection error: a MAX_STREAM_DATA "for a receive-only stream" (§19.10), a RESET_STREAM "for a send-only stream" (§19.4), and a STOP_SENDING "for a receive-only stream" (§19.5). They are easy to leave out, because a correct peer never sends one and nothing in a working connection notices they are missing — which is also why the tests for them build a deliberately incorrect peer out of the real implementation, by switching off the guard that stops this end sending the frame.

The limits are counted apart, which is the point of §2.1 giving the two kinds separate identifier spaces: "these limits apply to streams of each type", and MAX_STREAMS carries a type bit saying which of them it raises. One counter for both would let a peer that opened many unidirectional streams exhaust the allowance for bidirectional ones.

Both ends default to zero of them (§18.2), and that default is kept in the DNS-over-QUIC drivers: advertising capacity for something a DoQ server would never read is an invitation to spend its buffers on nothing.

And a STOP_SENDING is now answered rather than merely obeyed. §3.5 requires the RESET_STREAM that acknowledges it, and this used to move straight to its own terminal state instead: it stopped sending, freed the slot, and satisfied every question about its own behaviour — while the peer waited. A half with a reset still to write is no longer finished with, which is what keeps the slot alive long enough for the frame to go out. The other side of the same mechanism is here too, since §3.5's exchange is useless with only one half of it: an endpoint that could send STOP_SENDING but not answer one, or answer one but not send it, leaves the other end waiting on a conversation it cannot finish.

Unreliable datagrams (RFC 9221), which is an extension rather than part of RFC 9000 — and the first thing here that is. It exists for applications that want everything QUIC does except the one thing it is best known for: a handshake, congestion control, path validation and migration, with no retransmission underneath. Real-time media is the usual example, and tunnelling somebody else's unreliable protocol is the other.

§5.2 is the whole of it in one line: "DATAGRAM frames... SHOULD NOT be retransmitted", because an application that wanted them retransmitted would have used a stream. That makes the implementation smaller than a stream's rather than larger — a frame is written and forgotten, and the way to not retransmit something is to keep no copy of it. What a datagram is still subject to is congestion control and acknowledgement: it is ack-eliciting like almost everything else, and what differs is only what happens when the acknowledgement does not come, which is nothing.

The negotiation is a transport parameter and nothing more. §3: "an endpoint that includes this parameter supports the DATAGRAM frame types and is willing to receive such frames on this connection", so the parameter's presence is the whole of the agreement — which is what lets an extension be deployed at all, since a connection where one end implements it and the other does not is an ordinary connection rather than a failure. A peer that sends one anyway, having been told nothing, is a connection error.

Two things about that limit are easy to get wrong in opposite directions. It measures the whole frame, "including the frame type, length, and payload", so an endpoint that measured only the payload would send a frame one or two octets over a limit the peer closes the connection for — and the octets in question are the ones the sender added itself. And it is not a promise about the path: §3 says so explicitly, so a frame that satisfies the peer's limit can still be too large to fit in a packet. That one is refused when it is offered rather than queued, because a datagram has no offset to be split at (§5.1: "a receiver is not required to reassemble") and nothing will ever make it smaller — queuing it would be queuing it forever, and the application would never learn why.

Both queues are bounded, in a way that is less of a compromise than usual. §5.3 says a receiver "MAY drop" a datagram it cannot hold, and an application that chose an unreliable transport has already agreed that dropping is an outcome — so a full queue is a legitimate answer here rather than a failure to provide for.

The server's preferred address (RFC 9000 §9.6), which is the last thing in RFC 9000 and the one place in QUIC where an address appears on the wire. A server names one at the end of the handshake — typically to move a client off an anycast address onto a unicast one — and the client, if it takes the invitation, ends up talking to a different address on the same connection.

The addresses are raw octets here and stay that way. This library has no address type and cannot connect to anything, so an address is data to hand back to the caller: what the caller does is open a socket to it, give the connection a new path number for it, and say to move. Everything after that is §9's ordinary migration, which was already here.

What was not already here is the identifier. §5.1.1 gives it sequence number 1, fixed by the specification rather than carried in the parameter, and that number has to be reserved at both ends. A server that advertised a preferred address and then issued its own identifiers starting at 1 would be naming two different values with one number, which its peer reads as an endpoint that has lost track of what it issued — and closes the connection over. A client that did not enter it in its own table would find the pool empty at the moment it needed a fresh name, and would migrate wearing the one it arrived in, which is exactly what §9.5's unlinkability exists to prevent.

§18.2 also carries a pair of sentences that look contradictory and are not: a client receiving disable_active_migration "MUST NOT use a new local address when sending to the address that the server provided", and then, in the same paragraph, that parameter "does not prohibit connection migration after a client has acted on a preferred_address transport parameter". The server forbade wandering and separately invited the client to one place. The exemption has its own entry point rather than a flag, so it cannot be taken by accident.

And it found a missing MUST next door. §8.2.1: "an endpoint MUST expand datagrams that contain a PATH_CHALLENGE frame to at least 1200 bytes", and §8.2.2 the same for PATH_RESPONSE. Two things rest on it. What validation proves is not that the peer is reachable but that the path carries what §14 requires of it, so a path validated by a sixty-octet datagram may still be unable to carry a real one. And validation would often not terminate without it: the answering endpoint is held to three times what arrived (§21.1.1.1), so a small challenge buys an answer that may be too small to send — after which neither end has anything left to do and the path is never validated at all. That second failure cannot happen until some endpoint initiates a migration rather than reacting to one, which nothing here did until §9.6 gave a client a reason to.

The same work sharpened what the three-times limit is for. §21.1.1.1 is about responding to an address that arrived in a packet, which might be a forged victim's; an address this endpoint chose itself was supplied by nobody and can be aimed by nobody. Applying the limit to one would only stop the endpoint from ever sending the challenge that would lift it, since nothing arrives from a path nothing has been sent to and three times nothing is nothing. Such a path is exempt from the limit and still unvalidated: §8.2 continues to require the answer before it carries anything that matters.

Interoperability

Everything in zig build test is this library talking to itself, which shows that it agrees with itself and nothing more. The parts of QUIC most likely to be subtly wrong are exactly the parts two implementations have to agree on: the key schedule, the header protection mask, the varint widths, the transport parameter encoding, the order of a TLS flight, what a certificate chain looks like when it spans several packets.

So there is a client and a server — src/main.zig and src/server.zig, a DNS-over-QUIC pair — and a NixOS test that runs each of them against aioquic's other half. aioquic shares nothing with this: a different language, a different author, an implementation years older, and the one Python's own DoQ tooling is built on.

Both directions matter, and the server direction more. A server is the endpoint that answers strangers: it derives Initial keys from a connection ID somebody else chose, obeys the amplification limit before it knows who it is talking to, routes every datagram to a connection by a value it handed out earlier, and sends a certificate chain — which nothing in the project had ever done, because every test until then used a server with no certificate at all.

$ nix build --print-build-logs .#checks.x86_64-linux.interop

DNS over QUIC (RFC 9250) is the right demonstration because it is almost nothing on top of QUIC: a connection, an ALPN of doq, and on each client-initiated bidirectional stream a two-octet length followed by a DNS message, each half of the stream closed when its message is complete. There is no application protocol to get wrong, so what is being tested is the transport.

The test asserts the refusals as well as the answers. With no trust anchor, or with the wrong hostname, the handshake must fail — and then the same connection to the same server must succeed with verification turned off, which is what makes the first two failures attributable to the certificate rather than to the transport.

It has found ten bugs so far, which is the argument for having it. Eight were protocol errors that no amount of talking to ourselves could have surfaced:

  • No HANDSHAKE_DONE, and no notion of confirmation. §4.1.2 of RFC 9001 makes a handshake "confirmed" at different moments for the two roles: at the server when the handshake completes, at the client only when a HANDSHAKE_DONE frame arrives. The asymmetry is load-bearing — when a server's handshake completes it holds the client's Finished, but when a client's completes its own Finished has merely been queued. This had neither the frame nor the distinction, so a client could never confirm, never discard its handshake keys, and never start a key update.
  • Keys were never discarded (§4.9). Initial packet protection is not authenticated, which is why §4.9.1 discards it "more aggressively than other keys" — and an endpoint that keeps it is an endpoint a spoofed Initial can still disrupt. Worse in practice: this kept coalescing Initial packets into every datagram for the life of the connection, and the peer had thrown those keys away long before.
  • Datagrams larger than the path is known to carry. §14 guarantees 1200 octets and no more until path MTU discovery says otherwise. This built 1219-octet datagrams simply because the caller had offered a 2048-octet buffer: what fits in a caller's buffer is not the same question as what the path will carry.
  • A stateless reset token that was never issued. The connection ID from the handshake arrives without one — a server names its token in a transport parameter instead, and §10.3 says a client "cannot use" that parameter at all — so the table held a zero placeholder, and §10.3.1's comparison ran against it on every datagram. Any datagram ending in sixteen zero octets therefore read as a teardown, and aioquic pads its Initial packets with zeros: the first thing this did against a real peer was reset itself on the client's own ClientHello. §10.3.1 says exactly this in one line — "an endpoint MUST NOT check for any stateless reset tokens associated with connection IDs it has not used."
  • A Retry token four times too large to hold. §8.1.4 leaves the token's format to the server that mints it — "there is no need for a single well-defined format" — so a client has to hold whatever it is handed. aioquic's are 256 octets where ours are 61, and worse than the buffer being small was the order: the client adopted the new connection ID and re-derived its Initial keys before discovering the token would not fit, then sent an Initial with no token at all. The server Retried again, and the client did the same thing forever. The check now happens before anything is committed.
  • psk_key_exchange_modes sent only when already resuming. RFC 8446 §4.2.9 requires a client to send that extension whenever it is willing to resume, not once it has a ticket in hand, and §4.6.1 lets a server withhold the NewSessionTicket from a client that did not — which aioquic correctly does. The failure is silent and self-concealing: everything works, the handshake succeeds, and there is simply never a ticket, so there is never a second connection to resume and nothing to notice. Talking to ourselves could not have found it, because both ends were skipping the same step.
  • A slice invalidated by the call that followed it. readable borrows from the reassembly window and consume slides that window down, so reading a request after saying it had been taken read octets that had moved. The rule is now written on readable, where the next caller will find it.

The other two were in the drivers rather than the library, and one of them is worth repeating because it is a trap anyone writing this loop will meet: a QUIC connection always has something to wait for — the idle timer, if nothing else — so "run until there is nothing left to do" runs until the connection times out. A caller has to say what it came for.

The TLS problem

QUIC does not use TLS as a protocol. It uses the handshake as a component and throws the rest away — RFC 9001 §4.1: "QUIC… does not use the TLS record protocol". So it needs a TLS 1.3 implementation that is sans-I/O: one the caller drives, that hands back handshake bytes and traffic secrets per encryption level, and that has no record layer at all.

Almost no TLS library is shaped that way, because for TLS over TCP it is the wrong shape. ianic/tls.zig — the most complete TLS 1.3 in Zig, and the one with a server side — is not: its handshake owns the I/O loop, and the 5-byte record header is reserved inline and back-patched into the same buffer the handshake message is built in. The key schedule is right there and is exactly what QUIC needs; the obstacle is structural rather than cryptographic.

That is why this repository started with an interface and no implementation behind it. Picking a TLS library and building on its shape is how a transport ends up unable to change one.

It has one now. tls.zig gained a quic module — a sans-I/O TLS 1.3 handshake for both ends, sharing that library's key schedule, certificate verification and message helpers, and adding the message-level state machine and the framing that is absent from it. src/tls_handshake.zig here is the adapter, and it is about a hundred lines: the seam existing is why it is an adapter rather than a design.

The one thing that adapter really decides is which traffic secret is for reading. TLS derives a pair per level and names them for the directions they protect; QUIC asks "what do I read with and what do I write with", and the answer inverts between the two ends. Getting it backwards gives a connection where both sides encrypt correctly and neither can decrypt anything.

Nothing above that file names tls.zig. A different TLS — one with 0-RTT, or one that offloads to hardware, or one not written in Zig — is a different file of about the same length.

Building and testing

$ nix develop
$ zig build test --summary all
$ zig build check      # compile the things no test builds
$ zig build fuzz-run -- --seconds 60
$ nix flake check      # the interoperability test, in a guest

And the client, against whatever DNS-over-QUIC server is to hand:

$ zig build run -- --server 127.0.0.1 --port 8853 --host doq.example \
    --ca server.crt example.com
rcode 0, 1 answer(s)
A 192.0.2.1 ttl 300

src/rfc9001.zig is this project's equivalent of RFC 7541's Appendix C in zig-http: a worked example with every intermediate value printed, turning "the key schedule looks right" into "the key schedule produces these exact octets". Key derivation is unusually well suited to that kind of anchor, because every mistake in it — a label off by a character, a length field in the wrong byte order, a context that is absent rather than empty — produces output that is perfectly well-formed and completely wrong.

The vectors were extracted from the published text of RFC 9001 rather than retyped.

Fuzzing

Fourteen targets, all aimed at the same thing: everything a QUIC endpoint does before it has authenticated anything, or on data a peer chose.

  • datagrams — the whole receive path on arbitrary octets. open parses a header, removes header protection with keys the sender very likely does not have, reconstructs a packet number from the result and hands the rest to an AEAD, and every length involved comes from the datagram or from a mask computed over it. None may be trusted; the property is that whatever arrives, the answer is an error or a packet, never a crash and never a read past the end.
  • headers — removing header protection and applying it again is the identity, the revealed packet number length agrees with the bits that revealed it, and no bit outside the four or five §5.4.1 permits ever moves.
  • frames — the iterator either refuses a payload or accounts for every octet of it exactly once, every slice a frame hands back points inside the payload it came from, and no ACK range is inside out or above the largest acknowledged. Anything the parser accepts the writer can write, reading that back gives the same frame, and writing it again gives the same octets — so the encoding is canonical for everything this library produces. That last property is where the encoder gets its real coverage: the hand-written cases reach one shape of each frame type and the fuzzer reaches the field values and varint widths nobody would think to write down.
  • connections — every layer at once, on a datagram a peer chose: packet parsing, header protection removal, the AEAD, frame parsing, CRYPTO reassembly and a TLS handshake. The property is not only that nothing crashes but that nothing arbitrary establishes a connection, and that an answer — if there is one at all — never exceeds three times what arrived, which is §5.2's amplification limit.
  • mtu — a search driven by arbitrary answers, including answers to sizes that were never probed. Whatever a peer does, the search must never claim a size larger than one that was acknowledged, never go below §14.1's guaranteed minimum, and terminate — a search that could be kept going forever would be a way to make an endpoint send probes indefinitely.
  • resets — datagrams a peer chose, against the tokens this endpoint holds. §10.3.1 runs this comparison on every datagram that could not be processed, which makes it the one check an attacker can feed arbitrary octets to for the life of a connection: a false positive is a connection somebody else tore down.
  • paths — challenges and responses in whatever order the input asks for. The adversary is an endpoint forging a source address, and the two properties are what make §8.2 a defence rather than a formality: a path is validated only by data that was actually sent to it, and an unvalidated path gets no more than three times what it sent. It found one — the first version asserted that a validated path never has a challenge outstanding, which §8.2 explicitly permits: "an endpoint might check that a peer is still in possession of its address after a period of quiescence."
  • identifiers — NEW_CONNECTION_ID and RETIRE_CONNECTION_ID frames a peer chose. The interesting input is the pair of sequence numbers, which §19.15 gives three different answers to; the property that bounds memory is §5.1.1's cap, since a peer able to make an endpoint hold identifiers without limit would be choosing how much it allocates.
  • parameters — the extension body a peer sends. For a server these arrive in a ClientHello from an unvalidated address, before anything has been authenticated at all, and every field in them including the lengths is a varint the peer chose. Anything accepted must satisfy §18.2's ranges and must be expressible by the writer, so that a parsed value cannot be one the connection acting on it could not have produced.
  • streams — STREAM frames a peer chose, against §4's accounting. Flow control is where a receiver's memory is decided by numbers the sender writes, so every one of them is hostile: an offset near 2^62, a length overlapping what has arrived, a FIN contradicting an earlier one. It found one — a FIN claiming a stream ended before data already received, which §4.5 makes a FINAL_SIZE_ERROR and which the check for the opposite order did not catch.
  • recovery — sending and acknowledging in whatever order the input asks for, with the congestion controller driven alongside. The adversary is the peer's ACK frame, the one input to loss detection an attacker writes directly: it may name packets never sent, ranges covering the whole 62-bit number space, or the same packet forever. None of that may make an endpoint declare a packet lost that §6.1 says is still in flight, none of it may take the congestion window below §7.2's floor — a window of zero is a connection killed from off the path — and none of it may cost unbounded time or memory, since an ACK range naming 2^62 numbers is six octets to write and must not be six octets to answer.
  • acks — whatever sequence of packet numbers arrives, the ACK frame built from it describes exactly those still remembered. The "no more" half is the one with teeth: an ACK claiming a packet that never arrived tells the peer to stop retransmitting something that was lost, and the peer has no way to find out.
  • reassembly — what comes out is a prefix of what went in, each octet delivered once, and every octet equal to what the first fragment covering that offset carried. Checked against a shadow copy, so a later fragment cannot rewrite bytes already delivered.
  • varints — a value never exceeds 2^62-1 and survives a round trip. The round trip is one-directional on purpose: re-encoding is not the identity on octets, because §16 permits non-minimal encodings and a test expecting otherwise would be enforcing a rule QUIC does not have.

The datagrams target earned itself immediately. With the wrong keys, header protection removal reveals a garbage packet number length — and the length decides how much ciphertext there is, so a caller who sized the output buffer for the packet it expected was overrun by a packet that guessed differently. open now checks that length against the datagram that actually arrived rather than trusting it.

References cited

Iyengar, J., and M. Thomson, eds. 2021. QUIC: A UDP-Based Multiplexed and Secure Transport. RFC 9000. RFC Editor. https://www.rfc-editor.org/info/rfc9000. §16 and §17 are implemented, and Appendix A.3's packet number decoding is transcribed in src/packet.zig.

Thomson, M., and S. Turner, eds. 2021. Using TLS to Secure QUIC. RFC 9001. RFC Editor. https://www.rfc-editor.org/info/rfc9001. §5 is implemented here and Appendix A runs as tests in src/rfc9001.zig.

Iyengar, J., and I. Swett, eds. 2021. QUIC Loss Detection and Congestion Control. RFC 9002. RFC Editor. https://www.rfc-editor.org/info/rfc9002. §5 and §6 are implemented in src/recovery.zig and §7 in src/congestion.zig, with the constants of Appendices A.2 and B.1.

Rescorla, E. 2018. The Transport Layer Security (TLS) Protocol Version 1.3. RFC 8446. RFC Editor. https://www.rfc-editor.org/info/rfc8446. §7.1's HKDF-Expand-Label, which every key in QUIC is derived with; §4.2.9, §4.2.11 and §4.6.1 for session resumption, implemented in the tls.zig fork.

Krawczyk, H., and P. Eronen. 2010. HMAC-based Extract-and-Expand Key Derivation Function (HKDF). RFC 5869. RFC Editor. https://www.rfc-editor.org/info/rfc5869.

Fairhurst, G., T. Jones, M. Tüxen, I. Rüngeler, and T. Völker. 2020. Packetization Layer Path MTU Discovery for Datagram Transports. RFC 8899. RFC Editor. https://www.rfc-editor.org/info/rfc8899. The search in src/mtu.zig is this, in the form §14.3 of RFC 9000 asks for: probes that carry nothing worth keeping, and a failure that is not an answer until it has happened three times.

Huitema, C., S. Dickinson, and A. Mankin. 2022. DNS over Dedicated QUIC Connections. RFC 9250. RFC Editor. https://www.rfc-editor.org/info/rfc9250. The first intended consumer, and the reason this is a library of its own.

Pauly, T., E. Kinnear, and D. Schinazi. 2022. An Unreliable Datagram Extension to QUIC. RFC 9221. RFC Editor. https://www.rfc-editor.org/info/rfc9221. Implemented in src/frame.zig, src/parameters.zig and src/Connection.zig; the first extension here, and the reason the transport parameter registry is shared rather than closed.

Bishop, M., ed. 2022. HTTP/3. RFC 9114. RFC Editor. https://www.rfc-editor.org/info/rfc9114. The second, which will live in zig-http rather than here.

Krasic, C., M. Bishop, and A. Frindell, ed. 2022. QPACK: Field Compression for HTTP/3. RFC 9204. RFC Editor. https://www.rfc-editor.org/info/rfc9204. Goes with it, and not here either — though §5 defers to RFC 7541 for the Huffman code, so whoever writes it will find that the table is already somewhere.

Licence

MIT. See LICENSES/MIT.txt; the project follows the REUSE specification, so every file says so itself.