flare_mlake · API · quic

flare.quic.crypto

QUIC v1 crypto primitives (RFC 9001).

Sans-I/O. Every entry point is a pure function over byte spans; no socket, no fd, no TLS context, no allocator that survives the call. The QUIC server reactor plumbs the AEAD output into the packet codec; this module owns the math.

Layering

The full QUIC crypto story has three slabs that compose:

  1. HKDF (RFC 5869) -- key derivation. Implemented here over the hmac_sha256 primitive that flare.crypto already exports from the OpenSSL FFI. Pure math; no AEAD dependency.

  2. HKDF-Expand-Label (RFC 8446 §7.1) -- the TLS 1.3 label wrapper around HKDF-Expand. RFC 9001 §5.1 reuses TLS 1.3's schedule with QUIC-specific labels (quic key, quic iv, quic hp, quic ku). Implemented here.

  3. AEAD packet protection (RFC 9001 §5.3-5.4) -- AES-128-GCM / AES-256-GCM / ChaCha20-Poly1305 encrypt-with-AD over each packet's frame contents, plus the AES-128-CTR-based header protection mask (§5.4) that hides the packet number and the four header low bits. The trait :trait:QuicCrypto names the surface a backend must implement; :class:OpenSslQuicCrypto wires that surface to the OpenSSL FFI for h3 server connections (the OpenSSL-backed packet protection path), and the rustls binding under :mod:flare.tls.rustls_quic drives the TLS exchange itself.

Backends
  • :class:StubQuicCrypto -- typed sentinel for tests that want to assert the trait boundary without driving a real AEAD. Encrypt / decrypt / mask raise NotImplemented.
  • :class:OpenSslQuicCrypto -- production AEAD + header- protection backend. Binds the flare_quic_aead_* / flare_quic_hp_mask FFI thunks from libflare_tls.so to the trait surface. Constructs from a 32-byte direction secret via :py:meth:OpenSslQuicCrypto.from_secret, which runs the RFC 9001 paragraph 5.1 schedule.

References:

  • RFC 5869 "HMAC-based Extract-and-Expand Key Derivation Function".
  • RFC 8446 "The Transport Layer Security (TLS) Protocol Version 1.3".
  • RFC 9000 "QUIC: A UDP-Based Multiplexed and Secure Transport".
  • RFC 9001 "Using TLS to Secure QUIC".

Structs

InitialSecrets

struct InitialSecrets

Per-direction initial-secret pair derived from the client's Destination Connection ID per RFC 9001 §5.2.

Each secret is 32 bytes (SHA-256 hash output). The packet- protection keys (quic key / quic iv / quic hp) are derived from each secret independently via HKDF-Expand-Label.

Implements Copyable, Movable

Fields
initial_secret: List[UInt8]: HKDF-Extract(QUIC_V1_INITIAL_SALT, dst_cid) -- 32 bytes.
client_initial_secret: List[UInt8]: HKDF-Expand-Label(initial_secret, "client in", "", 32).
server_initial_secret: List[UInt8]: HKDF-Expand-Label(initial_secret, "server in", "", 32).

QuicAead

struct QuicAead

RFC 9001 §5.3 AEAD selector codepoint.

The TLS 1.3 cipher suite negotiated during the handshake selects one of three AEAD algorithms; the AEAD enum carries the choice forward into the per-direction packet-protection keying schedule.

All three are mandatory-to-implement for a TLS 1.3 endpoint per RFC 8446 §9.1, but RFC 9001 §5.1 mandates that at least AES-128-GCM be supported by every QUIC v1 endpoint.

Implements Movable

comptime AES_128_GCM = Int(0)

RFC 5288 AES-128 in GCM mode (the QUIC v1 mandatory-to-implement).

comptime AES_256_GCM = Int(1)

RFC 5288 AES-256 in GCM mode.

comptime CHACHA20_POLY1305 = Int(2)

RFC 8439 ChaCha20-Poly1305 AEAD construction.

StubQuicCrypto

struct StubQuicCrypto

Typed sentinel that raises NotImplemented on every AEAD operation.

Exists so unit tests can exercise the :trait:QuicCrypto trait boundary without driving a real AEAD; production code constructs :class:OpenSslQuicCrypto instead.

Implements Copyable, Movable, QuicCrypto

Fields
aead_choice: Int

__init__

def __init__(out self, aead_choice: Int = Int(0))

aead

def aead(self) -> Int

encrypt

def encrypt(self, plaintext: Span[UInt8], associated_data: Span[UInt8], packet_number: UInt64) -> List[UInt8]

decrypt

def decrypt(self, ciphertext: Span[UInt8], associated_data: Span[UInt8], packet_number: UInt64) -> List[UInt8]

