flare_mlake · API · tls

flare.tls.rustls_quic

flare.tls.rustls_quic -- rustls QUIC binding surface.

QUIC's TLS shape is fundamentally different from TLS-over-TCP: the handshake runs inside QUIC frames, keys are derived per encryption level (Initial / Handshake / 1-RTT / 0-RTT), and the API the TLS library exposes is record-shaped rather than byte-stream-shaped. See docs/tls-strategy.md for the full rationale on why flare's QUIC path uses rustls instead of extending the OpenSSL FFI for QUIC: the BoringSSL- shape QUIC API is what the broader ecosystem (quiche, ngtcp2, lsquic, msquic) standardized on, and rustls carries that API natively (rustls::quic::ServerConnection).

Module surface

This module declares:

  • :class:RustlsQuicConfig -- server-side acceptor config (certificate chain, private key, ALPN list). Owns the configuration the Rust crate reads through the C ABI.
  • :class:RustlsQuicAcceptor -- factory for per-connection TLS sessions. Conceptually parallel to :class:flare.tls.acceptor.TlsAcceptor, but produces QUIC sessions rather than TCP TLS streams.
  • :class:RustlsQuicSession -- per-connection rustls handle. The QUIC reactor feeds it CRYPTO-frame bytes per encryption level and pulls handshake output bytes plus derived keys back out.
  • :class:RustlsQuicError -- typed error carrier for the cases the reactor must distinguish (handshake-incomplete, protocol violation, certificate rejected, internal error).

Both RustlsQuicAcceptor and RustlsQuicSession are Movable (not Copyable) because they own a Rust-allocated Box<Acceptor> / Box<Session> respectively; copy would double-free on drop. The carriers route every FFI call through read lib borrow helpers (see flare/tls/_rustls_quic_ffi.mojo) so Mojo's ASAP destructor cannot unmap libflare_rustls_quic.so between get_function and the call.

References:

  • RFC 9001 "Using TLS to Secure QUIC".
  • RFC 8446 "The Transport Layer Security (TLS) Protocol Version 1.3".
  • BoringSSL QUIC API conventions (the shape rustls implements).

Structs

QuicEncryptionLevel

struct QuicEncryptionLevel

RFC 9001 §4.1 packet protection levels.

Each level has its own set of secret-keyed AEAD keys derived by the TLS handshake. The rustls QUIC binding emits CRYPTO frames at one level at a time and returns the derived keys when each level transitions into "ready".

Implements Movable

comptime INITIAL = Int(0)

RFC 9001 §4.1 -- keyed by the QUIC v1 initial salt mixed with the client's Destination Connection ID. Used for the first client-hello flight.

comptime EARLY_DATA = Int(1)

RFC 9001 §4.1 -- 0-RTT keys. Captured from rustls via :meth:RustlsQuicSession.install_early_keys (which wraps Connection::zero_rtt_keys()) rather than the per-level KeyChange pump; the AEAD + header-protection thunks accept this level for the 0-RTT send/recv path on a resumed connection.

comptime HANDSHAKE = Int(2)

RFC 9001 §4.1 -- handshake keys derived after the server accepts the client's Initial. Used for ServerHello and EncryptedExtensions.

comptime APPLICATION = Int(3)

RFC 9001 §4.1 -- 1-RTT keys. Used for all post-handshake application traffic; this is the keyed level the H3 server will see for every request.

RustlsQuicConfig

struct RustlsQuicConfig

Server-side rustls QUIC configuration carrier.

Mirrors the shape of :class:flare.tls.config.TlsConfig but targets the rustls QUIC backend. Fields are owned by Mojo; the Rust crate reads them through the C ABI at acceptor construction time and never mutates them.

The actual configuration that rustls would consume is built inside :class:RustlsQuicAcceptor.__init__ -- this struct is the inputs.

Implements Copyable, Defaultable, Movable

