Skip to content

net.quic #

net.quic — build dependencies

Tracking issue: vlang/v#27675.

TLS 1.3 approach

net.quic implements a QUIC-scoped TLS 1.3 handshake (RFC 8446) in pure V, rather than patching vendored mbedTLS (which has no QUIC support in any released version — see the issue for the full rationale). X.509 certificate parsing and chain validation are delegated to mbedTLS's already-bound C functions (mbedtls_x509_crt_parse, mbedtls_x509_crt_verify, mbedtls_pk_parse_key, mbedtls_pk_verify_ext/mbedtls_pk_verify) — the same thing net.http's HTTP/1.1 and HTTP/2 backends already do. No mbedTLS source patch is required for this.

OpenSSL dependency: hard, not opt-out

TLS 1.3 key exchange for net.quic needs P-256 ECDH (for the secp256r1 key_share group), which didn't exist anywhere in V before this. It's added as a new OpenSSL binding (vlib/crypto/ecdsa/ecdsa.c.v), following the same -lcrypto linkage crypto.ecdsa already uses for ECDSA sign/verify.

This was a candidate for a -d no_openssl_quic opt-out flag (falling back to Ed25519-only cert chain support), but is not needed: Windows CI (windows_ci_gcc.yml) already builds and runs vlib/crypto/ecdsa/ecdsa_test.v against OpenSSL today, with explicit OpenSSL diagnostics steps beforehand. So the exact dependency net.quic needs is already proven to build and pass on Linux, macOS, and Windows. Decision: P-256 ECDH is a hard dependency of net.quic. No opt-out build flag, no reduced-interop fallback mode.

CertificateVerify signature verification (ECDSA and RSA-PSS) and certificate chain-of-trust validation (including RSA-PKCS1v1.5-signed certificates, still common among real-world CAs — net.quic advertises this via the signature_algorithms_cert extension, RFC 8446 §4.2.3) are both handled through mbedTLS's already-vendored, already-bound C functions (mbedtls_pk_verify_ext, mbedtls_x509_crt_verify) — no OpenSSL dependency for either. (An earlier draft of this file added a separate vlib/crypto/rsa_pss/ OpenSSL module for RSA-PSS specifically; it was removed as unused dead code once the mbedTLS path above was confirmed to cover the same need.)

mbedTLS X.509-only usage (no mbedtls_ssl_context)

net.quic calls mbedtls_x509_crt_parse/_verify and mbedtls_pk_parse_key/ mbedtls_pk_verify directly, without ever constructing an mbedtls_ssl_context — a usage pattern the existing net.mbedtls.SSLConn path never exercises (it always builds a full SSL context/config). This is confirmed to work: see vlib/net/mbedtls/x509_standalone_test.v, which parses and verifies a certificate with no mbedtls_ssl_context in scope, relying only on the module's existing init() (v_mbedtls_threading_setup(), already called automatically on import net.mbedtls regardless of whether an SSLConn is ever constructed).

Constants #

const quic_v1 = u32(00x00000001)
const quic_v1_max_cid_len = 20

quic_v1_max_cid_len is RFC 9000 §17.2's own connection-ID length limit for this version: "In QUIC version 1, this value MUST NOT exceed 20 bytes." Enforced on both the encode and parse paths for long headers -- the wire format's own length field allows up to 255 bytes (a single byte), which is NOT the same thing as this version's own protocol limit; a peer sending 21-255 is spec-violating even though the wire format itself has no problem representing it.

const initial_salt = [u8(0x38), 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3, 0x4d, 0x17, 0x9a,
	0xe6, 0xa4, 0xc8, 0x0c, 0xad, 0xcc, 0xbb, 0x7f, 0x0a]!

RFC 9001 §5.2 — the QUIC v1 Initial packet protection salt. Used as the HKDF-Extract salt when deriving Initial secrets from a connection's client-chosen Destination Connection ID. This value is public and fixed per QUIC version; it provides domain separation between QUIC versions, not secrecy.

const max_packet_number = (u64(1) << 62) - 1

max_packet_number is RFC 9000 §12.3's own stated limit: "if any packet number is exhausted, ... MUST close the connection" -- packet numbers are 62-bit unsigned values, so 2^62-1 is the largest one a sender may ever use; a sender reaching this limit must stop sending in this space (typically by closing the connection or migrating), not keep counting.

const max_varint = u64(0x30x3FFFFFFFFFFFFFFF) // 2^62 - 1

QUIC variable-length integer encoding (RFC 9000 §16).

This is NOT the same encoding as vlib/encoding/leb128 — the two are bit-incompatible and must never be interchanged. LEB128 uses a continuation bit in every byte; QUIC's varint instead packs a 2-bit length class into the top bits of the FIRST byte:

0b00xxxxxx -> 1 byte, 6-bit value 0b01xxxxxx xxxxxxxx -> 2 bytes, 14-bit value 0b10xxxxxx xxxxxxxx xxxxxxxx xxxxxxxx -> 4 bytes, 30-bit value 0b11xxxxxx (+ 7 more bytes) -> 8 bytes, 62-bit value

The maximum representable value is 2^62 - 1.

fn build_client_hello #

fn build_client_hello(p ClientHelloParams) ![]u8

build_client_hello constructs a complete TLS 1.3 ClientHello handshake message (RFC 8446 §4.1.2), framed via encode_handshake_message. Sends exactly eight extensions: server_name, supported_versions, supported_groups, signature_algorithms, signature_algorithms_cert, alpn, key_share, and quic_transport_parameters (RFC 9001 §8.2) — order doesn't matter per RFC 8446 §4.2 ("extensions MAY appear in any order") except that pre_shared_key would have to be last, and v1 never sends one (no 0-RTT/resumption, Phase 14).

fn certificate_verify_signed_content #

fn certificate_verify_signed_content(role CertificateVerifyRole, transcript_hash []u8) []u8

certificate_verify_signed_content builds RFC 8446 §4.4.3's exact signed-content construction: 64 octets of 0x20, the role-specific context string, a single 0x00 separator byte, then Transcript-Hash(Handshake Context, Certificate). This 64-byte pad exists specifically to defeat a prior-TLS-version attack that obtained signatures over a chosen 32-byte prefix (RFC 8446 §4.4.3) — it is not arbitrary padding a future cleanup could shrink.

v1 only ever needs the server variant (client CertificateVerify is never sent — CertificateRequest is rejected outright, per PROGRESS.md); the client variant is included for API completeness with the RFC's own two-sided definition, at negligible cost, and to keep this function correct if Phase 13's server-role client-cert-auth support is ever added without needing to revisit this file.

fn compute_finished_verify_data #

fn compute_finished_verify_data(base_secret []u8, transcript_hash []u8) ![]u8

compute_finished_verify_data implements RFC 8446 §4.4.4's Finished message computation:

finished_key = HKDF-Expand-Label(BaseKey, "finished", "", Hash.length) verify_data = HMAC(finished_key, Transcript-Hash(...))

base_secret is whichever side's traffic secret applies — the client's Finished uses client_handshake_traffic_secret, the server's uses server_handshake_traffic_secret (Phase 2b's HandshakeSecrets fields); this function is side-agnostic, the caller picks. transcript_hash must already cover exactly the messages up to but NOT including this Finished message itself (for the server's Finished: ClientHello... CertificateVerify*; for the client's Finished: ClientHello...server Finished).

