Sans-I/O QUIC for Zig: RFC 9000-9002 plus raw public keys, multipath, address discovery and n0 NAT traversal, tested against iroh.
  • Zig 94.5%
  • Nix 3.3%
  • Python 0.9%
  • Rust 0.7%
  • C 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 72e6c497cc
All checks were successful
test / interop (push) Successful in 2m17s
test / test (push) Successful in 20m7s
test / docs (push) Successful in 16m58s
Ignore a Retry at a server, keep packet numbers, probe before moving
A Retry arriving at a server closed the connection with
PROTOCOL_VIOLATION. Nothing in a Retry's header is authenticated, so
anyone who knew a connection ID could close it that way; it is now
dropped.

A client reset its Initial packet numbers after a Retry, which
§17.2.5.3 forbids. They now carry on.

migrateToPreferredAddress moved the connection to the server's
preferred address at once, before the address had answered. §9.6.3
has the client validate it first and stay on the original address if
that fails. The new path is now probed with a PATH_CHALLENGE, addressed
by a spare connection ID the original path does not use, and the
connection moves only when the PATH_RESPONSE arrives.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01P9FTtTSPWyDyPixBguXT4e
2026-10-31 02:13:28 -05:00
.forgejo/workflows Raw public keys against iroh 2026-09-24 11:32:13 -05:00
docs Build with Zig 0.17.0 2026-10-02 19:38:13 -05:00
LICENSES The seam onto TLS, and the packet protection that hangs off it 2026-09-16 22:41:42 -05:00
src Ignore a Retry at a server, keep packet numbers, probe before moving 2026-10-31 02:13:28 -05:00
tests Ignore a Retry at a server, keep packet numbers, probe before moving 2026-10-31 02:13:28 -05:00
tools Build with Zig 0.17.0 2026-10-02 19:38:13 -05:00
.gitignore Raw public keys against iroh 2026-09-24 11:32:13 -05:00
build.zig Build with Zig 0.17.0 2026-10-02 19:38:13 -05:00
build.zig.zon Move tls.zig to e689dc9 2026-10-06 22:25:41 -05:00
build.zig.zon.nix Move tls.zig to e689dc9 2026-10-06 22:25:41 -05:00
flake.lock Build with Zig 0.17.0 2026-10-02 19:38:13 -05:00
flake.nix Build with Zig 0.17.0 2026-10-02 19:38:13 -05:00
package.nix Version 0.2.1 2026-10-02 19:58:45 -05:00
README.md Version 0.2.1 2026-10-02 19:58:45 -05:00
REUSE.toml Raw public keys against iroh 2026-09-24 11:32:13 -05:00

zig-quic

A QUIC implementation for Zig: the transport of RFC 9000, secured with TLS 1.3 as RFC 9001 describes, with the loss recovery and congestion control of RFC 9002. It gives you encrypted, multiplexed streams and unreliable datagrams over UDP, from either end of a connection.

It comes as two modules:

  • quic_io — sockets, tasks, and streams that are an ordinary std.Io.Reader and std.Io.Writer. A server is an Endpoint and a callback; a client is a Client and a connect. Start here.
  • quic — the protocol itself, sans-I/O: a Connection is a state machine that takes datagrams in and hands datagrams out, with no sockets, no clock and no allocation of its own. Use it to drive QUIC from an event loop you already have.

The API documentation is generated from the doc comments, which explain each type and option in detail and quote the RFC text behind the rules.

Contents

Features

The whole of QUIC version 1 (RFC 9000, RFC 9001, RFC 9002):

  • Bidirectional and unidirectional streams, with stream and connection flow control.
  • The TLS 1.3 handshake, with certificate verification, ALPN, session resumption and 0-RTT early data.
  • Loss detection, NewReno congestion control and ECN.
  • Connection IDs, connection migration and path validation, and a server's preferred address.
  • Retry and address validation, stateless reset, and version negotiation.
  • Path MTU discovery (RFC 8899), starting at 1200 octets.
  • Key updates, with the AEAD usage limits that require them.
  • Connection close with transport and application error codes, idle timeout, and the closing and draining states.

