- Zig 94.5%
- Nix 3.3%
- Python 0.9%
- Rust 0.7%
- C 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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 |
||
| .forgejo/workflows | ||
| docs | ||
| LICENSES | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
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 ordinarystd.Io.Readerandstd.Io.Writer. A server is anEndpointand a callback; a client is aClientand aconnect. Start here.quic— the protocol itself, sans-I/O: aConnectionis 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
- Requirements
- Adding it to a project
- A server
- A client
- Using
quic_io - Using the sans-I/O core
- Example programs
- Interoperability
- Limitations
- Development
- Where this lives
- References cited
- License
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.0and kept on thezig-0.16branch. - 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. Thequicmodule 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()andsession.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 aboutsend_windowper round trip. Raise it for bulk transfers.tracked— how many unacknowledged packets are remembered. It must be at leastsizes.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 withinhandshake_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_keyderives 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_keyseals Retry tokens.require_retrymakes 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_keyturns on session resumption. A client that saved a ticket passes it asClient.Options.ticketand skips the certificate exchange. Tickets are sealed with this key, so the server stores nothing per client.early_dataaccepts 0-RTT data from resuming clients (early_dataon 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,sizesandmax_send_windowset 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, andsession.requestImmediateAck()asks for an acknowledgement now. - Multipath:
initial_max_path_idof 1 or more, andsizes.pathsof at least two. A client opens a path from a second socket withclient.openPath(&socket), waits forclient.pathReady(opened), and moves traffic withsession.useSendPathorsession.setScheduler. - NAT traversal:
n0_nat_traversal, the number of candidate addresses to accept. Each end offers its addresses withsession.addNatAddress, and the client starts hole punching withsession.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.ecnreads 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.mdcovers 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.