fn decode_packet_number #

fn decode_packet_number(truncated u64, pn_len int, largest_pn ?u64) !u64

decode_packet_number reconstructs the full packet number from its on-the-wire truncated form, given the largest packet number successfully processed so far in this space (RFC 9000 Appendix A.3, DecodePacketNumber). truncated must already be the numeric value of the pn_len-byte field (i.e. the header-protection mask must already have been removed and the bytes parsed as a big-endian integer of that length).

fn decode_preferred_address #

fn decode_preferred_address(buf []u8) !PreferredAddress

decode_preferred_address parses the fixed-format Figure 22 layout: a 25-byte fixed prefix (4-byte IPv4 address, 2-byte IPv4 port, 16-byte IPv6 address, 2-byte IPv6 port, 1-byte connection ID length), followed by the connection ID itself and a fixed 16-byte stateless reset token.

fn decode_transport_parameters #

fn decode_transport_parameters(buf []u8) !QuicTransportParameters

decode_transport_parameters parses a full Transport Parameters sequence (RFC 9000 §18, Figure 20). Per RFC 9000 §7.4.2 ("An endpoint MUST ignore transport parameters that it does not support"), an unrecognized parameter ID is skipped, not rejected — this is also how RFC 9000 §18.1's reserved "31*N+27" grease IDs are exercised, with no special-casing needed beyond the general unknown-ID skip. ack_delay_exponent, max_udp_payload_size, max_ack_delay, and active_connection_id_limit are validated against RFC 9000 §18.2's own stated bounds.

A duplicate parameter ID is rejected outright, even though RFC 9000's prose for §18.2 does not spell this out explicitly as a MUST — this mirrors established QUIC implementation practice and this project's own "singleton wire field extracted in a loop" lesson: silently letting the last occurrence win is exactly the failure mode that class of bug warns about, for no compensating benefit (a conforming peer never sends the same parameter twice).

fn decode_varint #

fn decode_varint(buf []u8) !(u64, int)

decode_varint decodes a QUIC variable-length integer from the start of buf, returning the decoded value and the number of bytes consumed. Accepts all four legal length classes for a decoded value, even when a shorter class could have encoded the same value: RFC 9000 §16 lets an encoder choose any of the four length classes a value fits in (encoders SHOULD use the smallest, matching this file's own encode_varint, which always does), but does not require -- and real, otherwise-conforming peers are not guaranteed -- a minimal encoding on the wire. A previous version of this function rejected non-minimal encodings outright (Codex finding, vlang/v#27680 pullrequestreview-4781706846); that treated a spec-legal encoding as a parse error, wrongly failing the handshake for a compliant peer that (for whatever reason) chose a longer-than-minimal form for a value used anywhere a varint appears -- packet lengths, tokens, transport parameters, ACK ranges, and more. Truncated input is still rejected.

fn decrypt_packet_payload #

fn decrypt_packet_payload(keys QuicPacketProtectionKeys, packet_number u64, header []u8, ciphertext []u8) ![]u8

decrypt_packet_payload AEAD-decrypts and authenticates one packet's payload. On authentication failure, callers MUST silently drop the packet rather than tear down the connection: a single AEAD failure is indistinguishable from ordinary network corruption or off-path garbage UDP data, and RFC 9001's security guidance is that it must never, by itself, be escalated to a connection close. This function only reports the failure as an error; enforcing "drop, don't close" is the caller's (Phase 4+ packet-receive loop's) responsibility.

fn derive_application_secrets #

fn derive_application_secrets(handshake_secret []u8, transcript_hash_ch_sfin []u8) !ApplicationSecrets

derive_application_secrets computes the Master Secret and both application traffic secrets (RFC 8446 §7.1) from the Handshake Secret and Transcript-Hash(ClientHello...server Finished).

exporter_master_secret and resumption_master_secret are intentionally NOT computed here: v1 uses neither TLS exporters (QUIC derives its packet-protection keys directly from the traffic secrets returned here, via hkdf_expand_label with the "quic key"/"quic iv"/"quic hp" labels, never through the generic TLS exporter interface) nor session resumption (0-RTT is Phase 14, out of committed scope). Adding either output now would be untested, unused surface area.

fn derive_early_secret #

fn derive_early_secret() ![]u8

derive_early_secret returns the TLS 1.3 Early Secret (RFC 8446 §7.1). v1 has no PSK/session resumption (0-RTT is Phase 14, out of committed scope), so this always takes the spec's no-PSK branch: both the HKDF-Extract salt and IKM are absent inputs, each replaced per §7.1's rule ("If a given secret is not available, then the 0-value ... is used") with a string of Hash.length zero bytes. The salt's zero value is supplied implicitly (hkdf.extract defaults an empty salt to Hash.length zero bytes); the IKM's zero value must be passed explicitly, since HKDF-Extract does not treat an empty IKM the same as a zero IKM.

This step is still computed for real, not shortcut to a hardcoded zero Early Secret, because its OUTPUT feeds the "derived" Derive-Secret call that chains into the Handshake Secret below — skipping the computation would assume, rather than prove, that shortcut is equivalent.

fn derive_handshake_secrets #

fn derive_handshake_secrets(early_secret []u8, ecdhe_shared_secret []u8, transcript_hash_ch_sh []u8) !HandshakeSecrets

derive_handshake_secrets computes the Handshake Secret and both handshake traffic secrets (RFC 8446 §7.1) from the Early Secret, the (EC)DHE shared secret, and Transcript-Hash(ClientHello...ServerHello).

fn derive_initial_secrets #

fn derive_initial_secrets(client_dcid []u8) !InitialSecrets

derive_initial_secrets computes the QUIC v1 Initial packet protection secrets (RFC 9001 §5.2) from a connection's client-chosen Destination Connection ID.