header_protection_mask

def header_protection_mask(self, sample: Span[UInt8]) -> List[UInt8]

PacketKeys

struct PacketKeys

Per-direction packet-protection keys derived from a 32-byte secret per RFC 9001 §5.1.

The handshake produces two secrets per encryption level (one per direction); each becomes a PacketKeys via :func:derive_packet_keys.

Implements Copyable, Movable

Fields
aead: Int: QuicAead codepoint these keys are scheduled for.
key: List[UInt8]: AEAD key -- 16 bytes (AES-128) or 32 bytes (AES-256, ChaCha20).
iv: List[UInt8]: AEAD IV -- 12 bytes (RFC 9001 §5.3 nonce-construction base).
hp: List[UInt8]: Header-protection key -- same length as key for AES; 32 bytes for ChaCha20-Poly1305 (RFC 9001 §5.4).

OpenSslQuicCrypto

struct OpenSslQuicCrypto

Production QUIC v1 AEAD + header-protection backend backed by OpenSSL via the flare_quic_aead_* / flare_quic_hp_mask FFI thunks.

The carrier holds the per-direction PacketKeys (AEAD key, AEAD IV, header-protection key) for a single encryption level in a single direction. The QUIC server reactor maintains four carriers per connection: client/server initial, handshake, and 1-RTT (the initial-secret pair gets dropped after the handshake completes per RFC 9001 paragraph 4.9.1).

Construction:

  • :py:meth:from_secret -- derive PacketKeys from a 32-byte direction secret + AEAD codepoint, then wrap into the carrier.
  • :py:meth:__init__ -- bring-your-own PacketKeys for the test path or when the keys come from an outboard schedule (e.g. the rustls QUIC binding).

Implements Copyable, Movable, QuicCrypto

Fields
keys: PacketKeys

__init__

def __init__(out self, var keys: PacketKeys)

from_secret

def from_secret(secret: Span[UInt8], aead_choice: Int) -> Self

Derive PacketKeys from secret per RFC 9001 §5.1 and wrap into a carrier ready to encrypt / decrypt against the schedule's keys.

aead

def aead(self) -> Int

encrypt

def encrypt(self, plaintext: Span[UInt8], associated_data: Span[UInt8], packet_number: UInt64) -> List[UInt8]

decrypt

def decrypt(self, ciphertext: Span[UInt8], associated_data: Span[UInt8], packet_number: UInt64) -> List[UInt8]

header_protection_mask

def header_protection_mask(self, sample: Span[UInt8]) -> List[UInt8]

Traits

QuicCrypto

Pluggable QUIC v1 AEAD + header-protection backend.

The QUIC server reactor drives one carrier per connection. The carrier owns the per-direction packet- protection keys derived from the initial-secret pair (and later the 1-RTT secrets) and exposes the encrypt-with-AD / decrypt-with-AD operations plus the header-protection mask used to hide the packet number.

Backends:

  • OpensslQuicCrypto (OpenSSL FFI backend) -- production default. Routes through the OpenSSL EVP_AEAD surface plus EVP_aes_*_ecb for the AES-128-CTR header- protection mask.
  • RustlsQuicCrypto -- alternative backend used when rustls is selected as the QUIC TLS provider.

The :class:StubQuicCrypto is a typed sentinel: it raises a clear NotImplemented Error on encrypt / decrypt / header_protection so test harnesses can confirm the trait boundary is wired up correctly before the real backend lands.

Implements Copyable, Movable

aead

def aead(self) -> Int

Return the negotiated :data:QuicAead value.

encrypt

def encrypt(self, plaintext: Span[UInt8], associated_data: Span[UInt8], packet_number: UInt64) -> List[UInt8]

Encrypt plaintext against associated_data with the current packet-protection key, returning ciphertext plus the 16-byte authentication tag appended.

packet_number is XOR'd into the per-key static IV per RFC 9001 §5.3 to form the AEAD nonce.

decrypt

def decrypt(self, ciphertext: Span[UInt8], associated_data: Span[UInt8], packet_number: UInt64) -> List[UInt8]

Decrypt ciphertext (which must include the 16-byte AEAD tag appended) against associated_data, returning the plaintext. Raises on tag mismatch.

header_protection_mask

def header_protection_mask(self, sample: Span[UInt8]) -> List[UInt8]

RFC 9001 §5.4 -- compute the 5-byte header-protection mask from a 16-byte sample of the packet's encrypted payload (starting at offset 4 into the payload, per §5.4.2). For AES-based AEADs the mask is the first 5 bytes of AES-128-ECB(hp_key, sample); for ChaCha20-Poly1305 it is the first 5 bytes of ChaCha20(hp_key, counter || nonce) where the sample's first 4 bytes are the counter and the remaining 12 are the nonce.