Extensions:

Extension Specification Turned on by
Unreliable datagrams RFC 9221 max_datagram_frame_size
Raw public keys instead of certificates RFC 7250 raw_public_key
Greasing the QUIC Bit RFC 9287 grease_quic_bit
Compatible version negotiation RFC 9368 always sent
Address discovery draft-ietf-quic-address-discovery-01 address_discovery
ACK frequency draft-ietf-quic-ack-frequency-14 min_ack_delay
Multipath draft-ietf-quic-multipath-21 initial_max_path_id
NAT traversal, as iroh does it n0's protocol, from noq n0_nat_traversal

Each extension is off until both ends ask for it, by setting the transport parameter in the last column. Raw public keys are not a transport parameter; they are an option on Endpoint and Client.

Requirements

  • Zig 0.17.0. Versions 0.2.0 and later need it. The last version for Zig 0.16 is tagged v0.0.0 and kept on the zig-0.16 branch.
  • tls.zig, fetched automatically. This is a fork of ianic/tls.zig with a sans-I/O TLS 1.3 handshake added for QUIC.
  • A platform with UDP sockets for quic_io. The quic module needs none.

Adding it to a project

$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-quic.git#v0.2.1

That adds the package to build.zig.zon as zig_quic. Import the modules in build.zig:

const quic = b.dependency("zig_quic", .{});

const exe = b.addExecutable(.{
    .name = "app",
    .root_module = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
        .imports = &.{
            .{ .name = "quic", .module = quic.module("quic") },
            .{ .name = "quic_io", .module = quic.module("quic_io") },
        },
    }),
});

A server

An echo server: every stream a client opens gets back what the client sent on it.

const std = @import("std");
const Io = std.Io;
const net = Io.net;
const quic = @import("quic");
const quic_io = @import("quic_io");

pub fn main(init: std.process.Init) !void {
    const io = init.io;
    const gpa = init.gpa;

    var seed: [8]u8 = undefined;
    io.random(&seed);
    var prng: std.Random.DefaultPrng = .init(std.mem.readInt(u64, &seed, .little));

    // The certificate chain (leaf first) and its private key, both PEM.
    var auth: quic.tls_handshake.CertKeyPair = try .fromFilePath(gpa, io, Io.Dir.cwd(), "server.crt", "server.key");
    defer auth.deinit(gpa);

    // Keep these two across restarts in a real deployment; see below.
    var reset_key: quic.stateless_reset.Key = undefined;
    try io.randomSecure(&reset_key);
    var token_key: quic.retry.Token.Key = undefined;
    try io.randomSecure(&token_key);

    const address: net.IpAddress = try .parse("0.0.0.0", 4433);
    var socket = try address.bind(io, .{ .mode = .dgram });
    defer socket.close(io);

    var endpoint: quic_io.Endpoint = try .init(io, gpa, &socket, .{
        .auth = &auth,
        .alpn = &.{"echo"},
        // A window advertised below may not exceed what this holds.
        .sizes = .{ .recv_window = 64 << 10 },
        .local = .{
            .initial_max_data = 1 << 20,
            .initial_max_stream_data_bidi_remote = 64 << 10,
            .initial_max_streams_bidi = 16,
            .max_idle_timeout = 30_000,
        },
        .rng = prng.random(),
        .reset_key = reset_key,
        .token_key = token_key,
    }, .{ .context = null, .serveFn = serve });
    defer endpoint.deinit();

    try endpoint.pump();
}