Fields
cert_chain_pem: String: PEM-encoded server certificate chain (leaf cert plus any intermediates). Empty string is invalid; the Rust crate will reject construction.
private_key_pem: String: PEM-encoded server private key (PKCS#8). RFC 9001 §4.6 says only TLS 1.3 is supported for QUIC; the Rust crate will reject non-TLS-1.3-compatible keys.
alpn_protocols: List[String]: ALPN protocol identifiers the server is willing to negotiate (RFC 7301). For HTTP/3 this should include "h3" (RFC 9114 §3.1). Order matters -- earlier entries are preferred.
max_early_data_size: UInt32: Maximum 0-RTT data the server will accept. Set to 0 to disable 0-RTT (the default). A non-zero value switches the server to STATEFUL resumption (an in-memory session cache, not a stateless ticket) because RFC 8446 sec 8.1 forbids 0-RTT with stateless tickets; rustls then advertises early data in the issued NewSessionTicket. 0-RTT replay protection beyond the rustls single-use window is out of scope for this cycle; see RFC 9001 sec 9.2.
session_resumption_enabled: Bool: Whether to issue NewSessionTicket frames for session resumption. Default is True for production parity with OpenSSL acceptor.

__init__

def __init__(out self)

RustlsQuicError

struct RustlsQuicError

Typed error carrier for the rustls QUIC binding.

The reactor distinguishes these cases for connection-close reason mapping (RFC 9000 §10.2 -- CONNECTION_CLOSE frame types and reasons). String reason is for logs only.

Implements Copyable, Movable

Fields
kind: Int: One of the :class:RustlsQuicErrorKind codepoints.
reason: String: Human-readable reason string for logs and the CONNECTION_CLOSE reason phrase.

__init__

def __init__(out self, kind: Int, reason: String)

not_built

def not_built() -> Self

The Rust crate is not built; the reactor should treat this as a configuration error (not a per-packet failure). The build_rustls.sh activation script wired into pixi's [activation.scripts] auto-builds the crate on pixi install; this carrier exists for the rare path where activation didn't run (e.g. a bare mojo invocation outside a pixi shell).

RustlsQuicErrorKind

struct RustlsQuicErrorKind

RFC 9000 §20.2 + RFC 9001 §4.8 cryptographic-error enumeration plus the local "not built" sentinel.

Implements Movable

comptime NOT_BUILT = Int(0)

The Rust crate is not built. Returned by the constructor when the FFI symbol lookup fails (the activation script didn't run); resolved by re-running pixi install.

comptime HANDSHAKE_INCOMPLETE = Int(1)

The session needs more CRYPTO frame bytes before it can advance. Reactor should keep feeding bytes; not a real error from the connection's perspective.

comptime PROTOCOL_VIOLATION = Int(2)

The peer violated the TLS 1.3 wire grammar or the QUIC transport-parameter encoding. Maps to PROTOCOL_VIOLATION (0x0a) in CONNECTION_CLOSE.

comptime CERTIFICATE_INVALID = Int(3)

The server's certificate chain failed validation (only meaningful for client-side mTLS, which is the v0.10 line item -- this exists to keep the enum complete).

comptime INTERNAL_ERROR = Int(4)

An internal Rust panic crossed the FFI boundary, or the C ABI returned an unexpected return code. Maps to INTERNAL_ERROR (0x01) in CONNECTION_CLOSE.

RustlsQuicAcceptor

struct RustlsQuicAcceptor

Factory for per-connection rustls QUIC sessions.

Long-lived. One instance per QUIC listener, shared across every connection it accepts. The actual rustls rustls::quic::ServerConfig lives behind a heap-allocated Rust Box<Acceptor>; this carrier holds the raw pointer (as Int) plus the loaded libflare_rustls_quic.so handle.

Constructed lifecycle:

  • __init__ calls flare_rustls_quic_acceptor_new with the PEM cert + key + wire-format ALPN list. Returns a carrier whose _opaque_handle is 0 when the FFI rejects the input (e.g. empty / malformed PEM). The reactor and per-connection accept() path treat a 0 handle as a configuration error and bounce every connection immediately, surfacing the rustls last-error message via RustlsQuicError.
  • __deinit__ calls flare_rustls_quic_acceptor_free to release the Rust-side Box<Acceptor> (no-op on a 0 handle).
  • accept(dcid) calls flare_rustls_quic_accept to construct a fresh per-connection rustls::quic::ServerConnection and wraps it in :class:RustlsQuicSession.

Implements Movable

Fields
config: RustlsQuicConfig

__init__

def __init__(out self, var config: RustlsQuicConfig)

Construct an acceptor from the supplied config.

Does not raise on FFI rejection: the carrier always constructs (with _opaque_handle == 0 on failure) so the reactor can surface the rustls last-error message via :class:RustlsQuicError rather than a partial-construction half-state.

__deinit__

def __deinit__(deinit self)

free_session

def free_session(self, handle: Int)

Release a per-connection rustls session previously produced by :func:flare.tls._rustls_quic_ffi._do_accept through the acceptor's pinned library handle.

NULL is a no-op. Used by :class:flare.quic.server.QuicListener to drop every session in its slab from inside the listener's destructor without sub-field access on self.tls_acceptor._lib (which Mojo's deinit ordering rule forbids).

accept

def accept(self, dst_cid: List[UInt8]) -> RustlsQuicSession

Create a new per-connection session bound to the client's Destination Connection ID.

The reactor calls this once per connection after parsing the first Initial packet. The DCID is required because the rustls binding uses it (via the QUIC transport parameters extension) to bind initial-secret derivation to the per-connection identity (RFC 9001 §5.2).

RustlsQuicConnector

struct RustlsQuicConnector

Client-role factory for per-connection rustls QUIC sessions.

The mirror of :class:RustlsQuicAcceptor: long-lived, one instance per HttpClient h3 origin policy (trust roots + ALPN), reused across every QUIC connection the client opens. Wraps a heap-allocated Rust Box<Connector> (the rustls ClientConfig) behind a raw pointer carried as Int plus the pinned libflare_rustls_quic.so handle.

Lifecycle mirrors the acceptor: __init__ -> flare_rustls_quic_connector_new (0 handle on PEM/ALPN failure, surfaced by :meth:connect); __deinit__ -> flare_rustls_quic_connector_free; :meth:connect -> flare_rustls_quic_connect returning a :class:RustlsQuicSession (role-agnostic on the Mojo side).

Implements Movable

Fields
alpn_protocols: List[String]: ALPN identifiers advertised to the origin ("h3" for HTTP/3). Order is preference order, as the acceptor's list.

__init__

def __init__(out self, ca_pem: String, var alpn_protocols: List[String])

Build a connector from a trust-anchor PEM bundle + ALPN list.

ca_pem is a PEM bundle of trusted roots; for a loopback link to flare's own server, pass that server's self-signed cert (a self-signed leaf is its own root). Does not raise on PEM-parse failure -- the carrier constructs with a 0 handle and :meth:connect surfaces the rustls last-error -- so a configuration mistake is one clear error at connect time, not a partial-construction half-state. Raises only on an invalid ALPN list (empty or >255-byte protocol).

def __init__(out self, var alpn_protocols: List[String], use_system_roots: Bool)

Build a connector trusting the operating system's CA bundle (loaded at runtime via rustls-native-certs) instead of a PEM. use_system_roots distinguishes this overload from the PEM constructor. Mirrors the PEM path: a 0 handle (no native roots found / none usable) is surfaced at :meth:connect time, not at construction. Raises only on an invalid ALPN list.

__deinit__

def __deinit__(deinit self)

with_system_roots

def with_system_roots(var alpn_protocols: List[String]) -> Self

Build a connector trusting the OS CA bundle (rustls-native-certs) -- the public-internet h3 client path where there is no PEM to ship. Equivalent to the use_system_roots=True constructor; provided as a named factory for an ergonomic call site.

free_session

def free_session(self, handle: Int)

Release a per-connection session through the connector's pinned library handle (mirror of :meth:RustlsQuicAcceptor.free_session). NULL is a no-op.

connect

def connect(self, server_name: String, transport_params: List[UInt8] = List()) -> RustlsQuicSession

Open a client-role session against server_name (SNI).

transport_params is the client's encoded QUIC transport parameters (empty is accepted for the handshake-only path; the QUIC client driver fills the real blob). The returned :class:RustlsQuicSession drives the client handshake through the same feed/take CRYPTO + AEAD + header thunks the server session uses; the first take_crypto(INITIAL) drains the ClientHello.

RustlsQuicSession

struct RustlsQuicSession

Per-connection rustls handle.

The reactor's per-connection state machine drives this:

  1. Feed inbound CRYPTO frame bytes via :meth:feed_crypto.
  2. Pull outbound CRYPTO frame bytes via :meth:take_crypto.
  3. :meth:is_handshake_complete returns True once the 1-RTT keys are derived; from there the application can send data on streams.
  4. :meth:selected_alpn returns the negotiated ALPN identifier (e.g. "h3") so the reactor can dispatch to the right application-layer driver.

Construction:

  • Direct RustlsQuicSession(dcid) builds a carrier with a 0 handle; every FFI-touching method then raises with a clear "NULL session" message. Tests use this shape to exercise the failure path without needing a real acceptor.
  • The production path uses :meth:RustlsQuicAcceptor.accept which calls into the RustlsQuicSession._wrap private constructor with a real Rust-side Box<Session>*.

Implements Movable

Fields
dst_cid: List[UInt8]: The DCID this session was created for. Carried so the reactor can sanity-check key derivation later.

__init__

def __init__(out self, dst_cid: List[UInt8])

Build a session carrier with a NULL handle. Every FFI-touching method raises on a NULL handle.

Useful for testing the level machine + DCID round-trip without a real acceptor; the production path uses :meth:RustlsQuicAcceptor.accept.

def __init__(out self, var lib: OwnedDLHandle, handle: Int, var dst_cid: List[UInt8])

Internal: wrap a real Rust-side Box<Session>* that :meth:RustlsQuicAcceptor.accept just produced.

Each session gets its own OwnedDLHandle so the .so refcount stays high until the session drops; on Linux the -z nodelete link flag is the additional belt-and-suspenders pin.

__deinit__

def __deinit__(deinit self)

feed_crypto

def feed_crypto(mut self, level: Int, data: List[UInt8])

Feed inbound CRYPTO frame bytes at level.

The reactor calls this after dispatching a CRYPTO frame out of a packet at the matching encryption level. The data buffer is a contiguous chunk; the rustls side reassembles fragments internally.

take_crypto

def take_crypto(self, level: Int) -> List[UInt8]

Drain pending outbound CRYPTO frame bytes at level.

Returns an empty list when no bytes are pending. The reactor packages the result into CRYPTO frames inside packets at the matching encryption level.

is_handshake_complete

def is_handshake_complete(self) -> Bool

Whether the 1-RTT keys are derived. Returns False on a NULL session (test path) or while handshaking; True after rustls flips into the application-keyed state.

selected_alpn

def selected_alpn(self) -> String

ALPN identifier the rustls side picked.

Returns the negotiated identifier from the ALPN list passed at config time (e.g. "h3"). The reactor uses this to dispatch to the H3 server vs an alternative application protocol over QUIC.

peer_transport_params

def peer_transport_params(self) -> List[UInt8]

The peer's raw quic_transport_parameters extension bytes (RFC 9000 §7.4), or an empty list if rustls has not yet surfaced them.

rustls only exposes these once the carrying handshake flight has been processed (the server's params reach the client after EncryptedExtensions; the client's reach the server after the ClientHello). Poll after the handshake completes for a stable result, then decode with :func:flare.quic.transport_params.decode_transport_parameters to apply the peer's flow-control and CID limits. Returns an empty list on a NULL session (test path).

current_level

def current_level(self) -> Int

Current outbound encryption level. Useful for tests confirming the level machine compiles.

have_keys

def have_keys(self, level: Int) -> Bool

Whether rustls has installed per-level keys at the given encryption level (2 = Handshake, 3 = 1-RTT).

Initial-level keys never flow through rustls's KeyChange (they derive from the connection ID per RFC 9001 §5.2 and the flare side runs Initial AEAD through :class:flare.quic.crypto.OpenSslQuicCrypto), so this returns False for level 0. The reactor polls :meth:have_keys after every :meth:take_crypto pump to learn when to install Handshake / 1-RTT keys onto the owning :class:flare.quic.server.QuicConnection.

NULL session handles (the test-only constructor path) return False without raising.

install_early_keys

def install_early_keys(self) -> Bool

Capture rustls's 0-RTT (EarlyData) keys into the session, if available (RFC 9001 §4.1; wraps Connection::zero_rtt_keys()).

On the client this returns True right after :meth:RustlsQuicConnector.connect IFF the connector's session store held a ticket for the SNI (a resumed connection) and early data is enabled; on the server it returns True once the resumed ClientHello has been fed and 0-RTT was accepted. After True, the :data:QuicEncryptionLevel.EARLY_DATA AEAD + header thunks encrypt/decrypt 0-RTT packets. Returns False on a NULL session (test path) or when rustls has no 0-RTT keys for this connection (the common first-flight case).

is_early_data_accepted

def is_early_data_accepted(self) -> Bool

Whether the server signalled it will process the client's 0-RTT data (RFC 8446 §4.2.10; client-role only).

A False after the handshake completes means the server rejected early data and the client must replay it in 1-RTT. Returns False on a NULL session or for the server role.

packet_encrypt

def packet_encrypt(self, level: Int, packet_number: UInt64, header: List[UInt8], mut payload: List[UInt8]) -> List[UInt8]

Encrypt payload in place at level via rustls's Keys.local.packet.encrypt_in_place (RFC 9001 §5.3).

header is the QUIC packet header (used as AEAD AAD); the returned tag is the 16-byte authentication tag the caller appends to the on-wire packet. Raises if the FFI rejects (e.g. keys not yet installed at level); the rustls error text is in :func:_do_last_error.

packet_decrypt

def packet_decrypt(self, level: Int, packet_number: UInt64, header: List[UInt8], mut payload: List[UInt8]) -> Int

Verify + strip the AEAD tag from payload in place at level via rustls's Keys.remote.packet.decrypt_in_place (RFC 9001 §5.3).

Returns the plaintext length (always len(payload) - 16 for the AEAD-GCM / ChaCha20-Poly1305 suites rustls speaks); raises if rustls rejects the tag (the typical wrong-keys- at-this-level symptom).

packet_decrypt_1rtt

def packet_decrypt_1rtt(self, key_phase: Int, packet_number: UInt64, header: List[UInt8], mut payload: List[UInt8]) -> Int

Open a 1-RTT payload in place under the header's key phase bit (RFC 9001 sec 6.3). Returns 1 when the peer started a key update and the keys rotated, else 0; raises on failure.

key_update

def key_update(self) -> Int

Start a 1-RTT key update (RFC 9001 sec 6.1); returns the new key phase bit.

header_encrypt

def header_encrypt(self, level: Int, sample: List[UInt8], first_byte_addr: Int, pn_addr: Int, pn_len: Int)

Apply QUIC header protection (RFC 9001 §5.4) to the packet's first byte + packet-number bytes via rustls's Keys.local.header.encrypt_in_place.

sample MUST be a 16-byte slice of the encrypted payload (4 bytes past the start of the packet-number field). first_byte_addr / pn_addr are raw pointer values (as Int) so the caller can keep the byte cells on the stack and let rustls write through.

header_decrypt

def header_decrypt(self, level: Int, sample: List[UInt8], first_byte_addr: Int, pn_addr: Int, pn_len: Int)

Remove QUIC header protection (RFC 9001 §5.4) from the packet's first byte + packet-number bytes via rustls's Keys.remote.header.decrypt_in_place. Same sample contract as :meth:header_encrypt.