Functions

hkdf_extract

def hkdf_extract(salt: Span[UInt8], ikm: Span[UInt8]) -> List[UInt8]

RFC 5869 §2.2 -- PRK = HMAC-Hash(salt, IKM).

Returns the 32-byte pseudorandom key (PRK). The hash is fixed at SHA-256; QUIC v1 mandates SHA-256 for the initial keys schedule (RFC 9001 §5.2).

hkdf_expand

def hkdf_expand(prk: Span[UInt8], info: Span[UInt8], length: Int) -> List[UInt8]

RFC 5869 §2.3 -- OKM = HKDF-Expand(PRK, info, L).

Iteratively computes T(i) = HMAC-Hash(PRK, T(i-1) || info || i) where T(0) is empty and i is a single byte counter starting at 1. The output is the first length bytes of T(1) || T(2) || ... || T(N). length must be at most 255 * 32 (the SHA-256 hash output size).

hkdf_expand_label

def hkdf_expand_label(secret: Span[UInt8], label: String, context: Span[UInt8], length: Int) -> List[UInt8]

RFC 8446 §7.1 -- HKDF-Expand-Label(Secret, Label, Context, Length).

Wraps HKDF-Expand with the canonical TLS 1.3 HkdfLabel struct serialization:

struct {
    uint16 length = Length;
    opaque label<7..255> = "tls13 " + Label;
    opaque context<0..255> = Context;
} HkdfLabel;

QUIC v1 (RFC 9001 §5.1) replaces "tls13 " with "tls13 " -- same prefix -- and uses labels "client in" / "server in" for initial-secret derivation, then "quic key" / "quic iv" / "quic hp" for the AEAD packet-protection keys.

hkdf_expand_label_empty_context

def hkdf_expand_label_empty_context(secret: Span[UInt8], label: String, length: Int) -> List[UInt8]

hkdf_expand_label with an empty Context value.

derive_initial_secrets

def derive_initial_secrets(dst_cid: Span[UInt8]) -> InitialSecrets

RFC 9001 §5.2 -- derive the initial-direction secrets from the client's Destination Connection ID.

The DCID is the client's first-flight Initial packet's Destination Connection ID (RFC 9000 §17.2.2). Bookkeeping notes:

  • Both endpoints derive the same secrets from this DCID.
  • Once the handshake produces 1-RTT keys, the initial-secret branch is discarded (RFC 9001 §4.9.1).
  • DCID length must be between 8 and 20 bytes per RFC 9000 §7.2; this function doesn't enforce the length so callers can pass any byte span (fuzz harnesses include zero-length DCIDs to probe the trait surface).

aead_key_length

def aead_key_length(aead: Int) -> Int

Per-AEAD key length in bytes (RFC 9001 §5.1 + RFC 8439).

derive_packet_keys

def derive_packet_keys(secret: Span[UInt8], aead: Int) -> PacketKeys

RFC 9001 §5.1 -- derive (key, iv, hp) from a 32-byte per-direction secret.

Labels are the canonical RFC 9001 §5.1 set:

  • "quic key" -- AEAD key (key length per AEAD).
  • "quic iv" -- AEAD IV (always 12 bytes).
  • "quic hp" -- header-protection key (same length as AEAD key for AES; 32 bytes for ChaCha20).

The same schedule applies to initial, handshake, and 1-RTT secrets; only the source secret changes. The handshake + 1-RTT secrets come from the TLS 1.3 exporter (RFC 9001 §7 handshake state machine, wired once rustls is integrated).

Aliases

comptime QUIC_V1_INITIAL_SALT = List(UInt8(56), UInt8(118), UInt8(44), UInt8(247), UInt8(245), UInt8(89), UInt8(52), UInt8(179), UInt8(77), UInt8(23), UInt8(154), UInt8(230), UInt8(164), UInt8(200), UInt8(12), UInt8(173), UInt8(204), UInt8(187), UInt8(127), UInt8(10), __list_literal__=NoneType(None))

RFC 9001 §5.2 QUIC v1 initial salt -- 0x38762cf7f55934b34d179ae6a4c80cadccbb7f0a.

Used as the HKDF-Extract salt when deriving the initial-secret from the client's Destination Connection ID. RFC 9001 §5.2 names this as the only valid salt for the v1 wire format.

comptime SHA256_OUTPUT_BYTES = Int(32)

SHA-256 hash output size in bytes. Used as the HKDF-Extract output length and the default HKDF-Expand-Label output length for the QUIC initial-secret derivations.