/// Runs on a task of its own for each connection; the connection is closed
/// when it returns.
fn serve(_: ?*anyopaque, session: *quic_io.Session) void {
    while (session.accept()) |stream| {
        var read_buffer: [1024]u8 = undefined;
        var reader = stream.reader(&read_buffer);
        var write_buffer: [1024]u8 = undefined;
        var writer = stream.writer(&write_buffer);

        // Copy everything the client sends back to it, then end our half.
        _ = reader.interface.streamRemaining(&writer.interface) catch continue;
        writer.finish() catch continue;
    }
}

pump reads the socket and runs the timers until endpoint.stop() is called. Each new connection is handed to serve on a task of its own, so a slow connection does not hold up the others.

A client

The other end: connect, send a message on a new stream, and print the reply.

const std = @import("std");
const Io = std.Io;
const net = Io.net;
const quic_io = @import("quic_io");

pub fn main(init: std.process.Init) !void {
    const io = init.io;
    const gpa = init.gpa;

    var seed: [8]u8 = undefined;
    io.random(&seed);
    var prng: std.Random.DefaultPrng = .init(std.mem.readInt(u64, &seed, .little));

    // Trust whatever the operating system trusts.
    var trust: std.crypto.Certificate.Bundle = .empty;
    defer trust.deinit(gpa);
    try trust.rescan(gpa, io, Io.Timestamp.now(io, .real));

    const any: net.IpAddress = try .parse("0.0.0.0", 0);
    var socket = try any.bind(io, .{ .mode = .dgram });
    defer socket.close(io);

    const server: net.IpAddress = try .parse("192.0.2.1", 4433);

    var why: quic_io.Client.Diagnosis = .{};
    var client: quic_io.Client = undefined;
    client.connect(io, gpa, &socket, server, .{
        .alpn = &.{"echo"},
        .host = "echo.example",
        .root_ca = trust,
        .rng = prng.random(),
        // A window advertised below may not exceed what this holds.
        .sizes = .{ .recv_window = 64 << 10 },
        .local = .{
            .initial_max_data = 1 << 20,
            .initial_max_stream_data_bidi_local = 64 << 10,
            .max_idle_timeout = 30_000,
        },
        .diagnosis = &why,
    }) catch |err| {
        std.log.err("{t}: code {?any}, \"{s}\"", .{ err, why.code, why.reason() });
        return err;
    };
    defer client.deinit();

    // Something has to read the socket while this task reads the stream.
    var tasks: Io.Group = .init;
    defer tasks.cancel(io);
    try tasks.concurrent(io, run, .{&client});
    defer client.stop();

    const stream = try client.session.openBidirectional();

    var write_buffer: [1024]u8 = undefined;
    var writer = stream.writer(&write_buffer);
    try writer.interface.writeAll("hello, QUIC");
    try writer.finish();

    var read_buffer: [1024]u8 = undefined;
    var reader = stream.reader(&read_buffer);
    const reply = try reader.interface.allocRemaining(gpa, .limited(64 << 10));
    defer gpa.free(reply);
    std.debug.print("{s}\n", .{reply});
}

fn run(client: *quic_io.Client) void {
    client.run() catch |err| std.log.err("client: {t}", .{err});
}

connect returns once the handshake is complete. After that, run must have a task of its own: see Tasks.

Using quic_io

The pieces

Type What it is
Endpoint A server: one UDP socket, a table of connections, and the task that reads the socket and runs the timers (pump).
Client One outgoing connection on a socket of the caller's, with run (or once) to read the socket and run the timers.
Session One connection, from either end. Opens and accepts streams, sends and receives datagrams, and closes the connection.
Stream One stream on a session. stream.reader(buffer) and stream.writer(buffer) give an Io.Reader and an Io.Writer.

A Session is the same type whether it came from an Endpoint or a Client, so code written against one works at either end.

Tasks

Reading a stream blocks until a datagram arrives, and datagrams arrive only when something reads the socket. That is Endpoint.pump for a server and Client.run for a client. Run it on a different task from anything that reads a stream. On the same task, the read waits for a datagram that only it could deliver, and nothing reports the deadlock. The examples above show the arrangement: the server's pump runs on the main task and calls serve on others, and the client starts run with Io.Group.concurrent.

