QUIC / HTTP/3
Crates hopf-quic and hopf-http (feature h3): a fully in-tree QUIC transport (RFC 9000/9001/9002) driven by a dedicated mio UDP thread, sharing the same TLS 1.3 handshake engine TCP TLS uses. HTTP/3 codecs reuse the same Stream SPI as H1/H2.
Contents
Architecture
UDP socket ── QuicDriverHandle (dedicated hopf-quic thread)
│
├── plain mode: one ProtocolHandler per bi-stream
│ (QuicListenConfig / QuicConnectConfig)
│
└── hooks mode: QuicConnection owns control/uni/bi
(used by listen_h3 / connect_h3)
│
▼
H3ServerConnection / H3ClientConnection
│
▼
ServerHandler / ClientHandler (hopf-http)
Each bidirectional QUIC stream is a QuicStreamEndpoint implementing hopf_core::Endpoint. HTTP/3 framing lives in hopf-http, not in hopf-quic.
TLS 1.3 on QUIC
- TLS 1.3 only (RFC 9001). Every QUIC TLS config builds a
hopf_core::tls::HandshakeConfigwithHandshakeMode::Quic— the same in-tree TLS 1.3 engine TCP TLS uses. There's no TLS 1.2 mode for QUIC at all, so the question of negotiating down never arises. - Key exchange is classical X25519 by default; hybrid PQC key exchange is opt-in via
QuicTlsOptions::with_pqc()(orwith_kx_policy(KxPolicy::…)) on both peers. The default stays classical because the ML-KEM key share makes the ClientHello span two Initial datagrams. See Conformance → QUIC.
Standards
| Spec | Notes |
|---|---|
| RFC 9000 family | QUIC via an in-tree transport (hopf-quic) |
| TLS 1.3 only | In-tree handshake engine (shared with TCP TLS); no TLS 1.2 mode on QUIC. Key exchange is classical X25519 only — not PQC-first, unlike TCP TLS 1.3 |
ALPN h3 |
hopf_quic::ALPN_H3 = b"h3" |
| RFC 9114 / 9204 | HTTP/3 + QPACK in hopf-http |
| 0-RTT / early data | Off by default; opt in with QuicTlsOptions::with_early_data on *_with PEM builders. Plain client dials open the first bi stream immediately when has_0rtt() (reuse the same ClientConfig Arc so tickets stick). H3 hooks still wait for Event::Connected |
QuicListen / QuicConnect configs
Plain bi-stream mode
| Type | Fields | Purpose |
|---|---|---|
QuicListenConfig |
addr, server: Arc<QuicServerConfig>, factory: HandlerFactory, hardening: QuicListenHardening |
UDP listen; one ProtocolHandler per accepted bi-stream. Hardening defaults to QuicListenHardening::high_security() (Retry before handshake) |
QuicConnectConfig |
addr, client: Arc<QuicClientConfig>, server_name, factory |
Dial; factory for the first bi-stream |
QuicListenHooksConfig |
addr, server, connection_factory: ConnectionFactory, hardening: QuicListenHardening |
Connection-level hooks (H3); same hardening default as plain listen |
QuicListenHardening |
require_address_validation, Incoming caps, Retry/NEW_TOKEN lifetimes, migration |
RFC 9000 §8 listen hardening. high_security() (default) requires a Retry round trip before accept, caps unfinished handshakes, disables migration; permissive() accepts without Retry |
Constructors:
use hopf_quic::{QuicConnectConfig, QuicListenConfig, QuicListenHardening, QuicListenHooksConfig};
QuicListenConfig::new(addr, server_cfg, factory);
QuicListenConfig::new(addr, server_cfg, factory)
.with_hardening(QuicListenHardening::permissive()); // lab / trusted path
QuicConnectConfig::new(addr, client_cfg, "localhost", factory);
QuicListenHooksConfig::new(addr, server_cfg, connection_factory);Port 0 binds an ephemeral UDP port. Addresses must be pre-resolved (SocketAddr) — Stage 0. Listeners apply apply_listen_hardening to the server config at bind time; with the default high-security policy, unvalidated Initials receive a Retry packet before any TLS handshake state is allocated.
Driver APIs
| Function | Returns |
|---|---|
listen_quic(QuicListenConfig) |
io::Result<QuicDriverHandle> |
connect_quic(QuicConnectConfig) |
io::Result<QuicDriverHandle> |
listen_quic_hooks(QuicListenHooksConfig) |
io::Result<QuicDriverHandle> |
connect_quic_hooks(addr, client, server_name, ConnectionFactory) |
io::Result<QuicDriverHandle> |
QuicDriverHandle exposes local_addr and shutdown(self). Drop or shut down explicitly — the driver thread is independent of Runtime lifetime.
Hooks traits
| Type | Role |
|---|---|
QuicConnection |
connected, accept_bi, accept_uni |
QuicConnApi |
open_uni, open_bi, write, finish |
ConnectionFactory |
Arc<dyn Fn() -> Box<dyn QuicConnection> + Send + Sync> |
PEM builders
All builders pin TLS 1.3 only and take an ALPN list. Crypto is always the shared aws-lc-rs provider with hybrid-first PQC KX (X25519MLKEM768) — see PQC-first TLS 1.3.
| Function | Result |
|---|---|
server_config_from_pem(cert, key, alpn) |
Arc<QuicServerConfig> |
client_config_from_pem(ca, alpn) |
Arc<QuicClientConfig> trusting PEM roots |
server_config_self_signed(&[names], alpn) |
(Arc<QuicServerConfig>, leaf_pem: String) |
client_config_for_certified_pem / client_config_for_pem_bytes |
Trust a single certified leaf (smoke / demo) |
QuicServerConfig/QuicClientConfig are hopf's own in-tree config types (hopf-quic/src/config.rs).
use hopf_quic::{server_config_from_pem, client_config_from_pem, ALPN_H3};
use std::path::Path;
let server = server_config_from_pem(
Path::new("cert.pem"),
Path::new("key.pem"),
&[ALPN_H3],
)?;
let client = client_config_from_pem(Path::new("ca.pem"), &[ALPN_H3])?;listen_h3 / connect_h3
Provided by hopf-http under feature h3. Internally install H3ServerConnection / H3ClientConnection via the hooks driver.
use hopf_http::{connect_h3, listen_h3, HttpLimits};
use hopf_quic::{server_config_self_signed, client_config_from_pem, ALPN_H3};
use std::sync::Arc;
// Server
let (server_cfg, leaf_pem) = server_config_self_signed(&["localhost"], &[ALPN_H3])?;
let listen = listen_h3(
"127.0.0.1:4433".parse()?,
server_cfg,
server_factory,
HttpLimits::default(),
)?;
// Client
let client_cfg = client_config_from_pem(std::path::Path::new("leaf.pem"), &[ALPN_H3])?;
let dial = connect_h3(
"127.0.0.1:4433".parse()?,
client_cfg,
"localhost",
client_factory,
HttpLimits::default(),
)?;On connect, the H3 client opens control + QPACK uni streams, then one bi-stream for the application request (ClientHandler::start). The control-stream SETTINGS frame advertises SETTINGS_QPACK_MAX_TABLE_CAPACITY (4096), SETTINGS_QPACK_BLOCKED_STREAMS (0), and Extended CONNECT; the encoder stays at capacity 0 until the peer's SETTINGS arrive, then grows to at most that advertised ceiling.
Each H3 request maps to a QUIC-stream Endpoint driving the same ServerHandler / ClientHandler API as H1/H2.
QUIC connection and stream teardown mirrors Gumdrop: a peer application or transport CONNECTION_CLOSE, idle timeout, or inbound STOP_SENDING reaches the stream's ProtocolHandler::error with QuicConnectionCloseError / QuicStreamStoppedError (downcast helpers connection_close_error / stream_stopped_error). Clean local shutdown and graceful stream FIN still call disconnected.
ALPN and TLS 1.3
| Constant / rule | Value |
|---|---|
ALPN_H3 |
b"h3" |
| Protocol versions | TLS 1.3 only — no TLS 1.2 mode exists on QUIC |
| Key exchange | Classical X25519 only today — not PQC-first, unlike TCP TLS 1.3 (see Conformance → QUIC) |
| Early data | Off by default; opt in with QuicTlsOptions::with_early_data |
Do not pass TCP ALPN lists (h2, http/1.1) into QUIC H3 configs.
Runtime extension
RuntimeQuicExt adds convenience methods on Runtime:
add_quic_listeneradd_quic_listener_hooksconnect_quic
QUIC I/O still runs on the dedicated hopf-quic thread; these helpers do not move UDP onto the TCP worker reactors.
Examples
| Example | Command |
|---|---|
http3-hello |
cargo run -p http3-hello -- 127.0.0.1:4433 |
http-get --http3 |
cargo run -p http-get -- --http3 --ca <pem> 127.0.0.1:4433 / |
Smokes: cargo test -p hopf-quic --features integration, cargo test -p hopf-http --features h3 h3_get_hello.
QUIC versions
hopf-quic speaks QUIC version 1 (RFC 9000) and version 2 (RFC 9369). Version 2 changes only the version number, the Initial salt, the HKDF labels, the long-header type bits and the Retry key, so HTTP/3 (h3) and DNS over QUIC (doq) run over it unchanged.
use hopf_quic::QuicVersion::{V1, V2};
// A listener accepts both by default; restrict it if you need to.
server_cfg.versions(&[V1, V2]); // QuicServerConfig (or Arc::make_mut on the Arc the builders return)
// A client speaks version 1 unless told otherwise. The first entry opens the
// connection; if the server answers with Version Negotiation the client
// restarts in the first listed version the server offers.
client_cfg.versions(&[V2, V1]);
- Dual stack. A client preferring v2 that meets a v1-only server receives a Version Negotiation packet and completes the handshake in v1 after one extra round trip. A client with no version in common abandons the attempt.
- Downgrade protection. Both endpoints exchange and validate the
version_informationtransport parameter (RFC 9368), so a forged Version Negotiation packet cannot silently force a v2-capable client down to v1. - Tickets. Session tickets (and so 0-RTT) are per version: a ticket earned over v1 is never offered on a v2 connection and vice versa.
- Not implemented. Compatible version negotiation (RFC 9368 section 2.3), where a server upgrades a v1 first flight to v2 without a round trip. A client that wants v2 should ask for it in its first flight.
QUIC-LB connection IDs
A load balancer that forwards UDP by Destination Connection ID needs to find the owning backend from the CID alone, so a datagram keeps reaching the same server when the client's address changes. hopf-quic can issue such IDs following draft-ietf-quic-load-balancers-21 (revision 21; the encoder lives in one module, transport/quic_lb.rs, so a later revision touches only that file).
use hopf_quic::{apply_server_quic_lb, QuicLbConfig};
// Same config ID, key and nonce length on every backend and on the balancer;
// only the server ID differs per backend.
let lb = QuicLbConfig::new(/* config id 0-6 */ 1, &[0xa1, 0x01], /* nonce */ 6)?
.with_key(shared_16_byte_key)?;
let (mut server_cfg, _pem) = server_config_self_signed(&["example.test"], &[b"h3"])?;
apply_server_quic_lb(&mut server_cfg, &lb); // or server_cfg.quic_lb(&lb) on a QuicServerConfig
listen_h3(addr, server_cfg, factory, limits)?; // HTTP/3 takes the same Arc<QuicServerConfig>
- Encodings. Without a key the server ID is sent in the clear (linkable across migration). With a 16-octet AES-128 key it is encrypted: one AES block when server ID and nonce total exactly 16 octets, otherwise the draft's four-round Feistel network. Server ID (at least 1 octet) and nonce (at least 4) total at most 19 octets, so the CID is at most 20. Optional length self-description (
with_length_self_description) fills the first octet's low bits with the CID length; otherwise they are random. - Where it applies. The Initial response's source CID and the Retry source CID (Retry is on by default under
QuicListenHardening::high_security). Nonces count up from a random start and are never reused; on exhaustion the server falls back to the reserved unroutable0b111codepoint, as the draft requires. In plaintext mode the nonce is random, and the endpoint redraws on a clash with a live connection and refuses an accept whose fixed post-Retry CID clashes. - Shared state. None beyond the config: the balancer decodes the server ID with
QuicLbConfig::decode_server_id's logic and forwards; backends keep their own connection tables. Clones of a server config share one nonce sequence. - Default. Without
quic_lb, connection IDs remain 8 random octets.connection_id_generatoraccepts any customConnectionIdGenerator. - Not covered. Terminating QUIC or TLS at the balancer; the draft's optional extra server-owned CID octets;
NEW_CONNECTION_IDissuance, because this stack does not issue additional CIDs yet (connection migration itself is still structurally inert, see Conformance), so only the Initial and Retry source CIDs are QUIC-LB encoded.
Limitations
QuicStreamEndpoint::start_tlsreturnsStartTlsError::Unsupported(QUIC is already TLS 1.3).- Driver thread is not a TCP worker reactor; shut down / drop
QuicDriverHandleindependently ofRuntime. - Addresses must be pre-resolved (no DNS inside
connect_quic/connect_h3). - Plain (non-hooks) server mode installs
NopHandleron accepted unidirectional streams. - No HTTP/2-style server push; no WebTransport / CONNECT-UDP product API yet (RFC 9297 HTTP Datagrams + Capsule Protocol plumbing is present).
- 0-RTT is off by default; enable via
QuicTlsOptions::with_early_data(replay risk — see SECURITY.md). Plainconnect_quicopens the first stream beforeEvent::Connectedwhen a session ticket makeshas_0rtt()true; H3 hooks-mode still starts after the full handshake. - Locally-initiated connection close (
LocallyClosed) still callsProtocolHandler::disconnectedon this endpoint's streams; only the peer seesQuicConnectionCloseErrorviaerror. Use the return value ofclose_connectionlocally if you need the code you just sent. - Connection migration (RFC 9000 §9) is not implemented — a connection keeps the remote address it was created with for its whole lifetime, and there's no
NEW_TOKEN(§8.1.3) support either, only Retry-based address validation (§8.1.2). See Conformance → QUIC for the specifics.
Implementation status
Shipped: QUIC transport (RFC 9000 loss recovery and congestion control, RFC 9001 packet protection) and the TLS 1.3 handshake are both in-tree, with no external QUIC or TLS library dependency — the handshake is driven by the same engine TCP TLS 1.3 uses. HTTP/3 codecs and QPACK in hopf-http (feature h3) are Hopf-owned. Hopf adds the mio UDP driver, stream-as-Endpoint model, and hooks-mode connection API for H3.
Known gaps: connection migration and NEW_TOKEN address validation aren't implemented. Hybrid PQC key exchange is opt-in rather than default — see Conformance → QUIC for the full, row-by-row detail.
MASQUE: RFC 9298 CONNECT-UDP (full server relay + client) and RFC 9484 CONNECT-IP (accept/capsule plumbing; the application supplies IP forwarding) live in hopf-masque, over HTTP/1.1, HTTP/2, and HTTP/3 alike — not H3-only. See masque.html and Conformance → MASQUE.
See also
- HTTP overview
- HTTP server
- HTTP client
- TLS — TCP TLS helpers (same in-tree engine; QUIC is the 1.3-only subset)
- Architecture