Callers MUST key this off whatever DCID the client is CURRENTLY using on the wire for its Initial packets ("client_dst_connection_id" in RFC 9001 §5.2's own terms) -- NOT a fixed "original" value retained across the connection's whole lifetime. RFC 9001 §5.2 is explicit: "The secrets used for constructing subsequent Initial packets change when a server sends a Retry packet to use the connection ID value selected by the server." Concretely: before any Retry, client_dcid is whatever DCID the client itself randomly chose for its first Initial packet; after a Retry, the client switches its wire DCID to the Retry packet's Source Connection ID (RFC 9000 §17.2.5), and Initial secrets MUST be RE-DERIVED from that new value -- a server deriving its own Initial keys from the DCID it actually received would otherwise compute different keys than a client still using its pre-Retry secrets, making every post-Retry Initial packet undecryptable to a conforming peer. This is a previously-incorrect claim in this doc comment itself (Codex finding, vlang/v#27680 pullrequestreview-4781706846), verified against the RFC 9001 §5.2 text directly, not just re-derived from memory.

Do NOT confuse this with the SEPARATE, unrelated original_destination_connection_id transport parameter (RFC 9000 §7.3) -- that is purely an anti-tampering AUTHENTICATION value the server echoes back for the client to verify, carrying no bearing on which secrets protect which packets. This function itself is DCID-agnostic (it derives from whatever client_dcid it's given); it is the caller's responsibility (Phase 9's QuicConn) to track the CURRENT wire DCID and call this again whenever a Retry changes it.

fn derive_packet_protection_keys #

fn derive_packet_protection_keys(secret []u8) !QuicPacketProtectionKeys

derive_packet_protection_keys computes quic_key/quic_iv/quic_hp (RFC 9001 §5.1) from one encryption level's one-directional traffic secret (an Initial secret, a handshake traffic secret, or an application traffic secret — this function is level-agnostic, since HKDF-Expand-Label's "quic key"/"quic iv"/"quic hp" labels are identical at every level).

fn derive_secret #

fn derive_secret(secret []u8, label string, transcript_hash []u8) ![]u8

derive_secret implements RFC 8446 §7.1's Derive-Secret(Secret, Label, Messages):

Derive-Secret(Secret, Label, Messages) = HKDF-Expand-Label(Secret, Label, Transcript-Hash(Messages), Hash.length)

The caller supplies the already-computed Transcript-Hash(Messages) directly. Accumulating raw handshake message bytes into a running hash — and, per RFC 8446 §4.4.1, covering only the handshake message bytes themselves with no QUIC/TLS record-layer framing — is Phase 2c's job (ClientHello...Finished), not this function's.

fn empty_transcript_hash #

fn empty_transcript_hash() []u8

empty_transcript_hash returns Transcript-Hash(""), used for RFC 8446 §7.1's two "derived" chaining steps (Early Secret -> Handshake Secret, Handshake Secret -> Master Secret). Both are defined over an EMPTY Messages input, not over any real handshake message — this is the spec's fixed value for that case, not a placeholder standing in for one.

fn encode_handshake_message #

fn encode_handshake_message(typ HandshakeType, body []u8) ![]u8

encode_handshake_message wraps body in the standard TLS 1.3 handshake message header (RFC 8446 §4): a 1-byte HandshakeType followed by a 3-byte big-endian length. This framing is identical to the synthetic message_hash record Phase 2b's synthetic_client_hello1_hash builds by hand for the HelloRetryRequest transcript rule; that call site is not migrated to use this function, since encode_handshake_message requires a real HandshakeType and message_hash's synthetic record is deliberately NOT a real handshake message being framed for transmission.

fn encode_long_header #

fn encode_long_header(h QuicLongHeader, reserved_bits u8, pn_length_bits u8) ![]u8

encode_long_header serializes a long header's unprotected portion. The packet-number-length bits (first byte, low 2 bits) and the reserved bits (first byte, bits 2-3) are passed in explicitly because they belong to the header-protected region — the caller (Phase 3's packet writer) is responsible for having already decided the packet number's encoded length before calling this, since length (a varint covering packet-number + payload bytes) must be sized to include it. This two-pass dependency (packet number length must be known before the Length field can be finalized, but the Length field precedes the packet number on the wire) is the caller's responsibility to sequence correctly, not something this function can resolve on its own.

fn encode_packet_number #

fn encode_packet_number(full_pn u64, largest_acked ?u64) !([]u8, int)

encode_packet_number picks the smallest encoding (1, 2, 3, or 4 bytes) for full_pn such that it can be unambiguously reconstructed given largest_acked (the largest packet number acknowledged so far in this space, or none if nothing has been acknowledged yet). Returns the truncated bytes and the number of bytes used.

Per RFC 9000 §17.1, the sender MUST use a packet number encoding that can represent more than twice as large a range as the difference between the packet number being sent and the largest acknowledged packet.

Rejects full_pn > max_packet_number (RFC 9000 §12.3) rather than silently truncating it -- an un-checked caller could otherwise reach here with an exhausted counter and get back a corrupted, wrapped-around encoding (e.g. 2^62 truncates to a 4-byte field of all zeros) instead of the caller finding out its connection needs to stop sending. Also guards the (num_unacked + 1) * 2 sizing arithmetic below, which would itself overflow for num_unacked values near u64 max.

fn encode_preferred_address #

fn encode_preferred_address(pa PreferredAddress) ![]u8

encode_preferred_address serializes the fixed-format Figure 22 layout. RFC 9000 §18.2: "A server MUST NOT include a zero-length connection ID in this transport parameter" — enforced here so a malformed PreferredAddress can never be silently encoded.

fn encode_transport_parameters #

fn encode_transport_parameters(p QuicTransportParameters) ![]u8

encode_transport_parameters serializes every present field as an (ID, Length, Value) tuple (RFC 9000 §18, Figure 21). Absent (none) Optional fields and a false disable_active_migration are simply omitted — both omission and explicit-default-value encoding are spec-equivalent on decode, so there is no need to special-case "equals the default" here.

fn encode_varint #

fn encode_varint(value u64) ![]u8

encode_varint encodes value as a QUIC variable-length integer, using the smallest length class that fits (RFC 9000 §16 requires minimal encoding).

fn encrypt_packet_payload #

fn encrypt_packet_payload(keys QuicPacketProtectionKeys, packet_number u64, header []u8, payload []u8) ![]u8

encrypt_packet_payload AEAD-encrypts one packet's payload (RFC 9001 §5.3). header must be the ENTIRE unprotected header, up to and including the plaintext packet number bytes — it is the AEAD associated data in full, not just a prefix of it. packet_number must already be the full, reconstructed value (see packet_protection_nonce).

fn find_extension #

fn find_extension(extensions []TlsExtension, typ u16) ?TlsExtension

find_extension returns the first extension in extensions matching typ, or none if no such extension is present.

fn handshake_type_from_u8 #

fn handshake_type_from_u8(b u8) !HandshakeType

handshake_type_from_u8 validates a wire byte against the known TLS 1.3 HandshakeType set (RFC 8446 §B.3), rather than blindly casting it to HandshakeType — an arbitrary u8 does not correspond to a valid variant for most of the 0..255 range, and a cast alone would not catch that.

fn hkdf_expand_label #

fn hkdf_expand_label(secret []u8, label string, context []u8, length int) ![]u8

hkdf_expand_label implements TLS 1.3's HKDF-Expand-Label (RFC 8446 §7.1). It is the single derivation primitive reused throughout the QUIC-TLS key schedule (RFC 9001 §5): Initial secrets here, the full Early/Handshake/ Master secret chain in Phase 2b, and per-level quic_key/quic_iv/quic_hp in Phase 3. secret must already be a PRK (an HKDF-Extract or a prior HKDF-Expand-Label output) — this function only ever expands, never extracts. QUIC never populates the Context field; every call site in this module passes an empty context, but the parameter exists for API fidelity with RFC 8446 and Phase 2b's Derive-Secret, which does use it (as a transcript hash).

HkdfLabel wire format (RFC 8446 §7.1):

uint16 length opaque label<7..255> = "tls13 " + label opaque context<0..255> = context

fn parse_certificate #

fn parse_certificate(body []u8) !ParsedCertificate

parse_certificate parses a Certificate handshake message BODY (RFC 8446 §4.4.2). v1 is client-only and never requests client-cert auth (CertificateRequest is rejected outright per PROGRESS.md), so this function only ever parses a SERVER's Certificate message — which RFC 8446 §4.4.2.4 states "MUST always be non-empty" ("If the server supplies an empty Certificate message, the client MUST abort the handshake with a decode_error alert"), enforced unconditionally here rather than deferred to a caller that would need to know which role sent it.

fn parse_certificate_verify #

fn parse_certificate_verify(body []u8) !ParsedCertificateVerify

parse_certificate_verify parses a CertificateVerify handshake message BODY (RFC 8446 §4.4.3). Validates algorithm against the fixed set v1 itself offered in its own signature_algorithms extension (tls13_client_hello.v's sig_scheme_* constants) — RFC 8446 §4.4.3: "the signature algorithm MUST be one offered in the client's signature_algorithms extension." Since v1's offered set is a fixed, hardcoded list rather than something that varies per connection, this check needs no caller-supplied state, unlike the checks tls13_server_hello.v defers to the state machine.

fn parse_encrypted_extensions #

fn parse_encrypted_extensions(body []u8) ![]TlsExtension

parse_encrypted_extensions parses an EncryptedExtensions handshake message BODY (RFC 8446 §4.3.1: just a length-prefixed extension list, nothing else), then rejects any extension outside encrypted_extensions_allowed -- RFC 8446 §4.2: "Implementations MUST NOT send extension responses if the remote endpoint did not send the corresponding extension requests... Upon receiving such an extension, an endpoint MUST abort the handshake with an 'unsupported_extension' alert." Previously only early_data was checked (a Codex finding, vlang/v#27680 pullrequestreview-4783410111, pointed out that other EE-illegal extensions like key_share/supported_versions passed through unrejected); early_data keeps its own explicit message for a clearer diagnostic, but both routes now carry the same unsupported_extension QUIC CONNECTION_CLOSE code via error_with_code, not a generic error() a caller would otherwise remap to decode_error.

fn parse_extension_list #

fn parse_extension_list(buf []u8) ![]TlsExtension

parse_extension_list walks a full extension list's inner bytes (the concatenated extensions themselves — callers strip whatever length prefix wrapped the whole list first) and returns every entry. RFC 8446 §4.2: "There MUST NOT be more than one extension of the same type in a given extension block" — enforced here, mirroring transport_parameters.v's duplicate-ID rejection for the analogous QUIC-level TLV sequence.

fn parse_handshake_message #

fn parse_handshake_message(buf []u8) !(HandshakeMessage, int)

parse_handshake_message reads one TLS 1.3 handshake message from the front of buf and returns it along with the number of bytes consumed. buf may contain trailing bytes belonging to a LATER message — QUIC delivers handshake messages via CRYPTO frames that can span or share packet boundaries arbitrarily (Phase 4's crypto_stream.v job to reassemble into a contiguous byte stream); this function only peels off exactly one message and reports how much of buf it consumed, so a caller can loop.

fn parse_long_header #

fn parse_long_header(buf []u8) !(QuicLongHeader, int)

parse_long_header parses a long header's unprotected portion (packet type through the Length field for Initial/0-RTT/Handshake, or through the retry token for Retry — see parse_retry_header for the Retry Integrity Tag that follows). Returns the parsed header and the number of bytes consumed, so the caller can locate where header-protected packet number bytes begin (or, for Retry, where the fixed-size integrity tag begins).

Reserved bits (first_byte bits 2-3) are NOT validated here — they are protected by header protection and can only be checked meaningfully after that is removed (Phase 3's responsibility).

fn parse_server_hello #

fn parse_server_hello(body []u8) !ServerHelloMessage

parse_server_hello parses a ServerHello handshake message body (RFC 8446 §4.1.3), returning either a ParsedServerHello or, when random matches the magic HelloRetryRequest value, a ParsedHelloRetryRequest -- the two share a wire type but are validated against distinct mandatory fields and allowed- extension sets (see server_hello_allowed/hello_retry_request_allowed above). Rejects a non-1.3 selected_version, a non-empty legacy_session_id_echo, a missing/malformed key_share, and any extension not on the applicable allowlist -- cipher_suite itself is parsed and returned but not validated here; the caller (process_server_hello) checks it against the single suite this client offers.

fn parse_short_header #

fn parse_short_header(buf []u8, dcid_len int) !(QuicShortHeader, int)

parse_short_header parses a short header given the expected DCID length (which the caller must supply from its own connection state — see the struct doc comment above for why this can't be inferred from the packet). Returns the header and bytes consumed (always 1 + dcid_len; the packet number that follows has no explicit length prefix and extends however many bytes the (still-protected) low 2 bits of the first byte indicate, which is only knowable after header protection removal).

fn parse_version_negotiation #

fn parse_version_negotiation(buf []u8) !QuicVersionNegotiation

parse_version_negotiation parses a Version Negotiation packet (RFC 9000 §17.2.1) -- distinguished from every other long-header packet by its version field being 0, and carrying no packet number, Length field, or encrypted payload.

fn peek_header_form #

fn peek_header_form(buf []u8) !HeaderForm

peek_header_form reports whether buf starts a long-header or short-header packet. This bit is never protected — it's always readable directly.

fn protect_header #

fn protect_header(mut packet []u8, pn_offset int, pn_length int, hp_key []u8, form HeaderForm) !

protect_header applies header protection to packet in place, given an already-chosen pn_length — the sender always knows this, since it picked the packet number's encoded length itself before calling this (see encode_packet_number). packet must already contain the full unprotected header AND the AEAD-encrypted payload: per RFC 9001 §5.4.1/§5.3, the sample is taken from the CIPHERTEXT, so this function must run strictly AFTER AEAD encryption — reversing that order is the single most common QUIC packet-protection implementation bug. pn_offset is the index into packet where the (currently still plaintext) packet number bytes begin.

fn protect_packet #

fn protect_packet(header []u8, form HeaderForm, packet_number u64, pn_length int, payload []u8, keys QuicPacketProtectionKeys) ![]u8

protect_packet assembles one fully protected QUIC packet from its unprotected header, full packet number, and plaintext payload, applying RFC 9001 §5's protection steps in the ONLY correct order: AEAD-encrypt the payload FIRST, THEN sample the resulting ciphertext to derive the header protection mask, THEN apply that mask to the header. Reversing this order (protecting the header before the payload exists, or sampling plaintext instead of ciphertext) is the single most common QUIC packet-protection implementation bug — callers should use this function rather than sequencing encrypt_packet_payload/protect_header themselves.

header is the complete unprotected header bytes, ending with the plaintext packet number encoded in exactly pn_length bytes (see encode_packet_number). form selects which low bits of the first byte header protection covers.

fn synthetic_client_hello1_hash #

fn synthetic_client_hello1_hash(client_hello1 []u8) []u8

synthetic_client_hello1_hash implements RFC 8446 §4.4.1's HelloRetryRequest transcript rule: after a HelloRetryRequest, every Transcript-Hash computed for the rest of the handshake replaces the literal ClientHello1 bytes with a synthetic "message_hash" handshake message wrapping Hash(ClientHello1), instead of hashing ClientHello1 itself twice over (once standalone in an earlier transcript, once as a prefix of a later one):

Transcript-Hash(ClientHello1, HelloRetryRequest, ... Mn) = Hash(message_hash || /* Handshake type / 00 00 Hash.length || / Handshake message length (bytes) */ Hash(ClientHello1) || HelloRetryRequest || ... || Mn)

This function returns that synthetic record's bytes (NOT yet hashed). The caller — Phase 2c's transcript accumulator, once a HelloRetryRequest is seen — feeds these bytes into the running hash IN PLACE OF the real ClientHello1 bytes, then continues normally with HelloRetryRequest and every later message appended as-is.

fn tls_alert_to_quic_error #

fn tls_alert_to_quic_error(alert TlsAlert) u64

tls_alert_to_quic_error implements RFC 9001 §4.8: "If TLS produces an alert, QUIC MUST convert it into a QUIC CONNECTION_CLOSE error. The alert description ... is added to 0x100 to produce a QUIC error code." This pure-V implementation never runs an actual TLS alert-producing library, so this handshake's own failure paths pick the RFC 8446 §6 alert a compliant peer would have raised for the equivalent condition, and this function does the same 0x100 translation a real TLS-stack-to-QUIC bridge would.

fn unprotect_header #

fn unprotect_header(mut packet []u8, pn_offset int, hp_key []u8, form HeaderForm) !int

unprotect_header removes header protection from packet in place and returns the packet number's true encoded length. Unlike protect_header, the caller does NOT know pn_length up front — it is itself part of the protected first byte — so this function must unmask the first byte FIRST to learn pn_length, then unmask exactly that many packet-number bytes. Doing this in the opposite order (unmasking a guessed number of packet-number bytes before the first byte has revealed the real count) would corrupt bytes belonging to the payload rather than the packet number.

Once unmasked, the first byte's Reserved Bits are checked: RFC 9000 §17.2/§17.3.1 requires a sender to always transmit them as zero, and requires a receiver to treat a non-zero value here as a PROTOCOL_VIOLATION connection error, NOT a silently dropped packet (unlike an AEAD authentication failure, see decrypt_packet_payload's doc comment) -- mapping this error to that specific close behavior is the caller's (Phase 8+ connection-lifecycle) responsibility, same division as this module's other errors.

fn unprotect_packet #

fn unprotect_packet(mut packet []u8, pn_offset int, form HeaderForm, keys QuicPacketProtectionKeys, largest_pn ?u64) !UnprotectedPacket

unprotect_packet reverses protect_packet on a received packet: removes header protection FIRST — which is the only way to learn the packet's real packet-number length and value at all — THEN AEAD-decrypts the payload using that recovered packet number and the now-unprotected header as associated data. This is the mirror image of protect_packet's ordering, not the same steps run backwards; see unprotect_header's own doc comment for why header-then-payload is mandatory on this side specifically.

packet is mutated in place (header protection removal happens destructively). pn_offset is where the (still-protected) packet number field begins — the caller gets this from parsing the always-visible header fields (parse_long_header/parse_short_header) that precede it. largest_pn is the largest packet number already processed in this packet's number space (none if this is the first packet processed in that space) — see decode_packet_number.

On AEAD authentication failure, the caller MUST drop the packet silently rather than close the connection (see decrypt_packet_payload's doc comment) — this function only surfaces the error.

fn varint_len #

fn varint_len(value u64) !int

varint_len returns the number of bytes encode_varint would use to encode value, without actually encoding it.

fn verify_finished #

fn verify_finished(base_secret []u8, transcript_hash []u8, peer_verify_data []u8) !bool

verify_finished checks a peer-supplied Finished message's verify_data against the expected value computed from our own key schedule and transcript state, using a constant-time comparison (crypto.hmac.equal) — verify_data is exactly the kind of peer-supplied authenticator where a naive == would leak timing information about how much of it matched.

fn verify_server_certificate_chain #

fn verify_server_certificate_chain(parsed ParsedCertificate, ca_bundle_pem string, hostname string) !&VerifiedCertificateChain

verify_server_certificate_chain builds an mbedTLS certificate chain from a parsed Certificate message's certificate_list (leaf-first, per RFC 8446 §4.4.2) and validates it against ca_bundle_pem (one or more trusted CA certificates in PEM format — the caller's trust anchor, mirroring this codebase's existing net.mbedtls.SSLConnectConfig.verify contract, since there is no OS trust-store lookup anywhere in this codebase for any TLS client) AND that hostname (the SNI name this client actually sent) matches the leaf certificate's SAN/CN — see net.mbedtls.verify_certificate_chain's own doc comment for the mechanism. Without this, any otherwise-trusted certificate for an unrelated host would be accepted (hostname impersonation), since chain-of-trust alone says nothing about which host the certificate is actually FOR.

fn CertificateVerifyRole.from #

fn CertificateVerifyRole.from[W](input W) !CertificateVerifyRole

fn ClientHandshakeState.from #

fn ClientHandshakeState.from[W](input W) !ClientHandshakeState

fn HandshakeType.from #

fn HandshakeType.from[W](input W) !HandshakeType

fn HeaderForm.from #

fn HeaderForm.from[W](input W) !HeaderForm

fn LongPacketType.from #

fn LongPacketType.from[W](input W) !LongPacketType

fn Tls13ClientHandshake.start #

fn Tls13ClientHandshake.start(p ClientHandshakeParams) !(&Tls13ClientHandshake, []u8)

Tls13ClientHandshake.start generates this client's ephemeral ECDHE keypair, builds ClientHello (RFC 8446 §4.1.2), and returns both the new handshake object (state .wait_server_hello) and the ClientHello bytes to send. p.transport_parameters is validated by build_client_hello itself (rejects any server-only parameter) before anything is allocated.

fn TlsAlert.from #

fn TlsAlert.from[W](input W) !TlsAlert

type ServerHelloMessage #

type ServerHelloMessage = ParsedHelloRetryRequest | ParsedServerHello

enum CertificateVerifyRole #

enum CertificateVerifyRole {
	server
	client
}

enum ClientHandshakeState #

enum ClientHandshakeState {
	wait_server_hello
	wait_encrypted_extensions
	wait_certificate
	wait_certificate_verify
	wait_finished
	connected
}

ClientHandshakeState tracks which handshake message this client is waiting to receive next (RFC 8446 §4.1.4's message flow, minus the PSK/0-RTT-only states v1 never reaches). wait_certificate covers BOTH a Certificate and a CertificateRequest arriving next -- RFC 8446 permits either, and process_certificate_or_request distinguishes them by message type rather than the state machine splitting into two states for a message type v1 always rejects anyway.

enum HandshakeType #

enum HandshakeType {
	client_hello         = 1
	server_hello         = 2
	new_session_ticket   = 4
	end_of_early_data    = 5
	encrypted_extensions = 8
	certificate          = 11
	certificate_request  = 13
	certificate_verify   = 15
	finished             = 20
	key_update           = 24
	message_hash         = 254
}

RFC 8446 §B.3 — TLS 1.3 handshake message types. Excludes the TLS-1.2-era RESERVED values (hello_request, hello_verify_request, hello_retry_request as its own wire type — HelloRetryRequest is a ServerHello variant distinguished by its random field, not a separate msg_type — server_key_exchange, server_hello_done, client_key_exchange, certificate_url, certificate_status, supplemental_data): those can never legitimately appear on a TLS 1.3 wire, so a peer sending one is malformed input, correctly rejected by parse_handshake_message's "unknown type" path rather than accepted as a recognized-but-unused variant.

enum HeaderForm #

enum HeaderForm {
	short
	long
}

enum LongPacketType #

enum LongPacketType {
	initial
	zero_rtt
	handshake
	retry
}

enum TlsAlert #

enum TlsAlert {
	unexpected_message = 10
	handshake_failure  = 40
	bad_certificate    = 42
	illegal_parameter  = 47
	decode_error       = 50
	decrypt_error      = 51
	// RFC 7301 §3.2 (registered separately from RFC 8446's own alert
	// registry): the fatal alert a server sends when it supports none of
	// the client's offered ALPN protocols. This client's own ALPN check
	// in process_encrypted_extensions raises the same alert when the
	// SERVER'S selection isn't one this client actually offered, or ALPN
	// is missing entirely -- RFC 9001 §8.1 makes ALPN mandatory for QUIC,
	// so either case means no application protocol was agreed.
	no_application_protocol = 120
	// RFC 8446 §4.2: "Implementations MUST NOT send extension responses if
	// the remote endpoint did not send the corresponding extension
	// requests... Upon receiving such an extension, an endpoint MUST abort
	// the handshake with an 'unsupported_extension' alert." Used by
	// parse_encrypted_extensions for any extension outside the small set
	// this client can legitimately receive there (IANA TLS Alert registry
	// value 110, confirmed directly, not assumed).
	unsupported_extension = 110
	// RFC 9001 §8.2: "endpoints that receive ClientHello or
	// EncryptedExtensions messages without the quic_transport_parameters
	// extension MUST close the connection with an error of type 0x016d
	// (equivalent to a fatal TLS missing_extension alert)" -- 0x016d =
	// 0x100 + 0x6d (109), confirmed against the IANA TLS Alert registry
	// directly, not assumed.
	missing_extension = 109
}

TlsAlert is the subset of RFC 8446 §6's alert descriptions this handshake actually produces -- not the full registry, matching this module's established narrow-but-real-values convention (see tls13_client_hello.v's sig_scheme_* constants).

struct ApplicationSecrets #

struct ApplicationSecrets {
pub:
	master_secret []u8
	client_secret []u8 // client_application_traffic_secret_0
	server_secret []u8 // server_application_traffic_secret_0
}

struct CertificateEntry #

struct CertificateEntry {
pub:
	cert_data  []u8
	extensions []TlsExtension
}

CertificateEntry is one X.509 certificate plus its per-certificate extensions (RFC 8446 §4.4.2). v1 only speaks the X509 CertificateType — RawPublicKey (RFC 7250) is never negotiated (v1's EncryptedExtensions parsing doesn't send/accept the certificate-type extensions that would select it), so cert_data is always a DER-encoded X.509 certificate.

struct ClientHandshakeParams #

struct ClientHandshakeParams {
pub:
	random               []u8 // exactly 32 bytes; caller supplies so callers can use a real CSPRNG while tests stay deterministic
	server_name          string
	transport_parameters QuicTransportParameters // this client's own offered set
	ca_bundle_pem        string                  // trust anchor for the server's certificate chain
	alpn_protocols       []string                // offered application protocols, most preferred first (RFC 7301 §3.1) -- e.g. ['h3']
}

ClientHandshakeParams is everything Tls13ClientHandshake.start needs beyond what's fixed by v1's scope decisions (single cipher suite, single named group -- see tls13_client_hello.v).

struct ClientHelloParams #

struct ClientHelloParams {
pub:
	random               []u8 // exactly 32 bytes; caller supplies so callers can use a real CSPRNG while tests stay deterministic
	server_name          string
	ecdhe_public_key     []u8 // Phase 1 PublicKey.uncompressed_bytes() output, 65 bytes for P-256
	transport_parameters QuicTransportParameters
	// Application-layer protocols this client is willing to speak, most
	// preferred first (RFC 7301 §3.1) -- MANDATORY for QUIC (RFC 9001
	// §8.1: there is no fallback protocol-negotiation mechanism). v1's
	// only real caller offers exactly `['h3']`, but this stays a list
	// (not a fixed single constant) to match RFC 7301's own wire shape
	// and leave room for a future h3+h2-fallback list without another
	// signature change.
	alpn_protocols []string
}

ClientHelloParams is everything build_client_hello needs beyond what's fixed by v1's scope decisions (single cipher suite, single named group, a fixed signature_algorithms list).

struct HandshakeMessage #

struct HandshakeMessage {
pub:
	typ  HandshakeType
	body []u8
}

struct HandshakeSecrets #

struct HandshakeSecrets {
pub:
	handshake_secret []u8
	client_secret    []u8 // client_handshake_traffic_secret
	server_secret    []u8 // server_handshake_traffic_secret
}

struct InitialSecrets #

struct InitialSecrets {
pub:
	client []u8
	server []u8
}

InitialSecrets holds the client and server Initial secrets derived per RFC 9001 §5.2. These are inputs to Phase 3's packet-protection key derivation (quic_key/quic_iv/quic_hp via hkdf_expand_label), not keys themselves.

struct ParsedCertificate #

struct ParsedCertificate {
pub:
	certificate_request_context []u8
	certificate_list            []CertificateEntry
}

struct ParsedCertificateVerify #

struct ParsedCertificateVerify {
pub:
	algorithm u16
	signature []u8
}

struct ParsedHelloRetryRequest #

struct ParsedHelloRetryRequest {
pub:
	cipher_suite     u16
	selected_version u16
	// ?u16, not u16: RFC 8446 §4.1.4 lets an HRR request only a cookie
	// round-trip with no key_share at all, when the client's already-
	// offered share is acceptable to the server -- key_share is not
	// mandatory in every HelloRetryRequest, only supported_versions is.
	selected_group ?u16
	cookie         ?[]u8
	extensions     []TlsExtension
}

struct ParsedServerHello #

struct ParsedServerHello {
pub:
	random                 []u8
	cipher_suite           u16
	selected_version       u16
	key_share_group        u16
	key_share_key_exchange []u8
	extensions             []TlsExtension
}

struct PreferredAddress #

struct PreferredAddress {
pub:
	ipv4_address          [4]u8
	ipv4_port             u16
	ipv6_address          [16]u8
	ipv6_port             u16
	connection_id         []u8
	stateless_reset_token []u8 // exactly 16 bytes
}

PreferredAddress is the server-only preferred_address transport parameter's value (RFC 9000 §18.2, Figure 22). Never sent by a client; encode_transport_parameters doesn't reject a client accidentally setting it (see the doc comment there), so callers on the client side simply must not populate it.

struct QuicLongHeader #

struct QuicLongHeader {
pub mut:
	typ     LongPacketType
	version u32
	dcid    []u8
	scid    []u8
	// Present only for Initial packets (RFC 9000 §17.2.2); empty otherwise.
	// Retry packets carry a token too, but in a different position — see
	// QuicRetryHeader.
	token []u8
	// `length` is the QUIC-varint-encoded byte length of (packet number +
	// payload) that follows. Present for Initial/0-RTT/Handshake, absent for
	// Retry (which has no packet number or length field at all).
	length u64
}

QuicLongHeader represents the parsed, unprotected-portion fields of a long header packet (RFC 9000 §17.2), up to (but not including) the packet number field — the packet number's length and value are only knowable after header protection is removed, which is layered on top of this parse (Phase 3).

struct QuicPacketProtectionKeys #

struct QuicPacketProtectionKeys {
pub:
	key []u8 // quic_key -- AEAD encryption key (16 bytes)
	iv  []u8 // quic_iv -- AEAD nonce base (12 bytes)
	hp  []u8 // quic_hp -- header protection key (16 bytes)
}

QuicPacketProtectionKeys holds one encryption level's, one direction's derived key material (RFC 9001 §5.1): key/iv protect the AEAD payload, hp protects the header. A single traffic secret (e.g. one level's client_secret) yields exactly one QuicPacketProtectionKeys — client and server secrets are always different PRKs (see tls13_keyschedule.v / initial_secrets.v), so deriving from the correct side's secret is what keeps client-write and server-write keys distinct; this struct itself has no notion of "which direction" beyond whatever secret it was derived from.

struct QuicShortHeader #

struct QuicShortHeader {
pub mut:
	spin_bit  bool
	key_phase bool
	dcid      []u8
}

QuicShortHeader represents a parsed short header (1-RTT packets, RFC 9000 §17.3). The DCID's length is NOT carried on the wire — the receiver must already know it from its own connection-ID-issuance bookkeeping (see QuicConn.scids, added in Phase 9); passing the wrong length here is the single most common short-header parsing bug. spin_bit and key_phase are only meaningful after header protection removal (they live in the protected low bits of the first byte), same caveat as reserved bits above.

struct QuicTransportParameters #

struct QuicTransportParameters {
pub mut:
	original_destination_connection_id  ?[]u8
	max_idle_timeout                    ?u64
	stateless_reset_token               ?[]u8
	max_udp_payload_size                ?u64
	initial_max_data                    ?u64
	initial_max_stream_data_bidi_local  ?u64
	initial_max_stream_data_bidi_remote ?u64
	initial_max_stream_data_uni         ?u64
	initial_max_streams_bidi            ?u64
	initial_max_streams_uni             ?u64
	ack_delay_exponent                  ?u64
	max_ack_delay                       ?u64
	disable_active_migration            bool
	preferred_address                   ?PreferredAddress
	active_connection_id_limit          ?u64
	initial_source_connection_id        ?[]u8
	retry_source_connection_id          ?[]u8
}

QuicTransportParameters holds every RFC 9000 §18.2 parameter. Fields use V's ?T Optional (not RFC-matching zero-value defaults) so "not present" is unambiguous and distinct from "present with a zero/default value" — both are wire-legal and spec-equivalent, but this struct preserves which one actually happened; applying the spec's stated defaults (e.g. max_udp_payload_size's 65527, ack_delay_exponent's 3) to an absent field is a later phase's job (Phase 9's QuicConn, once it actually consumes these values), not this file's.

The four server-only parameters (original_destination_connection_id, stateless_reset_token, preferred_address, retry_source_connection_id) are included so this struct can represent EITHER side's parameter set unchanged in Phase 13's server support, per the plan's role-field design — encode_transport_parameters does not reject a client populating them (RFC 9000 §18.2's "a server MUST treat receipt of any of these as TRANSPORT_PARAMETER_ERROR" is the RECEIVING side's responsibility to enforce, not the sending side's struct shape); v1's client-side caller simply must not populate them.

struct QuicVersionNegotiation #

struct QuicVersionNegotiation {
pub mut:
	dcid     []u8
	scid     []u8
	versions []u32
}

QuicVersionNegotiation represents a parsed Version Negotiation packet (RFC 9000 §17.2.1) — distinguished by version == 0, and structurally distinct from every other long-header packet: no packet number, no Length field, no encrypted payload, just a list of the versions the server supports.

struct Tls13ClientHandshake #

struct Tls13ClientHandshake {
mut:
	state ClientHandshakeState
	// Running concatenation of every handshake message's bytes (header
	// included, RFC 8446 §4.4.1 "Messages"), in RFC 8446 §4.4.1 order --
	// no QUIC/TLS record-layer framing, since QUIC has none. Re-hashed
	// (not incrementally hashed) at each checkpoint that needs a
	// Transcript-Hash: simpler than a streaming hash state, and a full
	// handshake's messages are a handful of KB, not a hot path.
	transcript []u8
	// Set once a HelloRetryRequest is seen, so a second one can be
	// rejected (RFC 8446 §4.1.4). Note: this handshake does not yet
	// generate a valid ClientHello2 in response to a first HRR (see
	// process_server_hello's own doc comment) -- ClientHello1's bytes are
	// therefore not separately retained yet either; RFC 8446 §4.4.1's
	// synthetic message_hash substitution is a follow-up's job once HRR
	// response generation exists.
	got_hello_retry_request bool
	ecdhe_private           ecdsa.PrivateKey
	ca_bundle_pem           string
	// The SNI hostname this client sent in its own ClientHello -- retained
	// so process_certificate_or_request can pass it to
	// verify_server_certificate_chain for SAN/CN matching (RFC 6066 §3);
	// without this, chain-of-trust verification alone says nothing about
	// which host the certificate is actually FOR.
	server_name string
	// Mirrors ClientHandshakeParams.alpn_protocols -- retained so
	// process_encrypted_extensions can check the server's ALPN selection
	// (RFC 7301 §3.2) is actually one this client offered.
	alpn_protocols      []string
	handshake_secrets   HandshakeSecrets
	application_secrets ApplicationSecrets
	verified_chain      &VerifiedCertificateChain = unsafe { nil }
	// Transcript-Hash(ClientHello...Certificate) -- RFC 8446 §4.4.3's
	// "Transcript-Hash(Handshake Context, Certificate)" input to
	// certificate_verify_signed_content. Computed when Certificate is
	// processed, consumed when CertificateVerify is processed next.
	certificate_transcript_hash []u8
	// Guards free() against a second call -- see free()'s own doc comment
	// for why this can't just be "check whether ecdhe_private was already
	// freed" the way VerifiedCertificateChain.free() checks its own
	// pointer: ecdsa.PrivateKey.free() has no such self-check, and calling
	// it twice on the same value is a real, empirically-confirmed crash
	// (OpenSSL's EVP_PKEY_free aborts on a double-free of the same
	// pointer -- unlike the mbedTLS heap elsewhere in this codebase, which
	// doesn't reliably crash on a double-free of a similar size).
	freed bool
pub mut:
	peer_transport_parameters QuicTransportParameters
}

Tls13ClientHandshake drives a single QUIC-scoped TLS 1.3 client handshake (RFC 8446, restricted to the subset RFC 9001 needs) from ClientHello construction through the client's own Finished message. Callers feed it each handshake message as QUIC's CRYPTO stream delivers it (Phase 4's job, not yet built) and get back either the next message to send or an error carrying the QUIC CONNECTION_CLOSE code to use (see handshake_error). The caller MUST call free() when done, successful or not -- ecdhe_private and verified_chain both own C/OpenSSL-heap resources with no GC visibility.

ANY error from a process_* method is fatal to the whole handshake, not just to that one call: the caller must tear down the QUIC connection (using the returned error's .code(), a CONNECTION_CLOSE error per RFC 9001 §4.8) and must not call any further process_* method on this object -- only free() remains safe to call. This is not merely convention: some failure paths (e.g. a key-schedule derivation error in process_server_hello, practically unreachable given this handshake's fixed-size inputs, but not structurally excluded) already accumulate a message into the transcript before the call that can still fail, so the object's internal state is not guaranteed consistent enough to resume from after an error.

fn (Tls13ClientHandshake) state #

fn (h &Tls13ClientHandshake) state() ClientHandshakeState

state returns which handshake message this client is currently waiting to receive.

fn (Tls13ClientHandshake) application_secrets #

fn (h &Tls13ClientHandshake) application_secrets() ApplicationSecrets

application_secrets returns the derived 1-RTT traffic secrets. Only meaningful once state() == .connected -- Phase 3's job to turn these into actual AEAD keys via hkdf_expand_label's "quic key"/"quic iv" labels.

fn (Tls13ClientHandshake) handshake_secrets #

fn (h &Tls13ClientHandshake) handshake_secrets() HandshakeSecrets

handshake_secrets returns the derived Handshake-level traffic secrets. Meaningful once state() has advanced past .wait_server_hello -- unlike application_secrets, needed mid-handshake (Phase 3's job to install Handshake-level packet protection keys as soon as ServerHello arrives, before the rest of the handshake completes).

fn (Tls13ClientHandshake) free #

fn (mut h Tls13ClientHandshake) free()

free releases ecdhe_private (an OpenSSL EVP_PKEY, Phase 1's crypto.ecdsa) and verified_chain (an mbedTLS certificate chain, Phase 2c's net.mbedtls), if either was ever allocated. Idempotent: safe to call more than once -- guarded by freed rather than checking each owned resource's own pointer (VerifiedCertificateChain.free() can do that, since it nulls its own pointer after freeing, but ecdsa.PrivateKey.free() has no equivalent self-guard, so a bare second h.ecdhe_private.free() call would still double-free even if verified_chain's own check were copied here).

fn (Tls13ClientHandshake) process_server_hello #

fn (mut h Tls13ClientHandshake) process_server_hello(msg HandshakeMessage, framed_message []u8) !HandshakeSecrets

process_server_hello handles the message immediately following ClientHello, which per RFC 8446 §4.1.3/§4.1.4 is either a real ServerHello or a HelloRetryRequest (the same wire type, distinguished by a magic random value). Returns the derived Handshake-level secrets once a real ServerHello completes the ECDHE exchange; Phase 3's job to turn .client_secret/.server_secret into actual packet-protection keys, once it exists.

fn (Tls13ClientHandshake) process_encrypted_extensions #

fn (mut h Tls13ClientHandshake) process_encrypted_extensions(msg HandshakeMessage, framed_message []u8, peer_initial_scid []u8, original_dcid []u8, retry_scid ?[]u8) !

process_encrypted_extensions handles EncryptedExtensions (RFC 8446 §4.3.1), the first message protected under the Handshake-level keys.

peer_initial_scid is the Source Connection ID this client actually observed on the server's first Initial/Handshake packet -- RFC 9001 §8.2 requires the initial_source_connection_id transport parameter to match it exactly, a check this function does since it is the first point that parameter's value is available.

original_dcid is the Destination Connection ID THIS CLIENT chose for its own very first Initial packet, and retry_scid is the Retry packet's Source Connection ID if (and only if) a Retry occurred (RFC 9000 §7.3, the anti-tampering check: these two transport parameters let the client detect an off-path attacker that injected a spoofed Retry or otherwise interfered with connection establishment). All three values come from Phase 4/9 (packet headers, QuicConn), not yet built, so callers must supply them explicitly until then.

fn (Tls13ClientHandshake) process_certificate_or_request #

fn (mut h Tls13ClientHandshake) process_certificate_or_request(msg HandshakeMessage, framed_message []u8) !

process_certificate_or_request handles the message immediately following EncryptedExtensions, which per RFC 8446 §4.1.4 is either an optional CertificateRequest or the mandatory Certificate. v1 supports no client-cert auth (there is no client identity to offer), so a CertificateRequest is rejected outright here rather than answered with an empty Certificate -- PROGRESS.md's stated scope for this. The verified chain is stored internally (h.verified_chain); the caller has no other way to reach it, since that field is private -- the only observable effect of success is the state advancing to .wait_certificate_verify.

fn (Tls13ClientHandshake) process_certificate_verify #

fn (mut h Tls13ClientHandshake) process_certificate_verify(msg HandshakeMessage, framed_message []u8) !

process_certificate_verify handles CertificateVerify (RFC 8446 §4.4.3), checking the server's signature over Transcript-Hash(ClientHello...Certificate) (captured when Certificate was processed, per that message type's own §4.4.3 requirement) against the verified chain's leaf public key.

fn (Tls13ClientHandshake) process_finished #

fn (mut h Tls13ClientHandshake) process_finished(msg HandshakeMessage, framed_message []u8) !([]u8, ApplicationSecrets)

process_finished handles the server's Finished (RFC 8446 §4.4.4), verifying its verify_data against Transcript-Hash(ClientHello... CertificateVerify), then derives the application traffic secrets and computes+returns this client's own Finished message (framed, ready to send) -- both keyed off the transcript hash as it stood at the moment each computation needed it, not the final post-client-Finished transcript.

struct TlsExtension #

struct TlsExtension {
pub:
	typ  u16
	data []u8
}

TlsExtension is one parsed (type, data) entry from a generic TLS extension list (RFC 8446 §4.2).

struct UnprotectedPacket #

struct UnprotectedPacket {
pub:
	header        []u8 // the full header, through the now-plaintext packet number
	packet_number u64  // full, reconstructed packet number
	payload       []u8 // decrypted plaintext payload
}

UnprotectedPacket is the result of successfully removing both header and packet protection from one received QUIC packet.

struct VerifiedCertificateChain #

struct VerifiedCertificateChain {
mut:
	chain &C.mbedtls_x509_crt = unsafe { nil }
}

VerifiedCertificateChain wraps an mbedTLS certificate chain built from a parsed TLS 1.3 Certificate message (tls13_certificate.v's ParsedCertificate) once it has passed chain-trust validation. The caller MUST call free() when done — the underlying mbedtls_x509_crt chain holds C-heap-allocated buffers with no GC visibility (see net.mbedtls.build_certificate_chain's own doc comment).

fn (VerifiedCertificateChain) free #

fn (mut c VerifiedCertificateChain) free()

free releases the underlying mbedTLS chain. Nulls out chain after freeing so a second free() call (e.g. a defer racing an explicit early free) is a harmless no-op rather than a double-free — same discipline as net.mbedtls.SSLConn.shutdown()'s own documented guard for the identical class of repeated-cleanup-call bug.

fn (VerifiedCertificateChain) verify_certificate_verify_signature #

fn (c &VerifiedCertificateChain) verify_certificate_verify_signature(cv ParsedCertificateVerify, role CertificateVerifyRole, transcript_hash []u8) !

verify_certificate_verify_signature checks a parsed CertificateVerify message's signature against this chain's leaf certificate's public key. role/transcript_hash feed certificate_verify_signed_content's exact RFC 8446 §4.4.3 signed-content construction -- what was actually signed, not transcript_hash directly. Dispatches on cv.algorithm to the matching digest + mbedTLS verification call; parse_certificate_verify has already restricted cv.algorithm to v1's fixed offered set (sig_scheme_ecdsa_secp256r1_sha256/rsa_pss_rsae_sha256/384/512), so the else arm below is unreachable in practice, not a real fallback path.

Guards against being called after free(): mbedtls.get_leaf_public_key's own doc comment already states the precondition ("do not call this after free_certificate_chain") but doesn't enforce it -- free() nulls c.chain, and the C shim behind get_leaf_public_key computes &crt->pk (pointer arithmetic on a NULL crt), which is undefined behavior, not a clean nil dereference an or {} could catch. No caller does this today, but the upcoming client state machine will hold a VerifiedCertificateChain across multiple calls (trust check, then this), making the free-then-use ordering an easy mistake to introduce later -- same defensive rationale as free()'s own idempotency guard, just checked from the other side.