Writes do not need the pump. Writing to a stream sends the datagrams itself.

Client.once(wait_ns) reads one datagram or runs the timers once. It is for a caller that drives everything from one task and never blocks in a stream read.

The Io you pass must be able to run tasks concurrently. std.Io.Threaded can.

Streams

  • session.openBidirectional() and session.openUnidirectional() open a stream. Opening fails while the peer's stream limit is used up; the limit rises as streams close.
  • session.accept() waits for the next stream the peer opens, and returns null once the connection has ended.
  • Stream.Writer.finish() flushes and ends this side of the stream. A peer reading the stream sees end of stream once it has read everything.
  • session.resetStream(id, code) abandons sending; session.stopSending(id, code) asks the peer to stop.
  • stream.id.directionality() says whether a stream is bidirectional or unidirectional.

Transport parameters

Options.local is what this end tells its peer it will accept (quic.parameters.Parameters). The defaults are those of §18.2 of RFC 9000, and most of them are zero: a peer gets no streams and no flow control credit until you set them. Most applications need at least:

Parameter Meaning
initial_max_data Octets the peer may send in total, across all streams, before more credit is granted.
initial_max_stream_data_bidi_remote Octets the peer may send on each bidirectional stream it opens.
initial_max_stream_data_bidi_local Octets the peer may send on each bidirectional stream this end opens.
initial_max_stream_data_uni Octets the peer may send on each unidirectional stream it opens.
initial_max_streams_bidi, initial_max_streams_uni How many streams of each kind the peer may open at once.
max_idle_timeout Milliseconds of silence before the connection is dropped; zero means no limit.

Credit is granted again as the application reads, so these are window sizes rather than totals. A stream flow-control limit may not be larger than the connection's sizes.recv_window. A connection whose limits are larger fails an assertion when it is created, because it would be promising buffer space it does not have.

Memory

Each connection's memory is fixed when it is created, and Options.sizes (quic.Connection.Sizes) sets how much:

  • recv_window — receive buffer per stream, 4 KiB by default. It is the largest per-stream flow-control limit you can advertise.
  • send_window — send buffer per stream, 4 KiB by default. Data stays here until the peer acknowledges it, so it limits throughput per stream to about send_window per round trip. Raise it for bulk transfers.
  • tracked — how many unacknowledged packets are remembered. It must be at least sizes.trackedFloor().
  • paths — how many network paths a connection can hold, for multipath.

A connection has room for 16 streams at once (quic.Connection.max_streams), so its stream buffers come to 16 times recv_window plus 16 times send_window. An Endpoint allocates that when a connection arrives, keeps the memory for reuse when the connection ends, and holds at most max_connections connections (64 by default).

max_send_window lets a connection start at sizes.send_window and grow up to a larger window, if it shows it can use one. A server with both nearby and distant clients can then avoid giving every connection the buffers only the distant ones need.

Authentication

Certificates. A server loads its chain and key with quic.tls_handshake.CertKeyPair.fromFilePath and passes it as auth. A client passes the name it expects as host and the roots it trusts as root_ca, a std.crypto.Certificate.Bundle. rescan fills one from the system store and addCertsFromFilePath from a PEM file. The certificate must name host and chain to a root in root_ca. RSA, ECDSA and Ed25519 keys are supported.

insecure_skip_verify = true turns verification off. It is for tests.

Raw public keys (RFC 7250) replace certificates with a bare Ed25519 key, the way iroh authenticates peers. Each end sets raw_public_key:

.raw_public_key = .{
    // This end's key: an Ed25519 private key in PEM, as `openssl genpkey
    // -algorithm ed25519` writes it.
    .key_pair = try quic.tls_handshake.rawKeyPairFromFile(gpa, io, Io.Dir.cwd(), "client.pem"),
    // The key the other end must hold. Required for a client; for a
    // server, null admits any client that proves it holds some key.
    .peer = server_public_key,
},

A client dialing by key may leave host empty, and then sends no server name. On a server, session.peerPublicKey() says which key a client proved.

When a handshake fails

Client.connect returns one of:

  • error.BadCertificate — a certificate was refused. Usually this end refused the server's: an untrusted chain, the wrong name, or an expired certificate. This is the failure that can mean someone is impersonating the server.
  • error.HandshakeFailed — any other failure: no protocol in common, a transport parameter the peer would not accept, or a peer that closed the connection.
  • error.HandshakeTimeout — no handshake within handshake_timeout_ns (ten seconds by default). This is what a server that is not there looks like.
  • error.NetworkFailed, error.OutOfMemory, error.Canceled.

For the details, pass a Diagnosis as Options.diagnosis, as the client example does. It records the close code, the TLS alert inside it (alert()), the reason text the peer sent (reason()), and whether the peer sent the close or this end did (from_peer).

Server options

  • reset_key derives the stateless reset tokens. A server that loses a connection's state, by restarting for instance, uses them to tell the peer the connection is gone, which works only if the key is the same as before. Generate it once and store it.
  • token_key seals Retry tokens.
  • require_retry makes every client prove its address with a Retry before the server keeps any state for it. It costs the client a round trip and protects the server from being used in an amplification attack.
  • ticket_key turns on session resumption. A client that saved a ticket passes it as Client.Options.ticket and skips the certificate exchange. Tickets are sealed with this key, so the server stores nothing per client.
  • early_data accepts 0-RTT data from resuming clients (early_data on the client too). An attacker can replay 0-RTT data, so turn it on only if your protocol tolerates receiving a request twice.
  • max_connections, connection_id_len, sizes and max_send_window set the server's capacity.

Closing

A server's connection closes when serve returns, with application error code zero. To close with a different code, call session.closeApplication(code, reason) first. session.closeState() reports a connection that is closing or has closed, and why. A client closes its connection in deinit.

Datagrams

RFC 9221 datagrams are delivered at most once, are never retransmitted, and are subject to congestion control. Set max_datagram_frame_size in local (1200 is a good value) on both ends, then use session.sendDatagram(bytes) and session.readDatagram(buffer). readDatagram blocks until one arrives, so give it a task of its own.

Cancelation

Canceling a task that is waiting on a session works as it does elsewhere in std.Io. connect returns error.Canceled. Functions whose return type has no room for it — stream reads and writes, accept, readDatagram — report an ordinary failure instead, and re-arm the cancelation so that the task's next cancelation point returns error.Canceled.

The other extensions

Set the transport parameter on both ends, then:

  • Address discovery: address_discovery = .both (or .provide, or .receive). session.observedAddress() is the address the peer sees this end's packets come from: the address a NAT has given it.
  • ACK frequency: min_ack_delay, in microseconds, on the end that will be asked. session.requestAckFrequency(...) asks the peer to acknowledge less often, and session.requestImmediateAck() asks for an acknowledgement now.
  • Multipath: initial_max_path_id of 1 or more, and sizes.paths of at least two. A client opens a path from a second socket with client.openPath(&socket), waits for client.pathReady(opened), and moves traffic with session.useSendPath or session.setScheduler.
  • NAT traversal: n0_nat_traversal, the number of candidate addresses to accept. Each end offers its addresses with session.addNatAddress, and the client starts hole punching with session.startNatRound().

Using the sans-I/O core

quic.Connection is the protocol with nothing else attached. You own the socket, the clock, the memory and the TLS handshake, and the connection tells you what to send and when to wake it:

var storage: quic.Connection.Storage(.{}) = .{};
var connection: quic.Connection = undefined;
connection.init(.{
    .role = .client,
    .handshake = tls_client.handshake(),
    .local_id = &my_connection_id,
    .destination_id = &initial_destination,
    .memory = storage.memory(),
    .local = parameters,
    .rng = rng,
});

// Each time round your loop:
try connection.receive(datagram, path, now);       // a datagram arrived
while (try connection.send(&buffer, now)) |out| {  // what to send, and where
    // write out.bytes to the peer on out.path
}
const wake_at = connection.timeout();              // when to call onTimeout(now)

Times are nanoseconds from a clock of your choosing. The TLS handshake goes through the quic.tls.Handshake interface. quic.tls_handshake implements it with tls.zig, and a different TLS library could be plugged in by implementing the same seven functions. The transport parameters you pass as local must also be encoded with quic.parameters.write and given to the TLS handshake, which carries them to the peer.

quic_io is written entirely on top of this API, and src/main.zig is a complete client that drives a Connection by hand. Both are good places to see the whole loop.

Example programs

Two programs come with the library, a DNS-over-QUIC (RFC 9250) client and server. They are small, they exercise most of the library, and they are what the interoperability tests point at other implementations.

$ zig build
$ zig-out/bin/doqd --cert server.crt --key server.key --port 8853 \
    --answer example.com=192.0.2.1
$ zig-out/bin/doq --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

zig build run -- ARGS runs doq without installing it. Both take --help, and between them have options for every feature above: raw public keys, resumption and 0-RTT, Retry, datagrams, multipath, NAT traversal and more.

Interoperability

NixOS virtual machine tests run the example programs against other QUIC implementations, over real sockets:

Test Against
interop aioquic, in both directions: certificates, resumption, 0-RTT, Retry, stateless reset, connection ID rotation, many queries on one connection.
openssl OpenSSL 3.6's QUIC, with raw public keys in both directions, including resumption and the refusals.
iroh iroh 1.2, dialing and being dialed by raw public key, with resumption and 0-RTT.
multipath This library on two machines, opening and using a second path.
$ nix build --print-build-logs .#checks.x86_64-linux.interop

They need a machine that can run virtual machines (/dev/kvm).

The library has also been used against public DNS-over-QUIC and DNS-over-HTTP/3 servers (AdGuard, Cloudflare, Google, Quad9) through zig-dns-client.

Limitations

  • Little production use. The protocol is complete and tested against the implementations above, but it has not been deployed widely. Expect to find problems that only real networks show.
  • TLS comes from one place. The handshake is the tls.zig fork: TLS 1.3 only, with the three cipher suites RFC 9001 allows. Raw public keys are Ed25519 only.
  • At most 16 streams at a time per connection, and fixed memory per connection; see Memory.
  • No HTTP/3 here. HTTP/3 (RFC 9114) and QPACK (RFC 9204) are being built in zig-http on top of this library, which also keeps protocols like DNS over QUIC free of an HTTP dependency.
  • ECN needs platform support. quic_io.ecn reads and sets the ECN bits where the platform allows it, and otherwise the connection runs without ECN.
  • A server cannot find out its own address from a socket bound to the unspecified address, since Zig 0.17's standard library has no way to ask. Address discovery is the protocol's answer, and docs/iroh-transport.md covers it and the other pieces needed to talk to iroh.

Development

$ nix develop
$ zig build test --summary all
$ zig build check            # compile everything, including what no test builds
$ zig build docs             # API documentation into zig-out/docs
$ nix flake check            # the package and the interoperability tests

zig build test -Dtest-filter=TEXT runs only the tests whose names contain TEXT.

src/rfc9001.zig checks the key schedule and packet protection against the worked examples in RFC 9001 Appendix A, octet for octet.

Fuzzing

tests/fuzz.zig has fifteen fuzz targets, aimed mostly at what a peer controls: received datagrams, header protection, frames, varints, acknowledgements, stream reassembly, loss recovery, transport parameters, stream and flow-control accounting, connection IDs, path validation, stateless reset, path MTU discovery, the full receive path through the TLS handshake, and what this end itself puts on a stream. Each target checks properties, not only the absence of crashes: for example, that anything the frame parser accepts the frame writer reproduces exactly, and that a server never sends more than three times what it received from an unvalidated address.

There are two ways to run them:

$ zig build fuzz --fuzz            # Zig's fuzzer, guided by coverage, until interrupted
$ zig build fuzz --fuzz=1M         # the same, for a million runs
$ zig build fuzz-run -- --seconds 60
$ zig build fuzz-run -- --iterations 100000 --target frames

zig build fuzz --fuzz is Zig's own fuzzer. It serves a web interface while it runs, and saves any failing input to .zig-cache/f/crash. Its report has one entry for the whole test binary, named after the first target, but every target runs. zig build fuzz-run is a simpler loop with no coverage feedback, seeded from a corpus of real packets. Given the same seed and count it produces the same inputs on any machine, which is why CI uses it.

Without --fuzz, zig build test runs the fuzz targets once on their seed inputs, as ordinary tests.

Where this lives

The repository is in three places with the same history. The Forgejo instance at https://git.jcollie.dev/jeff/zig-quic is the main 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 on the Radicle network as

rad:zuEbDUHm86sgB9SfddbBdzwj54ra

where rad clone rad:zuEbDUHm86sgB9SfddbBdzwj54ra fetches it from any node that seeds 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.

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.

Peon, R., and H. Ruellan. 2015. HPACK: Header Compression for HTTP/2. RFC 7541. RFC Editor. https://www.rfc-editor.org/info/rfc7541. Nothing here implements it. It is cited twice all the same: §5 of RFC 9204 defers to its Huffman code, so a caller writing QPACK will need it; and its Appendix C is the model src/rfc9001.zig follows, a worked example with every intermediate value printed.

Chu, J., N. Dukkipati, Y. Cheng, and M. Mathis. 2013. Increasing TCP's Initial Window. RFC 6928. RFC Editor. https://www.rfc-editor.org/info/rfc6928. Ten segments, which §7.2 of RFC 9002 adopts as QUIC's initial congestion window; src/congestion.zig takes the ten and adjusts it for UDP's smaller datagram.

Petit-Huguenin, M., and G. Salgueiro. 2016. Multiplexing Scheme Updates for Secure Real-time Transport Protocol (SRTP) Extension for Datagram Transport Layer Security (DTLS). RFC 7983. RFC Editor. https://www.rfc-editor.org/info/rfc7983. Why src/packet.zig can tell a QUIC packet from the STUN, DTLS and SRTP that may share its UDP port: the first octet's two high bits are not free, and a demultiplexer that ignores them is one that answers somebody else's traffic.

Hamilton, R. 2022. Bootstrapping WebSockets with HTTP/3. RFC 9220. RFC Editor. https://www.rfc-editor.org/info/rfc9220. Not implemented here — it belongs to zig-http — but its extended CONNECT is the shape tests/endpoint.zig checks a stream can take, since nothing else in this library needs a request that stays open.

Liu, Y., Y. Ma, Q. De Coninck, O. Bonaventure, C. Huitema, and M. Kühlewind. 2026. Managing multiple paths for a QUIC connection. draft-ietf-quic-multipath-21. Internet-Draft, work in progress. IETF. https://datatracker.ietf.org/doc/draft-ietf-quic-multipath/21/. §4's frames at §6's codepoints, §2.1's parameter, §2.4's nonce, §3's path management, and §5's per-path recovery, PMTU and scheduling.

Iyengar, J., I. Swett, and M. Kühlewind. 2026. QUIC Acknowledgment Frequency. draft-ietf-quic-ack-frequency-14. Internet-Draft, work in progress. IETF. https://datatracker.ietf.org/doc/draft-ietf-quic-ack-frequency/14/. The min_ack_delay parameter, ACK_FREQUENCY and IMMEDIATE_ACK, §6's rules for when to acknowledge, with §6.2.1's two tables transcribed as tests in src/ack.zig, and §7's probe timeout.

n0 computer. 2026. noq. Commit 89ed00c, 22 September 2026. https://github.com/n0-computer/noq. Not a document but a QUIC implementation, cited because noq-proto/src/n0_nat_traversal.rs and the connection code around it are the only definition n0's NAT traversal has: the transport parameter, the three frames, the rounds and the probe schedule in src/nat.zig are read from it.

Seemann, M., and E. Kinnear. 2024. Using QUIC to traverse NATs. draft-seemann-quic-nat-traversal-02. Internet-Draft, expired. IETF. https://datatracker.ietf.org/doc/draft-seemann-quic-nat-traversal/02/. Cited for what is not implemented: n0's protocol takes its ideas from this draft and none of its codepoints, and docs/iroh-transport.md records why this library follows n0's instead.

Raiciu, C., M. Handley, and D. Wischik. 2011. Coupled Congestion Control for Multipath Transport Protocols. RFC 6356. RFC Editor. https://www.rfc-editor.org/info/rfc6356. §3's linked increases, as congestion.Coupling, applied to paths the lowest-RTT scheduler shares.

Seemann, M., and C. Huitema. 2026. QUIC Address Discovery. draft-ietf-quic-address-discovery-01. Internet-Draft, work in progress. IETF. https://datatracker.ietf.org/doc/draft-ietf-quic-address-discovery/01/. The address_discovery transport parameter and the OBSERVED_ADDRESS frame, at the codepoints this revision gives them, which are the ones noq uses.

Thomson, M. 2021. Version-Independent Properties of QUIC. RFC 8999. RFC Editor. https://www.rfc-editor.org/info/rfc8999. The invariants that let src/packet.zig read a long header's version and connection IDs before it knows what version the packet is in.

Thomson, M. 2022. Greasing the QUIC Bit. RFC 9287. RFC Editor. https://www.rfc-editor.org/info/rfc9287. The grease_quic_bit transport parameter, packet.parseWith, and the connection clearing the bit on what it sends once its peer has advertised it.

Schinazi, D., and E. Rescorla. 2022. Compatible Version Negotiation for QUIC. RFC 9368. RFC Editor. https://www.rfc-editor.org/info/rfc9368. The version_information transport parameter, §4's parsing rules and comparisons, and VERSION_NEGOTIATION_ERROR.

Wouters, P., H. Tschofenig, J. Gilmore, S. Weiler, and T. Kivinen. 2014. Using Raw Public Keys in Transport Layer Security (TLS) and Datagram Transport Layer Security (DTLS). RFC 7250. RFC Editor. https://www.rfc-editor.org/info/rfc7250. The client_certificate_type and server_certificate_type extensions and a Certificate that carries a bare SubjectPublicKeyInfo, implemented in the tls.zig fork and reached through raw_public_key on Endpoint and Client.

Josefsson, S., and J. Schaad. 2018. Algorithm Identifiers for Ed25519, Ed448, X25519, and X448 for Use in the Internet X.509 Public Key Infrastructure. RFC 8410. RFC Editor. https://www.rfc-editor.org/info/rfc8410. §4's encoding of an Ed25519 public key, which is why the tls.zig fork can check a raw key by comparing a fixed twelve-octet prefix rather than parsing ASN.1.

Mockapetris, P. 1987. Domain names — implementation and specification. RFC 1035. RFC Editor. https://www.rfc-editor.org/info/rfc1035. Not part of the transport at all. §4.1.4's name compression is stepped over by the example DNS-over-QUIC client in src/main.zig, which has to get past the names in an answer to read the address after them.

License

MIT. See LICENSES/MIT.txt. The project follows the REUSE specification, so every file carries its own copyright and license information.