From 4165068a5ec8e137fe612d698076c1b579eaf33c Mon Sep 17 00:00:00 2001 From: Richard Wheeler Date: Mon, 24 Aug 2026 13:37:49 -0400 Subject: [PATCH 01/10] net.quic: open Phase 13 (server support), start 13a Scoping pass mapping the completed client against what server support needs is posted on the tracking issue (vlang/v#27675). Records the suggested 13a-13e sub-phase breakdown in PROGRESS.md, mirroring Phase 12's own 12a-12d convention; 13a (TLS 1.3 server handshake) starts now. --- vlib/net/quic/PROGRESS.md | 41 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/vlib/net/quic/PROGRESS.md b/vlib/net/quic/PROGRESS.md index a27335bdd14a16..56aa5c9cbb8a0b 100644 --- a/vlib/net/quic/PROGRESS.md +++ b/vlib/net/quic/PROGRESS.md @@ -1231,8 +1231,45 @@ way a closed TCP port does, so auto-racing every `https://` request against h3 would regress the common case; real happy-eyeballs-style fallback is deferred as a separate follow-up feature). -13. Server support — explicitly out of committed scope, but Phases 1-9 are - designed to need no rework for it (`role` field already present). +## Phase 13: Server support — IN PROGRESS (13a started) + +Opened following a scoping pass mapping the completed client against what +server support needs (see tracking issue #27675 comment). Phases 1-9's core +QUIC layer is already role-parameterized (`role QuicRole` on `QuicConn`; +`is_locally_initiated`/`initial_*_limit_for_stream` already take a role +explicitly) — none of that foundation needs rework. Same one-sub-phase-per- +stacked-PR convention as Phase 12's 12a-12d. + +- [ ] **13a** (in progress) — TLS 1.3 server handshake: ServerHello + + EncryptedExtensions construction, Certificate presentation, + CertificateVerify **signing** (only verify exists today), server-side + Finished, HelloRetryRequest generation (cookie extension — the client + only ever rejects a second HRR, never sends one). +- [ ] **13b** — Retry + address validation: Retry packet + opaque token + minting/validation (`retry.v` currently only verifies), RFC 9000 §8.1 + anti-amplification 3x accounting (already flagged as a deferred, + server-only branch in `loss_detection.v`/`coalesce.v`). +- [ ] **13c** — Connection ID lifecycle: `NEW_CONNECTION_ID`/ + `RETIRE_CONNECTION_ID` frames (currently fall through `frame.v`'s + generic "not yet implemented" branch), stateless reset token + generation per issued CID (`StatelessResetTracker` currently only + checks incoming tokens). +- [ ] **13d** — UDP listener + connection demux: one socket routing many + concurrent connections by connection ID (not 4-tuple, since QUIC + supports migration) — no analog in the client's transport today; the + new-connection acceptance path (unrecognized DCID → Retry-or-accept). +- [ ] **13e** — `h3_server.v` wiring, mirroring `h2_server.v`'s established + shape (minimal/serial first, concurrency as an explicit follow-up), + plus server certificate/key loading. +- [ ] *(optional, deferrable)* Connection migration (`PATH_CHALLENGE`/ + `PATH_RESPONSE`) — both unimplemented today; a minimal v1 server can + ship without full migration support, same as the client structurally + deferred it. + +Still out of scope regardless: 0-RTT (Phase 14, separate); server push stays +permanently disabled (RFC 9114 §7.2.7, a Phase 12 decision independent of +server support existing at all). + 14. 0-RTT — explicitly out of committed scope. ## Scope decisions in effect (see tracking issue for rationale) From 7b45b39bc7399d2b39e1d6dd015d6f472ce18b5a Mon Sep 17 00:00:00 2001 From: Richard Wheeler Date: Mon, 24 Aug 2026 13:52:56 -0400 Subject: [PATCH 02/10] net.quic: 13a - ServerHello + EncryptedExtensions construction First slice of the TLS 1.3 server handshake (Phase 13a): build_server_hello and build_encrypted_extensions, the server-role mirror of the existing client-role build_client_hello. Both round-trip through this file's own parse_server_hello/parse_encrypted_extensions, which is a real cross-check since build and parse were each derived independently from the RFC 8446 text rather than from each other. Caught one real bug via the round-trip test before committing: a server's key_share extension is a bare KeyShareEntry (RFC 8446 SS4.2.8), not the list-wrapped KeyShareClientHello shape build_client_hello's own encode_key_share_extension produces -- needed a dedicated encode_key_share_extension_server. Same asymmetry already existed and was handled correctly for supported_versions (bare 2 bytes server-side vs. a length-prefixed list client-side); this is the identical class for key_share, not a new discovery about the protocol. build_encrypted_extensions enforces the two mandatory transport parameters RFC 9000 SS7.3 requires an endpoint (initial_source_connection_id) and a server specifically (original_destination_connection_id) to always include, mirroring build_client_hello's identical enforcement of its own mandatory field. Still to come within 13a: Certificate presentation, CertificateVerify signing, server Finished, HelloRetryRequest generation. --- vlib/net/quic/tls13_server_hello.v | 176 ++++++++++++++++++++++++ vlib/net/quic/tls13_server_hello_test.v | 132 ++++++++++++++++++ 2 files changed, 308 insertions(+) diff --git a/vlib/net/quic/tls13_server_hello.v b/vlib/net/quic/tls13_server_hello.v index aae92a1586c687..19db8a99fd4376 100644 --- a/vlib/net/quic/tls13_server_hello.v +++ b/vlib/net/quic/tls13_server_hello.v @@ -302,6 +302,182 @@ pub fn parse_server_hello(body []u8) !ServerHelloMessage { } } +// encode_key_share_extension_server encodes the SERVER-side key_share +// payload (RFC 8446 §4.2.8): a BARE KeyShareEntry (group(2) + +// key_exchange_len(2) + key_exchange), with no outer list-length wrapper. +// This is NOT the same shape as tls13_client_hello.v's +// encode_key_share_extension, which wraps its entry in an extra +// client_shares<0..2^16-1> list-length prefix -- the two directions share an +// extension type but not a wire shape, the same asymmetry +// encode_supported_versions_extension_server documents for +// supported_versions. Confirmed against parse_server_hello's own parsing +// (this file), which reads ks_ext.data[0..2] directly as the group with no +// list-length prefix to skip first. +fn encode_key_share_extension_server(group u16, key_exchange []u8) ![]u8 { + if key_exchange.len == 0 || key_exchange.len > 0xffff - 4 { + return error('quic: key_exchange length ${key_exchange.len} out of range') + } + mut data := []u8{} + data << u8(group >> 8) + data << u8(group) + data << u8(key_exchange.len >> 8) + data << u8(key_exchange.len) + data << key_exchange + return encode_extension(ext_key_share, data) +} + +// encode_supported_versions_extension_server encodes the SERVER-side +// supported_versions payload (RFC 8446 §4.2.1): a bare 2-byte +// selected_version, not the ClientHello's length-prefixed version list -- +// see parse_supported_versions_from_server's identical distinction on the +// parse side. v1 only ever selects TLS 1.3, matching the single version +// build_client_hello offers. +fn encode_supported_versions_extension_server() ![]u8 { + mut data := []u8{} + data << u8(tls_version_1_3 >> 8) + data << u8(tls_version_1_3) + return encode_extension(ext_supported_versions, data) +} + +// ServerHelloParams is everything build_server_hello needs beyond what's +// fixed by v1's scope decisions (single cipher suite, single selected +// version, single named group). +pub struct ServerHelloParams { +pub: + // Exactly 32 bytes. Caller supplies so a real caller can use a genuine + // CSPRNG while tests stay deterministic -- same convention as + // ClientHelloParams.random. MUST NOT equal the RFC 8446 §4.1.3 magic + // HelloRetryRequest value; a caller that wants to send an HRR uses + // build_hello_retry_request (below) instead, never this function with a + // hand-picked random. + random []u8 + // This SERVER's own ephemeral ECDHE public key for the selected group + // (Phase 1 PublicKey.uncompressed_bytes() output, 65 bytes for P-256). + // v1 only ever selects named_group_secp256r1, matching the single group + // build_client_hello offers -- a real caller has already confirmed the + // ClientHello's own key_share offered this group before calling here. + ecdhe_public_key []u8 +} + +// build_server_hello constructs a complete, real (non-HelloRetryRequest) +// TLS 1.3 ServerHello handshake message (RFC 8446 §4.1.3), framed via +// encode_handshake_message. Sends exactly two extensions: supported_versions +// and key_share -- the full server_hello_allowed set this same file's +// parse_server_hello enforces on the client side, kept in sync by +// construction rather than duplicated as a separate list. legacy_session_id +// is always echoed as empty: RFC 9001 §8.4 states a server "SHOULD treat the +// receipt of a TLS ClientHello with a non-empty legacy_session_id field as a +// connection error" -- a spec-compliant server that reached this point has +// already rejected any handshake where the client sent a non-empty session +// ID, so there is never a non-empty value to echo back. +pub fn build_server_hello(p ServerHelloParams) ![]u8 { + if p.random.len != 32 { + return error('quic: ServerHello random must be exactly 32 bytes, got ${p.random.len}') + } + if p.random == hello_retry_request_random[..] { + return error('quic: ServerHello random must not equal the RFC 8446 §4.1.3 HelloRetryRequest magic value -- use build_hello_retry_request to send an HRR') + } + + mut body := []u8{} + // legacy_version MUST be 0x0303 (RFC 8446 §4.1.3), matching + // build_client_hello's identical fixed value -- the real version is + // negotiated via supported_versions below. + body << u8(0x03) + body << u8(0x03) + body << p.random + // legacy_session_id_echo: always empty, see the doc comment above. + body << u8(0) + body << u8(cipher_suite_tls_aes_128_gcm_sha256 >> 8) + body << u8(cipher_suite_tls_aes_128_gcm_sha256) + // legacy_compression_method MUST be 0 (null), RFC 8446 §4.1.3. + body << u8(0) + + mut extensions := []u8{} + extensions << encode_key_share_extension_server(named_group_secp256r1, p.ecdhe_public_key)! + extensions << encode_supported_versions_extension_server()! + + if extensions.len > 0xffff { + return error('quic: ServerHello extensions block too large: ${extensions.len} bytes') + } + body << u8(extensions.len >> 8) + body << u8(extensions.len) + body << extensions + + return encode_handshake_message(.server_hello, body)! +} + +// EncryptedExtensionsParams is everything build_encrypted_extensions needs. +pub struct EncryptedExtensionsParams { +pub: + // This SERVER's own transport parameters (RFC 9001 §8.2). Every field is + // the server's own value, not the client's -- e.g. + // initial_max_stream_data_bidi_local/_remote here describe streams from + // THIS server's perspective, resolved the same way flow_control.v's + // initial_send_limit_for_stream/initial_receive_limit_for_stream already + // do for the connection's actual flow-control windows. + transport_parameters QuicTransportParameters + // The single protocol this server selected from the client's ALPN offer + // list (RFC 7301 §3.2: "the server SHALL include only one protocol name + // in the ProtocolNameList"). Never empty -- a server with no matching + // protocol MUST fail the handshake with no_application_protocol (RFC + // 9001 §8.1) rather than reach this function at all. + selected_alpn string + // True when the ClientHello carried a server_name extension this server + // wants to acknowledge. RFC 6066 §3: the acknowledgement's + // extension_data is always empty -- this server never echoes the + // hostname back, matching what parse_encrypted_extensions (this same + // file, client-role) already requires of a peer. + acknowledge_server_name bool +} + +// build_encrypted_extensions constructs a complete EncryptedExtensions +// handshake message (RFC 8446 §4.3.1: a length-prefixed extension list, +// nothing else), framed via encode_handshake_message. Sends only extensions +// this client's own parse_encrypted_extensions (this same file) actually +// permits -- alpn and quic_transport_parameters unconditionally, +// server_name only when acknowledging one. supported_groups is +// deliberately never sent: v1 offers no session resumption or 0-RTT (Phase +// 14, out of scope), so there is nothing for a future-connection group hint +// to usefully inform. +pub fn build_encrypted_extensions(p EncryptedExtensionsParams) ![]u8 { + if p.selected_alpn.len == 0 { + return error('quic: EncryptedExtensions must select exactly one ALPN protocol (RFC 7301 §3.2) -- a server with no match must fail the handshake before reaching here (RFC 9001 §8.1)') + } + // RFC 9000 §7.3 / §18.2: "An endpoint MUST treat the absence of the + // initial_source_connection_id transport parameter from either endpoint + // ... as a connection error of type TRANSPORT_PARAMETER_ERROR" -- this + // server's own SCID choice, mirroring build_client_hello's identical + // check for the client's own value. + if p.transport_parameters.initial_source_connection_id == none { + return error('quic: EncryptedExtensions transport parameters must include initial_source_connection_id (RFC 9000 §7.3)') + } + // RFC 9000 §7.3/§18.2: "...or the absence of the + // original_destination_connection_id transport parameter from the + // server as a connection error of type TRANSPORT_PARAMETER_ERROR" -- + // unlike initial_source_connection_id, this one is server-only and has + // no client-side analog to mirror. + if p.transport_parameters.original_destination_connection_id == none { + return error('quic: EncryptedExtensions transport parameters must include original_destination_connection_id (RFC 9000 §7.3, server-only)') + } + + mut extensions := []u8{} + if p.acknowledge_server_name { + extensions << encode_extension(ext_server_name, []u8{})! + } + extensions << encode_alpn_extension([p.selected_alpn])! + extensions << encode_quic_transport_parameters_extension(p.transport_parameters)! + + if extensions.len > 0xffff { + return error('quic: EncryptedExtensions block too large: ${extensions.len} bytes') + } + mut body := []u8{} + body << u8(extensions.len >> 8) + body << u8(extensions.len) + body << extensions + + return encode_handshake_message(.encrypted_extensions, body)! +} + // encrypted_extensions_allowed is the intersection of RFC 8446 §4.2's own // per-message applicability table (only server_name, max_fragment_length, // supported_groups, use_srtp, heartbeat, alpn, client_certificate_type, diff --git a/vlib/net/quic/tls13_server_hello_test.v b/vlib/net/quic/tls13_server_hello_test.v index df8992361dea30..d41d04dd06f098 100644 --- a/vlib/net/quic/tls13_server_hello_test.v +++ b/vlib/net/quic/tls13_server_hello_test.v @@ -485,3 +485,135 @@ fn test_parse_server_hello_hello_retry_request_rejects_unsolicited_extension() { } assert false, 'expected an error for an unsolicited alpn extension in HelloRetryRequest' } + +// build_server_hello / build_encrypted_extensions (Phase 13a, server-role +// construction). Round-tripped through this same file's own parse +// functions, which is a real cross-check: build_server_hello and +// parse_server_hello are independently written against the RFC text, not +// against each other, so a round trip that only ever validated the wire +// shape it itself produced would prove nothing. + +fn test_build_server_hello_round_trips_through_parse_server_hello() { + random := []u8{len: 32, init: 0x11} + key := []u8{len: 65, init: 0x04} + msg := build_server_hello(random: random, ecdhe_public_key: key)! + parsed_msg, consumed := parse_handshake_message(msg)! + assert consumed == msg.len + assert parsed_msg.typ == .server_hello + result := parse_server_hello(parsed_msg.body)! + match result { + ParsedServerHello { + assert result.random == random + assert result.cipher_suite == cipher_suite_tls_aes_128_gcm_sha256 + assert result.selected_version == tls_version_1_3 + assert result.key_share_group == named_group_secp256r1 + assert result.key_share_key_exchange == key + assert result.extensions.len == 2 + } + ParsedHelloRetryRequest { + assert false, 'expected a real ServerHello, not HRR' + } + } +} + +fn test_build_server_hello_rejects_wrong_random_length() { + build_server_hello(random: []u8{len: 31}, ecdhe_public_key: []u8{len: 65}) or { + assert err.msg().contains('32 bytes') + return + } + assert false, 'expected an error for a 31-byte random' +} + +// A real caller must never be able to accidentally produce a ServerHello +// that a peer would interpret as a HelloRetryRequest -- the two share a +// wire type, distinguished ONLY by this exact 32-byte value (RFC 8446 +// §4.1.3), so colliding with it by construction (e.g. a broken RNG, or a +// test fixture reusing the constant) must be caught here rather than +// silently producing an ambiguous message. +fn test_build_server_hello_rejects_hello_retry_request_random_collision() { + build_server_hello(random: hello_retry_request_random[..].clone(), ecdhe_public_key: []u8{len: 65}) or { + assert err.msg().contains('HelloRetryRequest') + return + } + assert false, 'expected an error when random collides with the HelloRetryRequest magic value' +} + +fn test_build_encrypted_extensions_round_trips_through_parse_encrypted_extensions() { + params := QuicTransportParameters{ + initial_source_connection_id: []u8{len: 8, init: 0xaa} + original_destination_connection_id: []u8{len: 8, init: 0xbb} + } + msg := build_encrypted_extensions( + transport_parameters: params + selected_alpn: 'h3' + acknowledge_server_name: true + )! + parsed_msg, consumed := parse_handshake_message(msg)! + assert consumed == msg.len + assert parsed_msg.typ == .encrypted_extensions + extensions := parse_encrypted_extensions(parsed_msg.body)! + assert extensions.len == 3 + + sn := find_extension(extensions, ext_server_name) or { panic('missing server_name') } + assert sn.data.len == 0 + + alpn_ext := find_extension(extensions, ext_alpn) or { panic('missing alpn') } + assert decode_alpn_response(alpn_ext.data)! == 'h3' + + tp_ext := find_extension(extensions, ext_quic_transport_parameters) or { + panic('missing quic_transport_parameters') + } + decoded := decode_transport_parameters(tp_ext.data)! + assert decoded.initial_source_connection_id? == []u8{len: 8, init: 0xaa} + assert decoded.original_destination_connection_id? == []u8{len: 8, init: 0xbb} +} + +fn test_build_encrypted_extensions_omits_server_name_when_not_acknowledging() { + params := QuicTransportParameters{ + initial_source_connection_id: []u8{len: 8, init: 0xaa} + original_destination_connection_id: []u8{len: 8, init: 0xbb} + } + msg := build_encrypted_extensions(transport_parameters: params, selected_alpn: 'h3')! + _, consumed := parse_handshake_message(msg)! + assert consumed == msg.len + parsed_msg, _ := parse_handshake_message(msg)! + extensions := parse_encrypted_extensions(parsed_msg.body)! + assert extensions.len == 2 + if _ := find_extension(extensions, ext_server_name) { + assert false, 'server_name must be absent when acknowledge_server_name is false' + } +} + +fn test_build_encrypted_extensions_requires_selected_alpn() { + params := QuicTransportParameters{ + initial_source_connection_id: []u8{len: 8, init: 0xaa} + original_destination_connection_id: []u8{len: 8, init: 0xbb} + } + build_encrypted_extensions(transport_parameters: params, selected_alpn: '') or { + assert err.msg().contains('ALPN') + return + } + assert false, 'expected an error for an empty selected_alpn' +} + +fn test_build_encrypted_extensions_requires_initial_source_connection_id() { + params := QuicTransportParameters{ + original_destination_connection_id: []u8{len: 8, init: 0xbb} + } + build_encrypted_extensions(transport_parameters: params, selected_alpn: 'h3') or { + assert err.msg().contains('initial_source_connection_id') + return + } + assert false, 'expected an error for a missing initial_source_connection_id' +} + +fn test_build_encrypted_extensions_requires_original_destination_connection_id() { + params := QuicTransportParameters{ + initial_source_connection_id: []u8{len: 8, init: 0xaa} + } + build_encrypted_extensions(transport_parameters: params, selected_alpn: 'h3') or { + assert err.msg().contains('original_destination_connection_id') + return + } + assert false, 'expected an error for a missing original_destination_connection_id' +} From 47254d7deae8ee8e4d2e98a92cae15ea571aa3fe Mon Sep 17 00:00:00 2001 From: Richard Wheeler Date: Mon, 24 Aug 2026 14:07:54 -0400 Subject: [PATCH 03/10] net.quic: 13a - Certificate, CertificateVerify signing, Finished, HRR Completes the message-construction half of 13a's TLS 1.3 server handshake: - encode_certificate (tls13_certificate.v): server Certificate message, reusing the existing CertificateEntry/ParsedCertificate types. certificate_ request_context is always empty per RFC 8446 SS4.4.2 ("in the case of server authentication, this field SHALL be zero length"). - encode_certificate_verify (tls13_certificate.v): signs certificate_verify_signed_content(.server, ...) via crypto.ecdsa.PrivateKey. sign() -- a pre-existing V primitive, untouched by net.quic work so far. Only sig_scheme_ecdsa_secp256r1_sha256 is wired up; RSA-PSS signing is explicitly rejected with a clear error rather than silently mis-signing -- it needs a mbedtls_pk_sign_ext V wrapper that doesn't exist yet (only the verify side, verify_rsa_pss_signature, does). - build_finished (tls13_messages.v): thin wrapper around the already side-agnostic compute_finished_verify_data. Verified against the real RFC 8448 SS3 vector, not just round-tripped against this module's own parser. - build_hello_retry_request (tls13_server_hello.v): shares ServerHello's wire type, distinguished by the fixed magic random. key_share carries a bare NamedGroup (RFC 8446 SS4.2.8's KeyShareHelloRetryRequest) -- a third, distinct wire shape from both the client's and the real-ServerHello key_share encodings. Every function round-trips through its already-existing, independently- written parse counterpart -- a real cross-check, not tautological, since build and parse were each derived from the RFC text separately. Full net.quic suite 54/54, ./vnew missdoc clean, ./vnew fmt -w applied. Certificate/CertificateVerify signature framing is verified; the produced ECDSA signature's cryptographic validity is not cross-verified against an independent verifier in this repo (no PublicKey.verify() in crypto.ecdsa, no EC certificate fixture) -- the same documented gap Phase 2c's own x509_standalone_signature_test.v already states for the identical reason. Still to come within 13a: the server-side state machine wiring these five functions into an actual handshake driver (see PROGRESS.md). --- vlib/net/quic/PROGRESS.md | 27 ++++-- vlib/net/quic/tls13_certificate.v | 115 ++++++++++++++++++++++++ vlib/net/quic/tls13_certificate_test.v | 95 ++++++++++++++++++++ vlib/net/quic/tls13_messages.v | 14 +++ vlib/net/quic/tls13_messages_test.v | 16 ++++ vlib/net/quic/tls13_server_hello.v | 81 +++++++++++++++++ vlib/net/quic/tls13_server_hello_test.v | 59 +++++++++++- 7 files changed, 401 insertions(+), 6 deletions(-) diff --git a/vlib/net/quic/PROGRESS.md b/vlib/net/quic/PROGRESS.md index 56aa5c9cbb8a0b..fde9b54f967e21 100644 --- a/vlib/net/quic/PROGRESS.md +++ b/vlib/net/quic/PROGRESS.md @@ -1240,11 +1240,28 @@ QUIC layer is already role-parameterized (`role QuicRole` on `QuicConn`; explicitly) — none of that foundation needs rework. Same one-sub-phase-per- stacked-PR convention as Phase 12's 12a-12d. -- [ ] **13a** (in progress) — TLS 1.3 server handshake: ServerHello + - EncryptedExtensions construction, Certificate presentation, - CertificateVerify **signing** (only verify exists today), server-side - Finished, HelloRetryRequest generation (cookie extension — the client - only ever rejects a second HRR, never sends one). +- [ ] **13a** (in progress) — TLS 1.3 server handshake: + - [x] Message construction, all five pieces (`tls13_server_hello.v`, + `tls13_certificate.v`, `tls13_messages.v`): `build_server_hello`, + `build_encrypted_extensions`, `encode_certificate`, + `encode_certificate_verify` (ECDSA P-256 signing only — + `sig_scheme_ecdsa_secp256r1_sha256`; RSA-PSS signing needs a + `mbedtls_pk_sign_ext` V wrapper that doesn't exist yet, only the + verify side does), `build_finished` (verified against the real RFC + 8448 §3 vector, not just round-tripped against this module's own + parser), `build_hello_retry_request`. Every function round-trips + through its ALREADY-EXISTING, independently-written parse + counterpart — a real cross-check, not tautological. Caught one + real bug this way before commit: a server's `key_share` is a bare + `KeyShareEntry` (RFC 8446 §4.2.8), not the client's list-wrapped + shape. + - [ ] Server-side state machine (mirroring `Tls13ClientHandshake` in + `tls13_handshake.v`) — orchestrating the above into an actual + handshake driver: deciding when to send Certificate vs. reuse + cached state, whether to send an HRR, deriving/tracking the + transcript hash across all these messages, discarding keys at the + right checkpoints. NOT started — none of the five functions above + are wired into anything yet. - [ ] **13b** — Retry + address validation: Retry packet + opaque token minting/validation (`retry.v` currently only verifies), RFC 9000 §8.1 anti-amplification 3x accounting (already flagged as a deferred, diff --git a/vlib/net/quic/tls13_certificate.v b/vlib/net/quic/tls13_certificate.v index e1990ed1e6bb71..86788e6dbf3d0b 100644 --- a/vlib/net/quic/tls13_certificate.v +++ b/vlib/net/quic/tls13_certificate.v @@ -1,5 +1,7 @@ module quic +import crypto.ecdsa + // 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 @@ -113,6 +115,66 @@ pub fn parse_certificate(body []u8) !ParsedCertificate { } } +// encode_certificate constructs a complete Certificate handshake message +// (RFC 8446 §4.4.2), framed via encode_handshake_message. `certificate_list` +// is this server's own certificate chain, leaf-first (RFC 8446 §4.4.2's own +// implicit ordering -- the peer's chain-validation walk, mirrored by this +// codebase's own verify_certificate_chain, always treats the first entry as +// the leaf). certificate_request_context is always encoded as empty: RFC +// 8446 §4.4.2 states it is only non-empty "if this message is in response +// to a CertificateRequest" -- "Otherwise (in the case of server +// authentication), this field SHALL be zero length" -- and v1 is +// server-authentication-only (client-cert auth is out of scope), so this +// function never takes a caller-supplied context, the same scope +// restriction parse_certificate's own doc comment already states for the +// parse side. +pub fn encode_certificate(certificate_list []CertificateEntry) ![]u8 { + // RFC 8446 §4.4.2.4 (quoted in parse_certificate's own doc comment): + // "the server MUST always provide a non-empty certificate_list" -- + // enforced here on the encode side too, not just checked on the way + // back in when a peer's Certificate is parsed. + if certificate_list.len == 0 { + return error('quic: Certificate certificate_list must not be empty (server certificate_list MUST always be non-empty, RFC 8446 §4.4.2.4)') + } + + mut body := []u8{} + body << u8(0) // certificate_request_context: always empty, see doc comment above + + mut list := []u8{} + for entry in certificate_list { + if entry.cert_data.len == 0 || entry.cert_data.len > 0xff_ffff { + return error('quic: CertificateEntry cert_data length ${entry.cert_data.len} out of range (opaque cert_data<1..2^24-1>)') + } + list << u8(entry.cert_data.len >> 16) + list << u8(entry.cert_data.len >> 8) + list << u8(entry.cert_data.len) + list << entry.cert_data + // parse_certificate's own doc comment establishes that this + // client's ClientHello offers neither status_request nor + // signed_certificate_timestamp, so the only RFC 8446 §4.2-legal + // CertificateEntry extensions for THIS codebase's peer are illegal + // to send here (RFC 8446 §4.4.2: "Extensions in the Certificate + // message from the server MUST correspond to ones from the + // ClientHello message") -- enforced here too, not just on the + // parse side, so a caller can never accidentally construct a + // message a compliant peer would reject. + if entry.extensions.len != 0 { + return error('quic: CertificateEntry.extensions must be empty -- this server never negotiates status_request or signed_certificate_timestamp (RFC 8446 §4.4.2)') + } + list << u8(0) // extensions length: always 0, see above + list << u8(0) + } + if list.len > 0xff_ffff { + return error('quic: Certificate certificate_list too large: ${list.len} bytes') + } + body << u8(list.len >> 16) + body << u8(list.len >> 8) + body << u8(list.len) + body << list + + return encode_handshake_message(.certificate, body)! +} + pub struct ParsedCertificateVerify { pub: algorithm u16 @@ -201,3 +263,56 @@ pub fn certificate_verify_signed_content(role CertificateVerifyRole, transcript_ out << transcript_hash return out } + +// encode_certificate_verify constructs a complete CertificateVerify +// handshake message (RFC 8446 §4.4.3) by SIGNING +// certificate_verify_signed_content(.server, transcript_hash) with +// `signing_key`, then framing the result via encode_handshake_message. v1 +// is server-authentication-only (client CertificateVerify is never sent), +// so this function always signs the `.server` context -- see +// certificate_verify_signed_content's own doc comment for why the `.client` +// variant exists at all without a real caller. +// +// Only sig_scheme_ecdsa_secp256r1_sha256 is wired up so far: rejected with +// a clear "not implemented yet" error for any other algorithm rather than +// silently producing a signature under the wrong scheme -- RSA-PSS signing +// needs a still-missing mbedtls_pk_sign_ext V wrapper (only the verify side, +// verify_rsa_pss_signature in net.mbedtls, exists today), tracked as +// follow-up work within 13a, not built here. +// +// `signing_key` MUST be a P-256 (prime256v1) key -- the only curve this +// codebase's own key generation/loading ever produces (Phase 1's scope +// decision, `crypto.ecdsa`'s CurveOptions defaults to prime256v1 and no v1 +// caller ever overrides it). crypto.ecdsa exposes no curve accessor to +// verify this defensively at the V level; behavior for a caller-supplied +// non-P-256 key is undefined by construction, not validated here -- the +// same trust boundary this function's own signing_key parameter implies +// for any local, non-peer-supplied cryptographic material. +pub fn encode_certificate_verify(algorithm u16, signing_key ecdsa.PrivateKey, transcript_hash []u8) ![]u8 { + if algorithm != sig_scheme_ecdsa_secp256r1_sha256 { + return error('quic: CertificateVerify signing for algorithm 0x${algorithm:04x} is not implemented yet (only ecdsa_secp256r1_sha256 is wired up)') + } + + content := certificate_verify_signed_content(.server, transcript_hash) + // PrivateKey.sign's default hash_config (.with_recommended_hash) picks + // SHA-256 for a 256-bit (P-256) key -- see default_digest in + // vlib/crypto/ecdsa/ecdsa.v, keyed off the key's own bit size, matching + // exactly what sig_scheme_ecdsa_secp256r1_sha256 requires. The + // resulting signature is OpenSSL's standard ASN.1 DER ECDSA-Sig-Value + // encoding, the same format net.mbedtls's verify_ecdsa_signature (used + // on the client-side verify path, tls13_certificate_chain.c.v) already + // parses -- no reformatting needed between the two libraries. + signature := signing_key.sign(content, hash_config: .with_recommended_hash)! + + mut body := []u8{} + body << u8(algorithm >> 8) + body << u8(algorithm) + if signature.len > 0xffff { + return error('quic: CertificateVerify signature too large: ${signature.len} bytes') + } + body << u8(signature.len >> 8) + body << u8(signature.len) + body << signature + + return encode_handshake_message(.certificate_verify, body)! +} diff --git a/vlib/net/quic/tls13_certificate_test.v b/vlib/net/quic/tls13_certificate_test.v index bcbb23532460a5..c70a3e6e756796 100644 --- a/vlib/net/quic/tls13_certificate_test.v +++ b/vlib/net/quic/tls13_certificate_test.v @@ -2,6 +2,7 @@ module quic import encoding.hex +import crypto.ecdsa // RFC 8446 §4.4.3's own worked example: transcript hash = 32 bytes of // 0x01, server context -> this exact 130-byte signed content. Extracted @@ -220,3 +221,97 @@ fn test_parse_certificate_verify_rejects_truncated_header() { } assert false, 'expected an error for a header shorter than 4 bytes' } + +// encode_certificate / encode_certificate_verify (Phase 13a, server-role +// construction). Round-tripped through this same file's own +// parse_certificate/parse_certificate_verify, the same real cross-check +// discipline as tls13_server_hello_test.v's build_server_hello tests. + +fn test_encode_certificate_round_trips_through_parse_certificate() { + entries := [ + CertificateEntry{ + cert_data: []u8{len: 300, init: 0x30} + }, + CertificateEntry{ + cert_data: []u8{len: 150, init: 0x31} + }, + ] + msg := encode_certificate(entries)! + parsed_msg, consumed := parse_handshake_message(msg)! + assert consumed == msg.len + assert parsed_msg.typ == .certificate + result := parse_certificate(parsed_msg.body)! + assert result.certificate_request_context.len == 0 + assert result.certificate_list.len == 2 + assert result.certificate_list[0].cert_data == entries[0].cert_data + assert result.certificate_list[1].cert_data == entries[1].cert_data +} + +fn test_encode_certificate_rejects_empty_list() { + encode_certificate([]CertificateEntry{}) or { + assert err.msg().contains('must not be empty') + return + } + assert false, 'expected an error for an empty certificate_list' +} + +fn test_encode_certificate_rejects_entry_with_extensions() { + entries := [ + CertificateEntry{ + cert_data: []u8{len: 10, init: 0x30} + extensions: [TlsExtension{ + typ: 0x1234 + data: []u8{} + }] + }, + ] + encode_certificate(entries) or { + assert err.msg().contains('extensions must be empty') + return + } + assert false, 'expected an error for a CertificateEntry carrying extensions' +} + +// test_encode_certificate_verify_round_trips_through_parse_certificate_verify +// verifies the WIRE FRAMING (algorithm field, signature length prefix) is +// correct and that the signature this function produces is plausible ECDSA +// DER output -- non-empty, and different for different transcript hashes +// (a constant/garbage signature would fail this). It does NOT +// cryptographically verify the signature against the public key: this +// codebase has no PublicKey.verify() exposed by crypto.ecdsa and no EC +// certificate fixture to build an mbedtls_pk_context from, the SAME +// documented gap Phase 2c's own x509_standalone_signature_test.v already +// states ("No EC private key exists anywhere in this repo, so the ECDSA +// path is tested only via rejecting an incompatible key") -- not silently +// skipped, stated here for the same reason. +fn test_encode_certificate_verify_round_trips_through_parse_certificate_verify() { + _, priv_key := ecdsa.generate_key()! + transcript_hash := []u8{len: 32, init: 0x01} + + msg := encode_certificate_verify(sig_scheme_ecdsa_secp256r1_sha256, priv_key, transcript_hash)! + parsed_msg, consumed := parse_handshake_message(msg)! + assert consumed == msg.len + assert parsed_msg.typ == .certificate_verify + + result := parse_certificate_verify(parsed_msg.body)! + assert result.algorithm == sig_scheme_ecdsa_secp256r1_sha256 + assert result.signature.len > 0 + + other_transcript_hash := []u8{len: 32, init: 0x02} + other_msg := encode_certificate_verify(sig_scheme_ecdsa_secp256r1_sha256, priv_key, + other_transcript_hash)! + _, other_consumed := parse_handshake_message(other_msg)! + other_parsed, _ := parse_handshake_message(other_msg)! + other_result := parse_certificate_verify(other_parsed.body)! + assert other_consumed == other_msg.len + assert other_result.signature != result.signature +} + +fn test_encode_certificate_verify_rejects_unimplemented_algorithm() { + _, priv_key := ecdsa.generate_key()! + encode_certificate_verify(sig_scheme_rsa_pss_rsae_sha256, priv_key, []u8{len: 32}) or { + assert err.msg().contains('not implemented yet') + return + } + assert false, 'expected an error for an unimplemented signing algorithm' +} diff --git a/vlib/net/quic/tls13_messages.v b/vlib/net/quic/tls13_messages.v index 46913d7e75cf6f..b45c01187d4ee7 100644 --- a/vlib/net/quic/tls13_messages.v +++ b/vlib/net/quic/tls13_messages.v @@ -117,6 +117,20 @@ pub fn compute_finished_verify_data(base_secret []u8, transcript_hash []u8) ![]u return hmac.new(finished_key, transcript_hash, sha256.sum256, sha256.block_size) } +// build_finished constructs a complete Finished handshake message (RFC 8446 +// §4.4.4) from `base_secret`/`transcript_hash`, framed via +// encode_handshake_message. Side-agnostic like compute_finished_verify_data +// itself, which this function wraps directly -- the caller picks which +// traffic secret to sign with (client_handshake_traffic_secret for the +// client's own Finished, server_handshake_traffic_secret for the server's) +// and which transcript_hash checkpoint applies; see +// compute_finished_verify_data's own doc comment for the exact checkpoint +// each side uses. +pub fn build_finished(base_secret []u8, transcript_hash []u8) ![]u8 { + verify_data := compute_finished_verify_data(base_secret, transcript_hash)! + return encode_handshake_message(.finished, verify_data)! +} + // 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 diff --git a/vlib/net/quic/tls13_messages_test.v b/vlib/net/quic/tls13_messages_test.v index 6cb0352e2315db..76a332c7626a6d 100644 --- a/vlib/net/quic/tls13_messages_test.v +++ b/vlib/net/quic/tls13_messages_test.v @@ -174,3 +174,19 @@ fn test_verify_finished_rejects_stale_transcript_hash() { peer_verify_data := hex.decode(rfc8448_server_verify_data)! assert verify_finished(base_secret, stale_transcript_hash, peer_verify_data)! == false } + +// build_finished (Phase 13a, server-role construction) -- tested against +// the SAME real RFC 8448 §3 vector the functions above already use, not +// just round-tripped against this module's own code, since this function +// is a thin wrapper around already-vector-verified +// compute_finished_verify_data. +fn test_build_finished_matches_rfc8448_vector() { + base_secret := hex.decode(rfc8448_server_hs_traffic_finished)! + transcript_hash := hex.decode(rfc8448_transcript_hash_ch_thru_certverify)! + msg := build_finished(base_secret, transcript_hash)! + + parsed_msg, consumed := parse_handshake_message(msg)! + assert consumed == msg.len + assert parsed_msg.typ == .finished + assert parsed_msg.body == hex.decode(rfc8448_server_verify_data)! +} diff --git a/vlib/net/quic/tls13_server_hello.v b/vlib/net/quic/tls13_server_hello.v index 19db8a99fd4376..43eb4baf9bf97d 100644 --- a/vlib/net/quic/tls13_server_hello.v +++ b/vlib/net/quic/tls13_server_hello.v @@ -406,6 +406,87 @@ pub fn build_server_hello(p ServerHelloParams) ![]u8 { return encode_handshake_message(.server_hello, body)! } +// HelloRetryRequestParams is everything build_hello_retry_request needs. +// Both fields are optional -- only supported_versions is mandatory in a +// real HelloRetryRequest (RFC 8446 §4.1.4), mirroring exactly what this +// same file's ParsedHelloRetryRequest.selected_group/cookie already model +// on the parse side. +pub struct HelloRetryRequestParams { +pub: + // Present when this server is requesting a DIFFERENT group than the one + // the client's ClientHello key_share offered (RFC 8446 §4.1.4: "the + // server corrects the mismatch with a HelloRetryRequest"). None when + // the HRR is purely a cookie round-trip and the client's + // already-offered key_share is acceptable to this server. + selected_group ?u16 + // Present when this server wants a stateless retry cookie round-trip + // (RFC 8446 §4.2.2) instead of holding per-connection state across the + // two ClientHellos this exchange produces. + cookie ?[]u8 +} + +// build_hello_retry_request constructs a complete HelloRetryRequest +// handshake message (RFC 8446 §4.1.4), framed via encode_handshake_message. +// A HelloRetryRequest shares ServerHello's wire TYPE but is distinguished +// by the fixed hello_retry_request_random magic value (this same file) in +// place of a genuine random -- callers must never call build_server_hello +// to send one (that function explicitly rejects this exact value). +// key_share, when present, carries only a bare NamedGroup (RFC 8446 §4.2.8's +// KeyShareHelloRetryRequest), NOT a full KeyShareEntry -- a different, third +// wire shape from both build_server_hello's real-ServerHello key_share +// (encode_key_share_extension_server) and build_client_hello's +// (encode_key_share_extension), matching what this file's own +// parse_server_hello already expects for the HRR branch. +pub fn build_hello_retry_request(p HelloRetryRequestParams) ![]u8 { + mut body := []u8{} + body << u8(0x03) + body << u8(0x03) + body << hello_retry_request_random[..].clone() + // legacy_session_id_echo: always empty, see build_server_hello's doc + // comment (RFC 9001 §8.4). + body << u8(0) + body << u8(cipher_suite_tls_aes_128_gcm_sha256 >> 8) + body << u8(cipher_suite_tls_aes_128_gcm_sha256) + body << u8(0) // legacy_compression_method + + mut extensions := []u8{} + extensions << encode_supported_versions_extension_server()! + if group := p.selected_group { + mut data := []u8{} + data << u8(group >> 8) + data << u8(group) + extensions << encode_extension(ext_key_share, data)! + } + if cookie := p.cookie { + // RFC 8446 §4.2.2: `opaque cookie<1..2^16-1>` -- same overhead-aware + // bound style as encode_server_name_extension/ + // encode_key_share_extension (this module), checked against the + // 2-byte length-prefix overhead this extension's own inner + // structure adds on top of the raw cookie bytes. + if cookie.len == 0 || cookie.len > 0xffff - 2 { + return error('quic: HelloRetryRequest cookie length ${cookie.len} out of range') + } + mut data := []u8{} + data << u8(cookie.len >> 8) + data << u8(cookie.len) + data << cookie + extensions << encode_extension(ext_cookie, data)! + } + + if extensions.len > 0xffff { + return error('quic: HelloRetryRequest extensions block too large: ${extensions.len} bytes') + } + body << u8(extensions.len >> 8) + body << u8(extensions.len) + body << extensions + + // HelloRetryRequest is wire-framed as a server_hello handshake message + // (RFC 8446 §4.1.4: "it is not a separate message from the perspective + // of the wire format"), same as parse_server_hello's own type + // dispatch above. + return encode_handshake_message(.server_hello, body)! +} + // EncryptedExtensionsParams is everything build_encrypted_extensions needs. pub struct EncryptedExtensionsParams { pub: diff --git a/vlib/net/quic/tls13_server_hello_test.v b/vlib/net/quic/tls13_server_hello_test.v index d41d04dd06f098..f450cf1966c0d8 100644 --- a/vlib/net/quic/tls13_server_hello_test.v +++ b/vlib/net/quic/tls13_server_hello_test.v @@ -531,7 +531,10 @@ fn test_build_server_hello_rejects_wrong_random_length() { // test fixture reusing the constant) must be caught here rather than // silently producing an ambiguous message. fn test_build_server_hello_rejects_hello_retry_request_random_collision() { - build_server_hello(random: hello_retry_request_random[..].clone(), ecdhe_public_key: []u8{len: 65}) or { + build_server_hello( + random: hello_retry_request_random[..].clone() + ecdhe_public_key: []u8{len: 65} + ) or { assert err.msg().contains('HelloRetryRequest') return } @@ -617,3 +620,57 @@ fn test_build_encrypted_extensions_requires_original_destination_connection_id() } assert false, 'expected an error for a missing original_destination_connection_id' } + +// build_hello_retry_request (Phase 13a, server-role construction). +// Round-tripped through this same file's own parse_server_hello, which +// dispatches to the HelloRetryRequest branch purely by matching the fixed +// magic random value -- the same real cross-check discipline as +// build_server_hello's own tests above. + +fn test_build_hello_retry_request_with_group_and_cookie_round_trips() { + msg := build_hello_retry_request( + selected_group: named_group_secp256r1 + cookie: [u8(1), 2, 3, 4] + )! + parsed_msg, consumed := parse_handshake_message(msg)! + assert consumed == msg.len + assert parsed_msg.typ == .server_hello // HRR shares ServerHello's wire type + result := parse_server_hello(parsed_msg.body)! + match result { + ParsedHelloRetryRequest { + assert result.cipher_suite == cipher_suite_tls_aes_128_gcm_sha256 + assert result.selected_version == tls_version_1_3 + assert result.selected_group? == named_group_secp256r1 + assert result.cookie? == [u8(1), 2, 3, 4] + } + ParsedServerHello { + assert false, 'expected a HelloRetryRequest, not a real ServerHello' + } + } +} + +fn test_build_hello_retry_request_cookie_only_round_trips() { + // RFC 8446 §4.1.4: key_share is not mandatory in an HRR when the + // client's already-offered share is acceptable -- only + // supported_versions is mandatory. + msg := build_hello_retry_request(cookie: [u8(9), 9])! + parsed_msg, _ := parse_handshake_message(msg)! + result := parse_server_hello(parsed_msg.body)! + match result { + ParsedHelloRetryRequest { + assert result.selected_group == none + assert result.cookie? == [u8(9), 9] + } + ParsedServerHello { + assert false, 'expected a HelloRetryRequest, not a real ServerHello' + } + } +} + +fn test_build_hello_retry_request_rejects_oversized_cookie() { + build_hello_retry_request(cookie: []u8{len: 0xffff}) or { + assert err.msg().contains('cookie length') + return + } + assert false, 'expected an error for an oversized cookie' +} From 9ecb42eca73ed1d91b76320b0e197a63f0731007 Mon Sep 17 00:00:00 2001 From: Richard Wheeler Date: Mon, 24 Aug 2026 14:27:21 -0400 Subject: [PATCH 04/10] net.quic: 13a - server-side TLS 1.3 state machine (completes 13a) Tls13ServerHandshake (tls13_server_handshake.v), the server-role mirror of the existing Tls13ClientHandshake. respond_to_client_hello parses and fully validates an incoming ClientHello (cipher suite, TLS 1.3 offered, secp256r1 key_share, ecdsa_secp256r1_sha256 in signature_algorithms, ALPN common protocol, and RFC 9000 SS7.3's transport-parameter role restrictions) BEFORE allocating anything, does real ECDH against the client's offered key_share, then builds the entire response flight -- ServerHello through this server's own Finished -- in one call, since nothing arrives from the peer in between. process_finished verifies the client's Finished and confirms the handshake. Needed ClientHello parsing, which didn't exist at all before this commit: added parse_client_hello, decode_alpn_offer, parse_key_share_extension_client, parse_signature_algorithms_extension_client, and parse_supported_versions_from_client to tls13_client_hello.v. Each client-vs- server wire-shape asymmetry already established for ServerHello (list-wrapped vs. bare key_share, versioned list vs. bare selected-version) recurs here in reverse, documented the same way. Verified with a real integration test, not just round-tripped against this module's own code: a genuine Tls13ClientHandshake and Tls13ServerHandshake, each independently written against the RFC text, run against each other with fresh ECDHE keys on both sides (not fixed RFC 8448 vectors). The client's own real process_server_hello/process_encrypted_extensions/verify_finished all independently accept this server's real output, and this server's process_finished accepts a real client Finished built the same way -- confirming the ECDH, key schedule, and both Finished computations genuinely agree between two separately-implemented roles. Full net.quic suite 55/55, ./vnew missdoc clean, ./vnew fmt -w applied. Not exercised: Certificate/CertificateVerify chain verification end-to-end (no EC certificate fixture in this repo -- same documented gap as encode_certificate_verify's own tests); HelloRetryRequest generation is not wired into this state machine (a key_share group mismatch is a hard failure) -- the same deliberate-defer scope choice Tls13ClientHandshake. process_server_hello already made for its own first-HRR gap. This completes 13a's stated scope in PROGRESS.md. Next: 13b (Retry + address validation). --- vlib/net/quic/PROGRESS.md | 42 +- vlib/net/quic/tls13_client_hello.v | 242 +++++++++++ vlib/net/quic/tls13_client_hello_test.v | 122 ++++++ vlib/net/quic/tls13_handshake_test.v | 14 +- vlib/net/quic/tls13_server_handshake.v | 422 ++++++++++++++++++++ vlib/net/quic/tls13_server_handshake_test.v | 316 +++++++++++++++ 6 files changed, 1144 insertions(+), 14 deletions(-) create mode 100644 vlib/net/quic/tls13_server_handshake.v create mode 100644 vlib/net/quic/tls13_server_handshake_test.v diff --git a/vlib/net/quic/PROGRESS.md b/vlib/net/quic/PROGRESS.md index fde9b54f967e21..20d9c3d9c40739 100644 --- a/vlib/net/quic/PROGRESS.md +++ b/vlib/net/quic/PROGRESS.md @@ -1240,7 +1240,7 @@ QUIC layer is already role-parameterized (`role QuicRole` on `QuicConn`; explicitly) — none of that foundation needs rework. Same one-sub-phase-per- stacked-PR convention as Phase 12's 12a-12d. -- [ ] **13a** (in progress) — TLS 1.3 server handshake: +- [x] **13a** — TLS 1.3 server handshake: - [x] Message construction, all five pieces (`tls13_server_hello.v`, `tls13_certificate.v`, `tls13_messages.v`): `build_server_hello`, `build_encrypted_extensions`, `encode_certificate`, @@ -1255,13 +1255,39 @@ stacked-PR convention as Phase 12's 12a-12d. real bug this way before commit: a server's `key_share` is a bare `KeyShareEntry` (RFC 8446 §4.2.8), not the client's list-wrapped shape. - - [ ] Server-side state machine (mirroring `Tls13ClientHandshake` in - `tls13_handshake.v`) — orchestrating the above into an actual - handshake driver: deciding when to send Certificate vs. reuse - cached state, whether to send an HRR, deriving/tracking the - transcript hash across all these messages, discarding keys at the - right checkpoints. NOT started — none of the five functions above - are wired into anything yet. + - [x] Server-side state machine (`tls13_server_handshake.v`): + `Tls13ServerHandshake.respond_to_client_hello` — parses+validates a + ClientHello (cipher suite, TLS 1.3, secp256r1 key_share, + ecdsa_secp256r1_sha256 in signature_algorithms, ALPN common + protocol, RFC 9000 §7.3 transport-parameter role restrictions — + added `parse_client_hello`/`decode_alpn_offer`/ + `parse_key_share_extension_client`/ + `parse_signature_algorithms_extension_client`/ + `parse_supported_versions_from_client` to `tls13_client_hello.v` + for this, none of which existed before), does real ECDH against + the client's offered key_share, then builds the ENTIRE response + flight (ServerHello through this server's own Finished) in one + call — RFC 8446 §7.1/Figure 3 lets application traffic secrets + derive right after the server's own Finished, no dependency on the + client's Finished arriving. `process_finished` verifies the + client's Finished and confirms the handshake. Simpler state + machine than the client's (2 states, not 6): a server never waits + on a peer message between ClientHello and its own Finished. + HelloRetryRequest is NOT wired in (group mismatch is a hard + failure) — the SAME deliberate-defer scope choice + `Tls13ClientHandshake.process_server_hello` already made for its + own first-HRR gap; `build_hello_retry_request` exists and is + unit-tested at the message layer but not yet driven by this state + machine. Verified via a REAL client-vs-server integration test + (`tls13_server_handshake_test.v`): fresh ECDHE keys on both + sides (not fixed RFC vectors), the client's own real + `process_server_hello`/`process_encrypted_extensions`/ + `verify_finished` all independently accept this server's real + output, and this server's `process_finished` accepts a real client + Finished built the same way. Certificate/CertificateVerify chain + verification is NOT exercised end-to-end (no EC certificate + fixture in this repo — the same documented gap as + `encode_certificate_verify`'s own tests). - [ ] **13b** — Retry + address validation: Retry packet + opaque token minting/validation (`retry.v` currently only verifies), RFC 9000 §8.1 anti-amplification 3x accounting (already flagged as a deferred, diff --git a/vlib/net/quic/tls13_client_hello.v b/vlib/net/quic/tls13_client_hello.v index bb846fb657ef99..d64bfbc389893c 100644 --- a/vlib/net/quic/tls13_client_hello.v +++ b/vlib/net/quic/tls13_client_hello.v @@ -289,6 +289,149 @@ fn decode_alpn_response(data []u8) !string { return list[1..].bytestr() } +// decode_alpn_offer parses a CLIENT's ALPN extension_data (RFC 7301 §3.1), +// returning every protocol name offered, most preferred first, in wire +// order. This is the multi-entry counterpart to decode_alpn_response +// (this file) -- a server reads a client's full offer list with this +// function, a client reads a server's single-entry selection with that +// one; the two share a wire TYPE but not a wire SHAPE, the same class of +// asymmetry tls13_server_hello.v documents for supported_versions/ +// key_share. RFC 7301 §3.1's own bounds are enforced: the list must not be +// empty, and no entry may be empty (opaque ProtocolName<1..2^8-1>). +pub fn decode_alpn_offer(data []u8) ![]string { + if data.len < 2 { + return error('quic: ALPN extension_data too short: need at least 2 bytes, have ${data.len}') + } + list_len := int((u32(data[0]) << 8) | u32(data[1])) + if 2 + list_len != data.len { + return error('quic: ALPN list_length ${list_len} does not match extension_data length ${data.len - 2}') + } + list := data[2..] + if list.len == 0 { + return error('quic: ALPN ProtocolNameList must not be empty') + } + mut protocols := []string{} + mut cursor := 0 + for cursor < list.len { + name_len := int(list[cursor]) + cursor += 1 + if name_len == 0 { + return error('quic: ALPN protocol name must not be empty') + } + if cursor + name_len > list.len { + return error('quic: ALPN protocol name declares ${name_len} bytes exceeding the remaining list') + } + protocols << list[cursor..cursor + name_len].bytestr() + cursor += name_len + } + return protocols +} + +// parse_supported_versions_from_client parses the CLIENT-side +// supported_versions payload (RFC 8446 §4.2.1): a 1-byte length prefix +// followed by that many bytes of 2-byte version codepoints -- the list +// shape a client OFFERS, not the server's bare 2-byte selected_version +// (see parse_supported_versions_from_server, tls13_server_hello.v, for +// that shape -- the same asymmetry as key_share below). +fn parse_supported_versions_from_client(data []u8) ![]u16 { + if data.len < 1 { + return error('quic: supported_versions (client) truncated: need at least 1 byte, have ${data.len}') + } + list_len := int(data[0]) + if 1 + list_len != data.len { + return error('quic: supported_versions (client) list length ${list_len} does not match remaining data ${data.len - 1}') + } + if list_len == 0 || list_len % 2 != 0 { + return error('quic: supported_versions (client) list length ${list_len} must be a non-zero, even number of bytes') + } + mut versions := []u16{} + mut cursor := 1 + for cursor < data.len { + versions << u16((u32(data[cursor]) << 8) | u32(data[cursor + 1])) + cursor += 2 + } + return versions +} + +// ClientKeyShareEntry is one offered (group, key_exchange) pair from a +// client's key_share extension. +pub struct ClientKeyShareEntry { +pub: + group u16 + key_exchange []u8 +} + +// parse_key_share_extension_client parses the CLIENT-side key_share payload +// (RFC 8446 §4.2.8's KeyShareClientHello): a 2-byte client_shares length +// prefix, then zero or more KeyShareEntry values (group(2) + +// key_exchange_len(2) + key_exchange) -- the list shape a client OFFERS, +// not a server's single bare KeyShareEntry (see +// encode_key_share_extension_server, tls13_server_hello.v, for that +// shape). This codebase's own build_client_hello only ever sends exactly +// one entry, but the wire format itself permits any number, so this parses +// the full list rather than assuming one. +pub fn parse_key_share_extension_client(data []u8) ![]ClientKeyShareEntry { + if data.len < 2 { + return error('quic: key_share (client) truncated: need at least 2 bytes, have ${data.len}') + } + list_len := int((u32(data[0]) << 8) | u32(data[1])) + if 2 + list_len != data.len { + return error('quic: key_share (client) client_shares length ${list_len} does not match remaining data ${data.len - 2}') + } + mut entries := []ClientKeyShareEntry{} + mut cursor := 2 + end := 2 + list_len + for cursor < end { + if end - cursor < 4 { + return error('quic: key_share (client) truncated KeyShareEntry header') + } + group := u16((u32(data[cursor]) << 8) | u32(data[cursor + 1])) + ke_len := int((u32(data[cursor + 2]) << 8) | u32(data[cursor + 3])) + cursor += 4 + if cursor + ke_len > end { + return error('quic: key_share (client) KeyShareEntry declares ${ke_len}-byte key_exchange exceeding client_shares') + } + if ke_len == 0 { + return error('quic: key_share (client) KeyShareEntry key_exchange must not be empty (opaque key_exchange<1..2^16-1>)') + } + entries << ClientKeyShareEntry{ + group: group + key_exchange: data[cursor..cursor + ke_len].clone() + } + cursor += ke_len + } + return entries +} + +// parse_signature_algorithms_extension_client parses a client's +// signature_algorithms payload (RFC 8446 §4.2.3's +// `SignatureScheme supported_signature_algorithms<2..2^16-2>`): a 2-byte +// length prefix followed by that many bytes of 2-byte SignatureScheme +// codepoints -- byte-identical framing to supported_groups's NamedGroupList +// (parse_encrypted_extensions, tls13_server_hello.v, validates that inner +// shape the same way), but kept as its own named function rather than a +// shared generic helper, matching this module's established +// one-function-per-RFC-field convention. +fn parse_signature_algorithms_extension_client(data []u8) ![]u16 { + if data.len < 2 { + return error('quic: signature_algorithms (client) truncated: need at least 2 bytes, have ${data.len}') + } + list_len := int((u32(data[0]) << 8) | u32(data[1])) + if 2 + list_len != data.len { + return error('quic: signature_algorithms (client) list length ${list_len} does not match remaining data ${data.len - 2}') + } + if list_len == 0 || list_len % 2 != 0 { + return error('quic: signature_algorithms (client) list length ${list_len} must be a non-zero, even number of bytes') + } + mut schemes := []u16{} + mut cursor := 2 + for cursor < data.len { + schemes << u16((u32(data[cursor]) << 8) | u32(data[cursor + 1])) + cursor += 2 + } + return schemes +} + // 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). @@ -398,3 +541,102 @@ pub fn build_client_hello(p ClientHelloParams) ![]u8 { return encode_handshake_message(.client_hello, body)! } + +// ParsedClientHello is the structural parse of a ClientHello (RFC 8446 +// §4.1.2), scoped like ParsedServerHello (tls13_server_hello.v): a few +// RFC-mandated, caller-state-independent fields pulled out directly, plus +// the raw extension list for a caller to interpret with the same +// find_extension/decode_* helpers process_encrypted_extensions already +// uses on the client side. cipher_suites is the FULL offered list (unlike +// ParsedServerHello's single cipher_suite) -- a server must find its own +// suite among possibly many, not just record what a peer already chose. +pub struct ParsedClientHello { +pub: + random []u8 + cipher_suites []u16 + extensions []TlsExtension +} + +// parse_client_hello parses a ClientHello handshake message BODY. Validates +// only what has no caller-dependent state: legacy_version, the +// cipher_suites/legacy_compression_methods vector shapes, and +// legacy_session_id -- RFC 9001 §8.4: "A server SHOULD treat the receipt of +// a TLS ClientHello with a non-empty legacy_session_id field as a +// connection error of type PROTOCOL_VIOLATION" (this file's own +// build_client_hello doc comment already states the identical requirement +// from the sending side; the QUIC-native PROTOCOL_VIOLATION code, not a TLS +// alert, is why this uses error_with_code with quic_error_protocol_violation +// directly rather than a TLS alert mapping). Extension-level semantic +// validation (ALPN offered-list, key_share group, quic_transport_parameters +// cross-checks, and rejecting the four server-only transport parameters a +// client must never send) is the caller's job -- the same division of +// labor process_encrypted_extensions already uses for EncryptedExtensions. +pub fn parse_client_hello(body []u8) !ParsedClientHello { + if body.len < 2 + 32 + 1 { + return error('quic: truncated ClientHello: need at least 35 bytes for the fixed prefix, have ${body.len}') + } + if body[0] != 0x03 || body[1] != 0x03 { + return error('quic: ClientHello legacy_version must be 0x0303, got 0x${body[0]:02x}${body[1]:02x}') + } + random := body[2..34].clone() + mut cursor := 34 + session_id_len := int(body[cursor]) + cursor += 1 + if session_id_len != 0 { + return error_with_code('quic: ClientHello legacy_session_id must be empty (RFC 9001 §8.4)', + int(quic_error_protocol_violation)) + } + + if body.len < cursor + 2 { + return error('quic: truncated ClientHello after legacy_session_id') + } + suites_len := int((u32(body[cursor]) << 8) | u32(body[cursor + 1])) + cursor += 2 + if suites_len == 0 || suites_len % 2 != 0 { + return error('quic: ClientHello cipher_suites length ${suites_len} must be a non-zero, even number of bytes') + } + if body.len < cursor + suites_len { + return error('quic: truncated ClientHello cipher_suites: declares ${suites_len} bytes, only ${body.len - cursor} remain') + } + mut cipher_suites := []u16{} + mut suite_cursor := cursor + for suite_cursor < cursor + suites_len { + cipher_suites << u16((u32(body[suite_cursor]) << 8) | u32(body[suite_cursor + 1])) + suite_cursor += 2 + } + cursor += suites_len + + if body.len < cursor + 1 { + return error('quic: truncated ClientHello: missing legacy_compression_methods') + } + compression_len := int(body[cursor]) + cursor += 1 + if body.len < cursor + compression_len { + return error('quic: truncated ClientHello legacy_compression_methods') + } + // RFC 8446 §4.1.2: "For every TLS 1.3 ClientHello, this vector MUST + // contain exactly one byte, set to zero" -- the offering side's mirror + // of parse_server_hello's fixed single-byte legacy_compression_method + // check (a server only ever selects, never offers, one, so that side + // has no vector wrapper at all). + if compression_len != 1 || body[cursor] != 0 { + return error('quic: ClientHello legacy_compression_methods must be exactly [0], got ${compression_len} bytes') + } + cursor += compression_len + + if body.len < cursor + 2 { + return error('quic: truncated ClientHello: missing extensions length') + } + extensions_len := int((u32(body[cursor]) << 8) | u32(body[cursor + 1])) + cursor += 2 + if cursor + extensions_len != body.len { + return error('quic: ClientHello extensions length ${extensions_len} does not match remaining body ${body.len - cursor}') + } + extensions := parse_extension_list(body[cursor..])! + + return ParsedClientHello{ + random: random + cipher_suites: cipher_suites + extensions: extensions + } +} diff --git a/vlib/net/quic/tls13_client_hello_test.v b/vlib/net/quic/tls13_client_hello_test.v index 250e5c80c229b1..6e73f35d02d1f6 100644 --- a/vlib/net/quic/tls13_client_hello_test.v +++ b/vlib/net/quic/tls13_client_hello_test.v @@ -384,3 +384,125 @@ fn test_build_client_hello_structure() { assert alpn_data[2] == 2 // protocol name length assert alpn_data[3..5].bytestr() == 'h3' } + +// parse_client_hello / decode_alpn_offer (Phase 13a, server-role parsing). +// Round-tripped through this same file's own build_client_hello -- the +// perfect pair, same discipline as every other build/parse cross-check in +// this module. + +fn test_parse_client_hello_round_trips_through_build_client_hello() { + _, priv_key := ecdsa.generate_key()! + pub_key := priv_key.public_key()! + ecdhe_public_key := pub_key.uncompressed_bytes()! + + tp := QuicTransportParameters{ + initial_source_connection_id: []u8{len: 8, init: 0xcc} + } + msg := build_client_hello( + random: []u8{len: 32, init: 0x22} + server_name: 'example.com' + ecdhe_public_key: ecdhe_public_key + transport_parameters: tp + alpn_protocols: ['h3', 'h3-29'] + )! + parsed_msg, consumed := parse_handshake_message(msg)! + assert consumed == msg.len + assert parsed_msg.typ == .client_hello + + result := parse_client_hello(parsed_msg.body)! + assert result.random == []u8{len: 32, init: 0x22} + assert result.cipher_suites == [cipher_suite_tls_aes_128_gcm_sha256] + + ks_ext := find_extension(result.extensions, ext_key_share) or { panic('missing key_share') } + // Client-side key_share is list-wrapped (KeyShareClientHello) -- skip + // the 2-byte client_shares length prefix before the single entry's own + // group(2)+key_exchange_len(2)+key_exchange, matching + // encode_key_share_extension's own wire shape (this file). + assert ks_ext.data[2..4] == [u8(named_group_secp256r1 >> 8), u8(named_group_secp256r1)] + assert ks_ext.data[6..] == ecdhe_public_key + + alpn_ext := find_extension(result.extensions, ext_alpn) or { panic('missing alpn') } + assert decode_alpn_offer(alpn_ext.data)! == ['h3', 'h3-29'] + + tp_ext := find_extension(result.extensions, ext_quic_transport_parameters) or { + panic('missing quic_transport_parameters') + } + decoded_tp := decode_transport_parameters(tp_ext.data)! + assert decoded_tp.initial_source_connection_id? == []u8{len: 8, init: 0xcc} +} + +fn test_parse_client_hello_rejects_nonempty_session_id() { + mut body := []u8{} + body << u8(0x03) + body << u8(0x03) + body << []u8{len: 32} + body << u8(1) // non-empty session id -- always wrong, RFC 9001 §8.4 + body << u8(0xaa) + body << u8(0) + body << u8(2) + body << u8(cipher_suite_tls_aes_128_gcm_sha256 >> 8) + body << u8(cipher_suite_tls_aes_128_gcm_sha256) + body << u8(1) + body << u8(0) + body << u8(0) + body << u8(0) + parse_client_hello(body) or { + assert err.msg().contains('legacy_session_id') + assert err.code() == int(quic_error_protocol_violation) + return + } + assert false, 'expected an error for a non-empty legacy_session_id' +} + +fn test_parse_client_hello_rejects_wrong_legacy_version() { + mut body := []u8{} + body << u8(0x03) + body << u8(0x01) // wrong: must be 0x0303 + body << []u8{len: 32} + body << u8(0) + body << u8(0) + body << u8(2) + body << u8(cipher_suite_tls_aes_128_gcm_sha256 >> 8) + body << u8(cipher_suite_tls_aes_128_gcm_sha256) + body << u8(1) + body << u8(0) + body << u8(0) + body << u8(0) + parse_client_hello(body) or { + assert err.msg().contains('legacy_version') + return + } + assert false, 'expected an error for a wrong legacy_version' +} + +fn test_parse_client_hello_rejects_wrong_compression_methods() { + mut body := []u8{} + body << u8(0x03) + body << u8(0x03) + body << []u8{len: 32} + body << u8(0) + body << u8(0) + body << u8(2) + body << u8(cipher_suite_tls_aes_128_gcm_sha256 >> 8) + body << u8(cipher_suite_tls_aes_128_gcm_sha256) + body << u8(2) // wrong: must be exactly [0] + body << u8(0) + body << u8(1) + body << u8(0) + body << u8(0) + parse_client_hello(body) or { + assert err.msg().contains('legacy_compression_methods') + return + } + assert false, 'expected an error for a malformed legacy_compression_methods' +} + +fn test_decode_alpn_offer_rejects_empty_entry() { + // A zero-length protocol name -- RFC 7301 §3.1: opaque ProtocolName<1..255>. + data := [u8(0), 1, 0] + decode_alpn_offer(data) or { + assert err.msg().contains('must not be empty') + return + } + assert false, 'expected an error for an empty protocol name' +} diff --git a/vlib/net/quic/tls13_handshake_test.v b/vlib/net/quic/tls13_handshake_test.v index 0ba23828de7841..5a1ffcc62f4434 100644 --- a/vlib/net/quic/tls13_handshake_test.v +++ b/vlib/net/quic/tls13_handshake_test.v @@ -26,12 +26,14 @@ fn handshake_test_pem_to_der(pem string) []u8 { // fake_server_sign_certificate_verify signs `signed_content` with the test // RSA private key using real RSA-PSS/SHA-256 -- simulating what a real TLS // 1.3 server does to produce CertificateVerify. Directly uses mbedTLS's C -// API (not net.mbedtls's pub verify-only wrappers, which have no signing -// counterpart -- production net.quic code never signs anything, since v1 -// is client-only with no client-cert auth) rather than mocking a signature; -// mirrors net.mbedtls/x509_standalone_signature_test.v's own -// sign_rsa_pss_for_test, duplicated rather than shared since it lives in a -// different module's test file. +// API rather than mocking a signature, since net.mbedtls's own verify-only +// wrappers have no RSA-PSS signing counterpart (only ECDSA signing exists +// today, via crypto.ecdsa -- see tls13_certificate.v's +// encode_certificate_verify, added in Phase 13 for server support; RSA-PSS +// signing remains unimplemented there for the exact reason this fake +// server can't just call it). Mirrors net.mbedtls/ +// x509_standalone_signature_test.v's own sign_rsa_pss_for_test, duplicated +// rather than shared since it lives in a different module's test file. fn fake_server_sign_certificate_verify(signed_content []u8) ![]u8 { hash := sha256.sum256(signed_content) diff --git a/vlib/net/quic/tls13_server_handshake.v b/vlib/net/quic/tls13_server_handshake.v new file mode 100644 index 00000000000000..791acbefa7cae1 --- /dev/null +++ b/vlib/net/quic/tls13_server_handshake.v @@ -0,0 +1,422 @@ +module quic + +import crypto.ecdsa +import crypto.sha256 + +// ServerHandshakeState tracks a server-role handshake's progress. Simpler +// than ClientHandshakeState (tls13_handshake.v): a server builds its ENTIRE +// response flight (ServerHello through its own Finished) synchronously in +// one call, with no peer message arriving in between to wait on -- there is +// no wait_encrypted_extensions/wait_certificate/wait_certificate_verify +// equivalent, since this server never RECEIVES those message types. +pub enum ServerHandshakeState { + wait_finished + connected +} + +// ServerHandshakeParams is everything +// Tls13ServerHandshake.respond_to_client_hello needs beyond what's fixed by +// v1's scope decisions (single cipher suite, single named group -- see +// tls13_client_hello.v). +pub struct ServerHandshakeParams { +pub: + // This SERVER's own transport parameters (own SCID via + // initial_source_connection_id, the client's observed Initial DCID via + // original_destination_connection_id -- both validated as present by + // build_encrypted_extensions itself, RFC 9000 §7.3). + transport_parameters QuicTransportParameters + // Application protocols this server supports, most preferred first -- + // RFC 7301 §3.2: the SERVER picks, from among protocols the client also + // offered, in the SERVER's own preference order (not just the first + // thing the client happened to list first). + supported_alpn_protocols []string + // This server's own certificate chain, leaf-first (RFC 8446 §4.4.2). + // Loading these from a PEM file is a future caller's job (13e's + // h3_server.v wiring), not this state machine's. + certificate_chain []CertificateEntry + // This server's own long-lived identity private key, matching the leaf + // certificate's public key. UNLIKE the ephemeral ECDHE keypair this + // state machine generates internally per-handshake (single-use, owned + // and freed by the Tls13ServerHandshake object -- see free()), + // signing_key is the caller's LONG-LIVED key, reused across every + // incoming connection -- this state machine only ever BORROWS it for + // one signing operation and never frees it. + signing_key ecdsa.PrivateKey + // Random bytes for this server's own ServerHello.random. Caller + // supplies so a real caller can use a genuine CSPRNG while tests stay + // deterministic, the same convention as ClientHandshakeParams.random. + server_hello_random []u8 +} + +// ServerHandshakeFlight is everything a caller needs to actually send this +// server's response and install the resulting keys. `server_hello` MUST be +// sent under Initial-level packet protection; `handshake_messages` +// (EncryptedExtensions, Certificate, CertificateVerify, and this server's +// own Finished, concatenated in that order) MUST be sent under +// Handshake-level protection -- RFC 8446/9001 protect these two groups +// under different keys, so a caller cannot simply concatenate and send +// everything as one CRYPTO-stream write the way this state machine's +// internal transcript accounting does. +pub struct ServerHandshakeFlight { +pub: + server_hello []u8 + handshake_messages []u8 + handshake_secrets HandshakeSecrets + application_secrets ApplicationSecrets + negotiated_alpn string +} + +// Tls13ServerHandshake drives a single QUIC-scoped TLS 1.3 SERVER +// handshake, the mirror of Tls13ClientHandshake (tls13_handshake.v) for the +// other role. See that struct's own doc comment for the shared error/ +// lifecycle contract (any process_* error is fatal to the whole handshake; +// the caller must call free() exactly once, successful or not). +pub struct Tls13ServerHandshake { +mut: + state ServerHandshakeState + // Running concatenation of every handshake message's bytes, same + // convention as Tls13ClientHandshake.transcript -- populated once, at + // construction time (respond_to_client_hello builds the entire + // ClientHello...server-Finished prefix in one call), then only ever + // extended by process_finished's own client Finished. + transcript []u8 + // This server's own ephemeral ECDHE keypair, generated fresh per + // handshake -- single-use, owned and freed by this object, unlike + // ServerHandshakeParams.signing_key (see that field's own doc comment). + ecdhe_private ecdsa.PrivateKey + handshake_secrets HandshakeSecrets + freed bool +pub mut: + peer_transport_parameters QuicTransportParameters +} + +// state returns which handshake message this server is currently waiting +// to receive. +pub fn (h &Tls13ServerHandshake) state() ServerHandshakeState { + return h.state +} + +// free releases ecdhe_private (an OpenSSL EVP_PKEY, Phase 1's +// crypto.ecdsa). Idempotent: safe to call more than once, guarded by +// `freed` -- the exact same double-free hazard and fix +// Tls13ClientHandshake.free() documents for its own identical field +// (ecdsa.PrivateKey.free() has no self-guard of its own). +pub fn (mut h Tls13ServerHandshake) free() { + if h.freed { + return + } + h.freed = true + h.ecdhe_private.free() +} + +fn (mut h Tls13ServerHandshake) accumulate(framed_message []u8) { + h.transcript << framed_message +} + +fn (h &Tls13ServerHandshake) transcript_hash() []u8 { + return sha256.sum256(h.transcript) +} + +// Tls13ServerHandshake.respond_to_client_hello processes a ClientHello (RFC +// 8446 §4.1.2) and builds this server's ENTIRE response flight in one call +// -- ServerHello through this server's own Finished -- since nothing from +// the peer arrives in between (RFC 8446 §4.1.4's server flow sends all of +// these back-to-back). Returns the new handshake object (state +// .wait_finished) and the flight to send; see ServerHandshakeFlight's own +// doc comment for why it is split into two byte groups rather than one. +// +// Every peer-input validation happens BEFORE any resource is allocated +// (the ephemeral ECDHE keypair, in particular) -- the same ordering +// Tls13ClientHandshake.start uses for its own ClientHello construction, so +// a rejected ClientHello never leaks a generated keypair through an error +// path. +// +// HelloRetryRequest is not sent here: if the ClientHello's key_share does +// not offer secp256r1, a real server would request it via a +// HelloRetryRequest (build_hello_retry_request, tls13_server_hello.v, +// already exists and is unit-tested at the message layer) -- wiring that +// into a full ClientHello2 round trip is deliberately deferred, the SAME +// scope choice tls13_handshake.v's own process_server_hello already +// documents for the client's identical first-HRR gap. +pub fn Tls13ServerHandshake.respond_to_client_hello(msg HandshakeMessage, framed_client_hello []u8, params ServerHandshakeParams) !(&Tls13ServerHandshake, ServerHandshakeFlight) { + if msg.typ != .client_hello { + return handshake_error(.unexpected_message, 'quic: expected ClientHello, got ${msg.typ}') + } + parsed := parse_client_hello(msg.body) or { + // Same convention as every process_* method in tls13_handshake.v: + // parse_client_hello carries its own specific QUIC error code (via + // error_with_code) for the non-empty-legacy_session_id class of + // failure; only a genuine structural parse failure (plain error(), + // code 0) should be remapped to the generic decode_error alert + // here. + if err.code() != 0 { + return err + } + return handshake_error(.decode_error, err.msg()) + } + + if cipher_suite_tls_aes_128_gcm_sha256 !in parsed.cipher_suites { + return handshake_error(.handshake_failure, + 'quic: ClientHello did not offer TLS_AES_128_GCM_SHA256, the only cipher suite this server supports') + } + + sv_ext := find_extension(parsed.extensions, ext_supported_versions) or { + return handshake_error(.missing_extension, + 'quic: ClientHello missing mandatory supported_versions extension') + } + offered_versions := parse_supported_versions_from_client(sv_ext.data) or { + return handshake_error(.decode_error, err.msg()) + } + if tls_version_1_3 !in offered_versions { + return handshake_error(.handshake_failure, 'quic: ClientHello did not offer TLS 1.3') + } + + ks_ext := find_extension(parsed.extensions, ext_key_share) or { + return handshake_error(.missing_extension, + 'quic: ClientHello missing mandatory key_share extension') + } + offered_shares := parse_key_share_extension_client(ks_ext.data) or { + return handshake_error(.decode_error, err.msg()) + } + mut client_key_exchange := []u8{} + mut found_group := false + for entry in offered_shares { + if entry.group == named_group_secp256r1 { + client_key_exchange = entry.key_exchange.clone() + found_group = true + break + } + } + if !found_group { + return handshake_error(.handshake_failure, + 'quic: ClientHello did not offer secp256r1 in key_share, and HelloRetryRequest-based group correction is not yet implemented') + } + + sa_ext := find_extension(parsed.extensions, ext_signature_algorithms) or { + return handshake_error(.missing_extension, + 'quic: ClientHello missing mandatory signature_algorithms extension') + } + offered_sig_algs := parse_signature_algorithms_extension_client(sa_ext.data) or { + return handshake_error(.decode_error, err.msg()) + } + // encode_certificate_verify (tls13_certificate.v) only signs + // ecdsa_secp256r1_sha256 today -- RFC 8446 §4.4.3: "the signature + // algorithm MUST be one offered in the [client's] 'signature_ + // algorithms' extension." + if sig_scheme_ecdsa_secp256r1_sha256 !in offered_sig_algs { + return handshake_error(.handshake_failure, + 'quic: ClientHello signature_algorithms does not include ecdsa_secp256r1_sha256, the only CertificateVerify algorithm this server can sign with') + } + + alpn_ext := find_extension(parsed.extensions, ext_alpn) or { + return handshake_error(.no_application_protocol, + 'quic: ClientHello missing mandatory alpn extension') + } + offered_alpn := decode_alpn_offer(alpn_ext.data) or { + return handshake_error(.decode_error, err.msg()) + } + mut negotiated_alpn := '' + for candidate in params.supported_alpn_protocols { + if candidate in offered_alpn { + negotiated_alpn = candidate + break + } + } + if negotiated_alpn == '' { + return handshake_error(.no_application_protocol, + 'quic: no ALPN protocol in common between this server and the ClientHello offer') + } + + tp_ext := find_extension(parsed.extensions, ext_quic_transport_parameters) or { + return handshake_error(.missing_extension, + 'quic: ClientHello missing mandatory quic_transport_parameters extension') + } + peer_params := decode_transport_parameters(tp_ext.data) or { + return transport_parameter_error('quic: malformed quic_transport_parameters: ${err.msg()}') + } + if peer_params.initial_source_connection_id == none { + return transport_parameter_error('quic: ClientHello transport parameters missing mandatory initial_source_connection_id') + } + // RFC 9000 §18.2: these four are server-only ("This transport parameter + // is only sent by a server" / stateless_reset_token's "MUST NOT be sent + // by a client") -- build_client_hello's own doc comment already + // enforces the identical restriction from the sending side; this is + // the RECEIVING side's mirror, the SAME division of labor as + // process_encrypted_extensions's peer_params checks (this file) on the + // client side. + if peer_params.original_destination_connection_id != none { + return transport_parameter_error('quic: ClientHello transport parameters must not include original_destination_connection_id (server-only)') + } + if peer_params.stateless_reset_token != none { + return transport_parameter_error('quic: ClientHello transport parameters must not include stateless_reset_token (client MUST NOT send)') + } + if peer_params.preferred_address != none { + return transport_parameter_error('quic: ClientHello transport parameters must not include preferred_address (server-only)') + } + if peer_params.retry_source_connection_id != none { + return transport_parameter_error('quic: ClientHello transport parameters must not include retry_source_connection_id (server-only)') + } + + // Every peer-input validation above has passed -- only internal + // failures (ECDHE keygen, key-schedule/message-construction errors) + // remain possible from here on, matching Tls13ClientHandshake.start's + // identical validate-then-allocate ordering. + ecdhe_public, ecdhe_private := ecdsa.generate_key(nid: .prime256v1) or { + return handshake_error(.handshake_failure, + 'quic: failed to generate ephemeral ECDHE keypair: ${err.msg()}') + } + defer { + ecdhe_public.free() + } + ecdhe_public_bytes := ecdhe_public.uncompressed_bytes() or { + ecdhe_private.free() + return handshake_error(.handshake_failure, + 'quic: failed to encode ephemeral ECDHE public key: ${err.msg()}') + } + + client_public := ecdsa.PublicKey.from_uncompressed_bytes(client_key_exchange, + nid: .prime256v1 + ) or { + ecdhe_private.free() + return handshake_error(.decode_error, 'quic: malformed ClientHello key_share: ${err.msg()}') + } + defer { + client_public.free() + } + shared_secret := ecdhe_private.derive_shared_secret(client_public) or { + ecdhe_private.free() + return handshake_error(.decrypt_error, + 'quic: ECDHE shared secret derivation failed: ${err.msg()}') + } + + mut transcript := framed_client_hello.clone() + + server_hello := build_server_hello( + random: params.server_hello_random + ecdhe_public_key: ecdhe_public_bytes + ) or { + ecdhe_private.free() + return handshake_error(.handshake_failure, + 'quic: failed to build ServerHello: ${err.msg()}') + } + transcript << server_hello + + early_secret := derive_early_secret() or { + ecdhe_private.free() + return handshake_error(.handshake_failure, err.msg()) + } + handshake_secrets := derive_handshake_secrets(early_secret, shared_secret, + sha256.sum256(transcript)) or { + ecdhe_private.free() + return handshake_error(.handshake_failure, err.msg()) + } + + mut acknowledge_server_name := false + if _ := find_extension(parsed.extensions, ext_server_name) { + acknowledge_server_name = true + } + + encrypted_extensions := build_encrypted_extensions( + transport_parameters: params.transport_parameters + selected_alpn: negotiated_alpn + acknowledge_server_name: acknowledge_server_name + ) or { + ecdhe_private.free() + return handshake_error(.handshake_failure, + 'quic: failed to build EncryptedExtensions: ${err.msg()}') + } + transcript << encrypted_extensions + + certificate := encode_certificate(params.certificate_chain) or { + ecdhe_private.free() + return handshake_error(.handshake_failure, + 'quic: failed to build Certificate: ${err.msg()}') + } + transcript << certificate + // Transcript-Hash(ClientHello...Certificate) -- RFC 8446 §4.4.3's + // "Transcript-Hash(Handshake Context, Certificate)" input to + // certificate_verify_signed_content, computed at exactly this + // checkpoint, the same moment Tls13ClientHandshake captures its own + // certificate_transcript_hash when processing the server's Certificate. + certificate_transcript_hash := sha256.sum256(transcript) + + certificate_verify := encode_certificate_verify(sig_scheme_ecdsa_secp256r1_sha256, + params.signing_key, certificate_transcript_hash) or { + ecdhe_private.free() + return handshake_error(.handshake_failure, + 'quic: failed to build CertificateVerify: ${err.msg()}') + } + transcript << certificate_verify + + server_finished := build_finished(handshake_secrets.server_secret, sha256.sum256(transcript)) or { + ecdhe_private.free() + return handshake_error(.handshake_failure, err.msg()) + } + transcript << server_finished + + // RFC 8446 §7.1/Figure 3: both application traffic secrets derive from + // Transcript-Hash(ClientHello...server Finished) -- available as soon + // as THIS server's own Finished is built, with no dependency on the + // client's Finished arriving. This is what lets a server send Half-RTT + // application data (RFC 8446 §4.4.4) before the handshake fully + // completes; this state machine doesn't build a Half-RTT sender, but + // the underlying key-derivation timing is the same fact. + application_secrets := derive_application_secrets(handshake_secrets.handshake_secret, + sha256.sum256(transcript)) or { + ecdhe_private.free() + return handshake_error(.handshake_failure, err.msg()) + } + + mut h := &Tls13ServerHandshake{ + state: .wait_finished + transcript: transcript + ecdhe_private: ecdhe_private + handshake_secrets: handshake_secrets + peer_transport_parameters: peer_params + } + + mut handshake_messages := []u8{} + handshake_messages << encrypted_extensions + handshake_messages << certificate + handshake_messages << certificate_verify + handshake_messages << server_finished + + return h, ServerHandshakeFlight{ + server_hello: server_hello + handshake_messages: handshake_messages + handshake_secrets: handshake_secrets + application_secrets: application_secrets + negotiated_alpn: negotiated_alpn + } +} + +// process_finished handles the client's Finished (RFC 8446 §4.4.4), +// verifying its verify_data against Transcript-Hash(ClientHello...server +// Finished) -- the SAME checkpoint respond_to_client_hello already used to +// derive Application secrets, since the client's Finished does not extend +// that transcript prefix (a Finished message's own verify_data covers +// everything BEFORE itself, never itself). Unlike +// Tls13ClientHandshake.process_finished, this returns nothing new to send +// or derive -- respond_to_client_hello already produced both traffic +// secrets; this call is purely the confirmation checkpoint (RFC 8446's +// "the handshake is confirmed" moment from the server's side, mirroring +// RFC 9001 §4.1.2's HANDSHAKE_DONE trigger condition, though sending that +// frame is the caller's job, not this state machine's). +pub fn (mut h Tls13ServerHandshake) process_finished(msg HandshakeMessage, framed_message []u8) ! { + if h.state != .wait_finished { + return handshake_error(.unexpected_message, + 'quic: received Finished while in state ${h.state}') + } + if msg.typ != .finished { + return handshake_error(.unexpected_message, 'quic: expected Finished, got ${msg.typ}') + } + ok := verify_finished(h.handshake_secrets.client_secret, h.transcript_hash(), msg.body) or { + return handshake_error(.decrypt_error, err.msg()) + } + if !ok { + return handshake_error(.decrypt_error, 'quic: client Finished verify_data does not match') + } + + h.accumulate(framed_message) + h.state = .connected +} diff --git a/vlib/net/quic/tls13_server_handshake_test.v b/vlib/net/quic/tls13_server_handshake_test.v new file mode 100644 index 00000000000000..10da487fbf9ab8 --- /dev/null +++ b/vlib/net/quic/tls13_server_handshake_test.v @@ -0,0 +1,316 @@ +// vtest build: present_openssl? +module quic + +import crypto.ecdsa +import crypto.sha256 + +// server_handshake_test_client_params builds a valid, deterministic +// ClientHandshakeParams -- the exact same shape a real client would send, +// reused across every test in this file so each one only varies what it's +// actually testing. +fn server_handshake_test_client_params() ClientHandshakeParams { + return ClientHandshakeParams{ + random: []u8{len: 32, init: 0x11} + server_name: 'example.com' + transport_parameters: QuicTransportParameters{ + initial_source_connection_id: []u8{len: 8, init: 0xaa} + } + ca_bundle_pem: '' + alpn_protocols: ['h3'] + } +} + +// server_handshake_test_server_params builds a valid, deterministic +// ServerHandshakeParams. `signing_key` is generated fresh per call (cheap, +// P-256) rather than shared, so tests never accidentally depend on +// call-ordering. +fn server_handshake_test_server_params() !ServerHandshakeParams { + _, signing_key := ecdsa.generate_key()! + return ServerHandshakeParams{ + transport_parameters: QuicTransportParameters{ + initial_source_connection_id: []u8{len: 8, init: 0xbb} + original_destination_connection_id: []u8{len: 8, init: 0xaa} + } + supported_alpn_protocols: ['h3'] + certificate_chain: [ + CertificateEntry{ + cert_data: []u8{len: 200, init: 0x30} + }, + ] + signing_key: signing_key + server_hello_random: []u8{len: 32, init: 0x22} + } +} + +// build_test_client_hello_body constructs a minimal but structurally valid +// ClientHello message BODY directly from this module's own low-level +// extension encoders (encode_supported_versions_extension, +// encode_key_share_extension, ...), with a caller-controlled +// transport_parameters -- unlike build_client_hello, this deliberately +// bypasses that function's own role-restriction checks (it refuses to +// encode a client-illegal transport parameter at all), so a test can +// construct the wire bytes a hand-crafted malicious or buggy client would +// send. encode_transport_parameters itself enforces no role restriction +// (that's the RECEIVING side's job, per its own doc comment) -- which is +// exactly what makes this possible. +fn build_test_client_hello_body(transport_parameters QuicTransportParameters) ![]u8 { + mut body := []u8{} + body << u8(0x03) + body << u8(0x03) + body << []u8{len: 32} + body << u8(0) // empty legacy_session_id + body << u8(0) + body << u8(2) + body << u8(cipher_suite_tls_aes_128_gcm_sha256 >> 8) + body << u8(cipher_suite_tls_aes_128_gcm_sha256) + body << u8(1) + body << u8(0) + + mut extensions := []u8{} + extensions << encode_supported_versions_extension()! + extensions << encode_signature_algorithms_extension()! + extensions << encode_alpn_extension(['h3'])! + extensions << encode_key_share_extension(named_group_secp256r1, []u8{len: 65, init: 0x04})! + extensions << encode_quic_transport_parameters_extension(transport_parameters)! + + body << u8(extensions.len >> 8) + body << u8(extensions.len) + body << extensions + return body +} + +// test_server_handshake_full_flow_agrees_with_real_client is the primary +// integration test: a REAL Tls13ClientHandshake and a REAL +// Tls13ServerHandshake run against each other, each independently written +// against the RFC text (never against each other), with fresh ECDHE +// keypairs on both sides -- not fixed RFC 8448 vectors. This is a strictly +// stronger cross-check than either side's own vector-based tests: it +// proves the two independently-implemented roles actually agree on wire +// bytes and derived secrets when talking to EACH OTHER, not just that each +// one matches a canned expected value in isolation. +// +// Certificate/CertificateVerify chain verification is NOT exercised here: +// this repo has no EC self-signed certificate fixture (only an RSA one, +// used by tls13_handshake_test.v's OWN fake-server tests, which +// encode_certificate_verify can't sign with -- RSA-PSS signing isn't wired +// up yet, see that function's own doc comment). This is the SAME documented +// gap already stated on encode_certificate_verify's own tests +// (tls13_certificate_test.v) and Phase 2c's x509_standalone_signature_test.v +// before it -- not silently skipped, stated here for the same reason. +// What IS proven below: real ECDH agreement, Handshake secret agreement, +// EncryptedExtensions/ALPN/transport-parameter cross-validation via the +// client's own real process_encrypted_extensions, and both directions' +// Finished messages verifying via each side's own independently-tested +// verify_finished/build_finished. +fn test_server_handshake_full_flow_agrees_with_real_client() { + mut client_h, client_hello := Tls13ClientHandshake.start(server_handshake_test_client_params())! + defer { + client_h.free() + } + ch_msg, ch_consumed := parse_handshake_message(client_hello)! + assert ch_consumed == client_hello.len + + server_params := server_handshake_test_server_params()! + mut server_h, flight := Tls13ServerHandshake.respond_to_client_hello(ch_msg, client_hello, + server_params)! + defer { + server_h.free() + } + assert server_h.state() == .wait_finished + assert flight.negotiated_alpn == 'h3' + + // Cross-check #1: the client's own real process_server_hello, given + // the server's real ServerHello, derives the EXACT SAME Handshake + // secrets the server itself computed -- proving the ECDH + key + // schedule genuinely agree between two independent implementations of + // each role, not just that each one is internally consistent. + sh_msg, sh_consumed := parse_handshake_message(flight.server_hello)! + assert sh_consumed == flight.server_hello.len + client_handshake_secrets := client_h.process_server_hello(sh_msg, flight.server_hello)! + assert client_handshake_secrets.handshake_secret == flight.handshake_secrets.handshake_secret + assert client_handshake_secrets.client_secret == flight.handshake_secrets.client_secret + assert client_handshake_secrets.server_secret == flight.handshake_secrets.server_secret + assert client_h.state() == .wait_encrypted_extensions + + // Split the flight's Handshake-level messages back into their 4 + // individually-framed pieces (EncryptedExtensions, Certificate, + // CertificateVerify, Finished) -- a real caller would already have + // these as separate CRYPTO-stream reads; this test reconstructs that + // split from the concatenated flight the same way it was built. + ee_msg, ee_consumed := parse_handshake_message(flight.handshake_messages)! + ee_framed := flight.handshake_messages[..ee_consumed] + rest_after_ee := flight.handshake_messages[ee_consumed..] + + cert_msg, cert_consumed := parse_handshake_message(rest_after_ee)! + cert_framed := rest_after_ee[..cert_consumed] + rest_after_cert := rest_after_ee[cert_consumed..] + + cv_msg, cv_consumed := parse_handshake_message(rest_after_cert)! + cv_framed := rest_after_cert[..cv_consumed] + rest_after_cv := rest_after_cert[cv_consumed..] + + fin_msg, fin_consumed := parse_handshake_message(rest_after_cv)! + assert fin_consumed == rest_after_cv.len // Finished is the last message in the flight + assert ee_msg.typ == .encrypted_extensions + assert cert_msg.typ == .certificate + assert cv_msg.typ == .certificate_verify + assert fin_msg.typ == .finished + + // Cross-check #2: the client's own real process_encrypted_extensions + // accepts the server's real EncryptedExtensions -- ALPN selection and + // every RFC 9000 §7.3 connection-ID/transport-parameter cross-check + // included. peer_initial_scid/original_dcid are this test's server/ + // client transport parameters' own values, matching what a real + // caller would have observed on the wire (the packet header SCID/DCID) + // rather than trusted blindly from the transport parameters alone. + client_h.process_encrypted_extensions(ee_msg, ee_framed, []u8{len: 8, init: 0xbb}, + []u8{len: 8, init: 0xaa}, none)! + assert client_h.negotiated_alpn()? == 'h3' + assert client_h.state() == .wait_certificate + + // Cross-check #3: the client's own real, RFC-8448-vector-tested + // verify_finished accepts the server's real build_finished output -- + // proving the two independently-written Finished implementations + // genuinely agree, not just that each one accepts its own output. + mut transcript_before_finished := []u8{} + transcript_before_finished << client_hello + transcript_before_finished << flight.server_hello + transcript_before_finished << ee_framed + transcript_before_finished << cert_framed + transcript_before_finished << cv_framed + ok := verify_finished(flight.handshake_secrets.server_secret, + sha256.sum256(transcript_before_finished), fin_msg.body)! + assert ok + + // Cross-check #4: a real client Finished, built via build_finished + // (the same function under test on the server's own side, applied to + // the CLIENT's secret this time), is accepted by the server's own + // process_finished -- closing the loop on both directions. + client_finished := build_finished(flight.handshake_secrets.client_secret, + sha256.sum256(server_h.transcript))! + cf_msg, cf_consumed := parse_handshake_message(client_finished)! + assert cf_consumed == client_finished.len + server_h.process_finished(cf_msg, client_finished)! + assert server_h.state() == .connected +} + +fn test_server_handshake_process_finished_rejects_tampered_verify_data() { + mut client_h, client_hello := Tls13ClientHandshake.start(server_handshake_test_client_params())! + defer { + client_h.free() + } + ch_msg, _ := parse_handshake_message(client_hello)! + server_params := server_handshake_test_server_params()! + mut server_h, flight := Tls13ServerHandshake.respond_to_client_hello(ch_msg, client_hello, + server_params)! + defer { + server_h.free() + } + + mut tampered := build_finished(flight.handshake_secrets.client_secret, + sha256.sum256(server_h.transcript))! + tampered[tampered.len - 1] ^= 0xff + cf_msg, _ := parse_handshake_message(tampered)! + server_h.process_finished(cf_msg, tampered) or { + assert err.msg().contains('does not match') + assert server_h.state() == .wait_finished // must NOT have advanced + return + } + assert false, 'expected an error for a tampered client Finished' +} + +fn test_server_handshake_rejects_unoffered_cipher_suite() { + body := build_test_client_hello_body(QuicTransportParameters{ + initial_source_connection_id: []u8{len: 8} + })! + // build_test_client_hello_body always offers TLS_AES_128_GCM_SHA256 at + // this fixed offset (legacy_version(2) + random(32) + + // legacy_session_id_len(1) + cipher_suites_len(2) = 37) -- splice in a + // different suite value to exercise the rejection. + mut bad_body := body.clone() + bad_body[37] = 0x13 + bad_body[38] = 0x02 // TLS_AES_256_GCM_SHA384, never offered/supported here + bad_msg := HandshakeMessage{ + typ: .client_hello + body: bad_body + } + bad_framed := encode_handshake_message(.client_hello, bad_body)! + server_params := server_handshake_test_server_params()! + Tls13ServerHandshake.respond_to_client_hello(bad_msg, bad_framed, server_params) or { + assert err.msg().contains('TLS_AES_128_GCM_SHA256') + return + } + assert false, 'expected an error for a ClientHello offering no supported cipher suite' +} + +fn test_server_handshake_rejects_alpn_mismatch() { + client_hello := build_client_hello( + random: []u8{len: 32} + server_name: 'example.com' + ecdhe_public_key: []u8{len: 65, init: 0x04} + transport_parameters: QuicTransportParameters{ + initial_source_connection_id: []u8{len: 8} + } + alpn_protocols: ['h2'] // this server (below) only supports 'h3' + )! + msg, _ := parse_handshake_message(client_hello)! + server_params := server_handshake_test_server_params()! + Tls13ServerHandshake.respond_to_client_hello(msg, client_hello, server_params) or { + assert err.msg().contains('no ALPN protocol in common') + return + } + assert false, 'expected an error for no ALPN protocol in common' +} + +fn test_server_handshake_rejects_server_only_transport_parameter_from_client() { + body := build_test_client_hello_body(QuicTransportParameters{ + initial_source_connection_id: []u8{len: 8} + stateless_reset_token: []u8{len: 16, init: 0x01} // client MUST NOT send this + })! + msg := HandshakeMessage{ + typ: .client_hello + body: body + } + framed := encode_handshake_message(.client_hello, body)! + server_params := server_handshake_test_server_params()! + Tls13ServerHandshake.respond_to_client_hello(msg, framed, server_params) or { + assert err.msg().contains('stateless_reset_token') + assert err.code() == int(quic_error_transport_parameter) + return + } + assert false, 'expected an error for a client-sent, server-only stateless_reset_token' +} + +fn test_server_handshake_propagates_nonempty_session_id_error_code() { + // A regression test that respond_to_client_hello's error wrapper + // (`if err.code() != 0 { return err }`) actually preserves + // parse_client_hello's own PROTOCOL_VIOLATION code for this case, + // rather than remapping it to the generic decode_error alert -- the + // exact class of wrapper bug process_server_hello/process_encrypted_ + // extensions's own doc comments warn against getting wrong. + mut body := []u8{} + body << u8(0x03) + body << u8(0x03) + body << []u8{len: 32} + body << u8(1) // non-empty session id -- always wrong, RFC 9001 §8.4 + body << u8(0xaa) + body << u8(0) + body << u8(2) + body << u8(cipher_suite_tls_aes_128_gcm_sha256 >> 8) + body << u8(cipher_suite_tls_aes_128_gcm_sha256) + body << u8(1) + body << u8(0) + body << u8(0) + body << u8(0) + msg := HandshakeMessage{ + typ: .client_hello + body: body + } + framed := encode_handshake_message(.client_hello, body)! + server_params := server_handshake_test_server_params()! + Tls13ServerHandshake.respond_to_client_hello(msg, framed, server_params) or { + assert err.code() == int(quic_error_protocol_violation) + return + } + assert false, 'expected an error for a non-empty legacy_session_id' +} From 01f754809d41bd3862ab07fc4968286d90d726b8 Mon Sep 17 00:00:00 2001 From: Richard Wheeler Date: Mon, 24 Aug 2026 14:42:31 -0400 Subject: [PATCH 05/10] net.quic: 13a - independently verify CertificateVerify's ECDSA signature Adds real cryptographic verification of encode_certificate_verify's output via crypto.ecdsa's PublicKey.verify() -- which already exists (vlib/crypto/ ecdsa/ecdsa.v:340) and was previously missed by an incomplete grep for the wrong receiver variable name, not actually absent. The prior round-trip test only confirmed wire framing and non-constancy; this closes the real gap: proving the signed content, key, and DER encoding all genuinely agree, not just that the bytes look plausible. Two new assertions: a wrong-transcript-hash/signature pairing must NOT verify (rules out a check that ignores the content), and a signature must be rejected by a DIFFERENT key's public half (rules out a verify() that accepts anything). This is a same-library round trip (OpenSSL signs, OpenSSL verifies) -- independent-library cross-verification via a real peer's mbedTLS still isn't exercised, since this repo has no EC certificate fixture to build an mbedtls_pk_context from. That remaining gap is unchanged and still documented; only the previously-incorrect "no PublicKey.verify() exists at all" claim is fixed. Full net.quic suite 55/55. --- vlib/net/quic/tls13_certificate_test.v | 58 +++++++++++++++++++++----- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/vlib/net/quic/tls13_certificate_test.v b/vlib/net/quic/tls13_certificate_test.v index c70a3e6e756796..fef73b041a2d9d 100644 --- a/vlib/net/quic/tls13_certificate_test.v +++ b/vlib/net/quic/tls13_certificate_test.v @@ -274,18 +274,22 @@ fn test_encode_certificate_rejects_entry_with_extensions() { // test_encode_certificate_verify_round_trips_through_parse_certificate_verify // verifies the WIRE FRAMING (algorithm field, signature length prefix) is -// correct and that the signature this function produces is plausible ECDSA -// DER output -- non-empty, and different for different transcript hashes -// (a constant/garbage signature would fail this). It does NOT -// cryptographically verify the signature against the public key: this -// codebase has no PublicKey.verify() exposed by crypto.ecdsa and no EC -// certificate fixture to build an mbedtls_pk_context from, the SAME -// documented gap Phase 2c's own x509_standalone_signature_test.v already -// states ("No EC private key exists anywhere in this repo, so the ECDSA -// path is tested only via rejecting an incompatible key") -- not silently -// skipped, stated here for the same reason. +// correct AND cryptographically verifies the produced signature via +// crypto.ecdsa's own PublicKey.verify() -- proving encode_certificate_verify +// actually signs the right content (certificate_verify_signed_content's +// output) with the right key, not just that it produces plausible-looking +// bytes. This is a same-library round trip (OpenSSL signs, OpenSSL +// verifies), not independent-library cross-verification the way this +// module's client-side chain verification eventually gets from a real +// peer's mbedTLS -- this repo has no EC certificate fixture to build an +// mbedtls_pk_context from for that, the same gap Phase 2c's own +// x509_standalone_signature_test.v documents for the identical reason. What +// IS proven here: the signed content, the key, and the DER encoding all +// actually agree -- the exact seam this function's own code introduces, as +// opposed to crypto.ecdsa's sign/verify primitives themselves, which are +// pre-existing and already used elsewhere in this codebase. fn test_encode_certificate_verify_round_trips_through_parse_certificate_verify() { - _, priv_key := ecdsa.generate_key()! + pub_key, priv_key := ecdsa.generate_key()! transcript_hash := []u8{len: 32, init: 0x01} msg := encode_certificate_verify(sig_scheme_ecdsa_secp256r1_sha256, priv_key, transcript_hash)! @@ -297,6 +301,9 @@ fn test_encode_certificate_verify_round_trips_through_parse_certificate_verify() assert result.algorithm == sig_scheme_ecdsa_secp256r1_sha256 assert result.signature.len > 0 + signed_content := certificate_verify_signed_content(.server, transcript_hash) + assert pub_key.verify(signed_content, result.signature, hash_config: .with_recommended_hash)! + other_transcript_hash := []u8{len: 32, init: 0x02} other_msg := encode_certificate_verify(sig_scheme_ecdsa_secp256r1_sha256, priv_key, other_transcript_hash)! @@ -305,6 +312,35 @@ fn test_encode_certificate_verify_round_trips_through_parse_certificate_verify() other_result := parse_certificate_verify(other_parsed.body)! assert other_consumed == other_msg.len assert other_result.signature != result.signature + + other_signed_content := certificate_verify_signed_content(.server, other_transcript_hash) + assert pub_key.verify(other_signed_content, other_result.signature, + hash_config: .with_recommended_hash + )! + // Cross-wired inputs must NOT verify -- confirms verify() is actually + // checking the content, not just the key/signature pair in isolation. + assert !pub_key.verify(signed_content, other_result.signature, + hash_config: .with_recommended_hash + )! +} + +// test_encode_certificate_verify_signature_rejected_by_wrong_public_key +// confirms a signature this function produces is rejected by a DIFFERENT +// key's public half -- the negative-space complement to the positive +// verification above, ruling out a verify() that accepts anything. +fn test_encode_certificate_verify_signature_rejected_by_wrong_public_key() { + _, priv_key := ecdsa.generate_key()! + other_pub_key, _ := ecdsa.generate_key()! + transcript_hash := []u8{len: 32, init: 0x03} + + msg := encode_certificate_verify(sig_scheme_ecdsa_secp256r1_sha256, priv_key, transcript_hash)! + parsed_msg, _ := parse_handshake_message(msg)! + result := parse_certificate_verify(parsed_msg.body)! + + signed_content := certificate_verify_signed_content(.server, transcript_hash) + assert !other_pub_key.verify(signed_content, result.signature, + hash_config: .with_recommended_hash + )! } fn test_encode_certificate_verify_rejects_unimplemented_algorithm() { From f66281dee8b33e04fe2b8df8b5b4d24d939be095 Mon Sep 17 00:00:00 2001 From: Richard Wheeler Date: Mon, 24 Aug 2026 14:46:20 -0400 Subject: [PATCH 06/10] net.quic: 13a - correct an overclaimed doc comment encode_certificate_verify's doc comment stated as settled fact that its OpenSSL-produced DER signature is directly compatible with net.mbedtls's verify_ecdsa_signature ("no reformatting needed between the two libraries"). That's the expected, standard behavior (OpenSSL's default EC signing format and mbedTLS's ECDSA verification both use ASN.1 DER ECDSA-Sig-Value, the TLS/X.509 convention), but this repo has never actually tested it: there's no EC certificate fixture to build an mbedtls_pk_context from for a real cross-library check, only a same-library (OpenSSL signs, OpenSSL verifies) round trip. Reworded to state what's actually verified (source inspection + the same-library test) versus what's expected-but-untested, rather than asserting settled fact. Caught during a requested pass verifying claims made across this PR's commits/comments. Everything else checked (RSA-PSS signing wrapper non-existence, EC certificate fixture non-existence, the three distinct key_share wire shapes, the supported_versions client/server asymmetry, and the application-secrets-derive-after-server-Finished timing) was confirmed accurate against the cached primary RFC text directly. --- vlib/net/quic/tls13_certificate.v | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/vlib/net/quic/tls13_certificate.v b/vlib/net/quic/tls13_certificate.v index 86788e6dbf3d0b..67be1edca3fd13 100644 --- a/vlib/net/quic/tls13_certificate.v +++ b/vlib/net/quic/tls13_certificate.v @@ -299,9 +299,17 @@ pub fn encode_certificate_verify(algorithm u16, signing_key ecdsa.PrivateKey, tr // vlib/crypto/ecdsa/ecdsa.v, keyed off the key's own bit size, matching // exactly what sig_scheme_ecdsa_secp256r1_sha256 requires. The // resulting signature is OpenSSL's standard ASN.1 DER ECDSA-Sig-Value - // encoding, the same format net.mbedtls's verify_ecdsa_signature (used - // on the client-side verify path, tls13_certificate_chain.c.v) already - // parses -- no reformatting needed between the two libraries. + // encoding (sign_digest, vlib/crypto/ecdsa/ecdsa.v, sets no raw/compact + // signature option, so OpenSSL's EVP_PKEY_sign default applies) -- + // the same format TLS/X.509 ECDSA signatures conventionally use, and + // what net.mbedtls's verify_ecdsa_signature (the client-side verify + // path, tls13_certificate_chain.c.v) is written to parse. This is + // confirmed by source inspection and this file's own same-library + // (OpenSSL signs, OpenSSL verifies) round-trip test + // (tls13_certificate_test.v), NOT by an actual cross-library + // OpenSSL-signs/mbedTLS-verifies test -- this repo has no EC + // certificate fixture to build an mbedtls_pk_context from for that + // (same gap verify_ecdsa_signature's own client-side tests document). signature := signing_key.sign(content, hash_config: .with_recommended_hash)! mut body := []u8{} From d29851d6bc30dc77935f37dfa00ff31391ef135a Mon Sep 17 00:00:00 2001 From: Richard Wheeler Date: Mon, 24 Aug 2026 15:07:51 -0400 Subject: [PATCH 07/10] net.quic: 13b - Retry packet construction, tokens, anti-amplification Completes Phase 13b (Retry + address validation, RFC 9000 SS8/SS8.1/SS17.2.5): - encode_retry_packet (retry.v): builds a complete Retry packet, reusing compute_retry_integrity_tag directly (already side-agnostic -- no new crypto needed for the tag itself). Round-trips through the already- existing, independently-written client-role verify_retry_integrity_tag/ parse_retry_packet -- proving the exact code that will receive this in production actually accepts it, not just that it looks well-formed. - generate_retry_token/validate_retry_token/validate_retry_token_for_attempt (retry_token.v, new): AEAD-sealed (AES-128-GCM) address-validation tokens. AEAD authentication alone satisfies RFC 9000 SS8.1.4's "difficult to guess" and integrity requirements -- no separate random component needed beyond the nonce GCM itself requires. validate_retry_token_for_attempt adds the two context-dependent checks SS8.1.4 calls for: bound client address must match, and a short expiry window. NEW_TOKEN-frame issuance (SS8.1.3, tokens reusable across future connections) is out of scope -- v1 only issues tokens via Retry. Full single-use replay tracking beyond the expiry window is deferred to 13d (needs a real listening socket to own a consumed-token cache's lifetime); the short window satisfies SS8.1.4's "prevented OR limited" replay requirement in the interim. - AntiAmplificationLimiter (anti_amplification.v, new): RFC 9000 SS8.1's 3x pre-validation send limit, deliberately mirroring flow_control.v's FlowControlWindow shape. Standalone and tested; not yet wired into any datagram-processing loop, since that loop doesn't exist until 13d. Found and flagged (not fixed here, out of scope): while choosing a CSPRNG for the token nonce, discovered conn.v's dial() uses V's general-purpose `rand` module (wyrand-backed, not cryptographically secure) for original_dcid/scid/client_random -- all security-relevant values that should use crypto.rand instead (identical API, OS-backed, already used elsewhere in this codebase). Real gap in already-merged Phase 9 code (PR #28129), flagged as a separate follow-up task. Full net.quic suite 57/57, ./vnew missdoc clean, ./vnew fmt -w applied. --- vlib/net/quic/PROGRESS.md | 37 ++++- vlib/net/quic/anti_amplification.v | 81 +++++++++ vlib/net/quic/anti_amplification_test.v | 65 ++++++++ vlib/net/quic/retry.v | 78 +++++++++ vlib/net/quic/retry_test.v | 55 +++++++ vlib/net/quic/retry_token.v | 208 ++++++++++++++++++++++++ vlib/net/quic/retry_token_test.v | 154 ++++++++++++++++++ 7 files changed, 674 insertions(+), 4 deletions(-) create mode 100644 vlib/net/quic/anti_amplification.v create mode 100644 vlib/net/quic/anti_amplification_test.v create mode 100644 vlib/net/quic/retry_token.v create mode 100644 vlib/net/quic/retry_token_test.v diff --git a/vlib/net/quic/PROGRESS.md b/vlib/net/quic/PROGRESS.md index 20d9c3d9c40739..d1e5c96ad31e68 100644 --- a/vlib/net/quic/PROGRESS.md +++ b/vlib/net/quic/PROGRESS.md @@ -1288,10 +1288,39 @@ stacked-PR convention as Phase 12's 12a-12d. verification is NOT exercised end-to-end (no EC certificate fixture in this repo — the same documented gap as `encode_certificate_verify`'s own tests). -- [ ] **13b** — Retry + address validation: Retry packet + opaque token - minting/validation (`retry.v` currently only verifies), RFC 9000 §8.1 - anti-amplification 3x accounting (already flagged as a deferred, - server-only branch in `loss_detection.v`/`coalesce.v`). +- [x] **13b** — Retry + address validation: + - [x] `encode_retry_packet` (`retry.v`) — builds a complete Retry packet, + reusing `compute_retry_integrity_tag` directly (already + side-agnostic). Round-trips through the already-existing, + independently-written client-role `verify_retry_integrity_tag`/ + `parse_retry_packet` — the strongest cross-check available: not + just "well-formed," but "the exact code that will receive this in + production accepts it." + - [x] `generate_retry_token`/`validate_retry_token`/ + `validate_retry_token_for_attempt` (`retry_token.v`, new file) — + AEAD-sealed (AES-128-GCM), authenticated address-validation tokens + satisfying RFC 9000 §8.1.4's difficult-to-guess and integrity + requirements via the AEAD tag itself. NEW_TOKEN-frame issuance + (§8.1.3, tokens reusable across future connections) is explicitly + out of scope — v1 only issues tokens via Retry. Single-use replay + tracking beyond a short expiry window is deferred to 13d, once a + real listening socket exists to own a consumed-token cache's + lifetime; a short `max_age_ms` window satisfies §8.1.4's "prevented + OR limited" replay requirement in the interim. + - [x] `AntiAmplificationLimiter` (`anti_amplification.v`, new file) — + RFC 9000 §8.1's 3x pre-validation send limit, mirroring + `flow_control.v`'s `FlowControlWindow` shape deliberately. A + standalone, tested accounting primitive — not yet wired into any + connection/datagram-processing loop, since that loop doesn't exist + until 13d. + - **Found and flagged, not fixed here (out of scope for this PR)**: while + picking a CSPRNG for the token nonce, discovered `conn.v`'s `dial()` + uses V's general-purpose `rand` module (wyrand-backed, NOT + cryptographically secure) for `original_dcid`/`scid`/`client_random` — + all three are security-relevant values that should use `crypto.rand` + instead (same API, OS-backed, already used elsewhere in this codebase). + This is a real gap in already-merged code (Phase 9, PR #28129), not + Phase 13 work — flagged as a separate follow-up task, not fixed inline. - [ ] **13c** — Connection ID lifecycle: `NEW_CONNECTION_ID`/ `RETIRE_CONNECTION_ID` frames (currently fall through `frame.v`'s generic "not yet implemented" branch), stateless reset token diff --git a/vlib/net/quic/anti_amplification.v b/vlib/net/quic/anti_amplification.v new file mode 100644 index 00000000000000..ec2e22455928b1 --- /dev/null +++ b/vlib/net/quic/anti_amplification.v @@ -0,0 +1,81 @@ +module quic + +// AntiAmplificationLimiter enforces RFC 9000 §8's server-side send limit +// before a client's address is validated: "after receiving packets from an +// address that is not yet validated, an endpoint MUST limit the amount of +// data it sends to the unvalidated address to three times the amount of +// data received from that address." Deliberately dumb: this type only +// counts bytes and answers "how many more may I send right now" -- it has +// no opinion on WHEN address validation actually completes (RFC 9000 §8.1 +// lists three independent ways: a Handshake-protected packet was received +// from the peer, the peer used a server-chosen connection ID with at least +// 64 bits of entropy, or a Retry/NEW_TOKEN token validated -- deciding +// which applies, and calling mark_validated() at that moment, is a future +// caller's job (13d's connection-acceptance path), not this primitive's). +// +// Mirrors flow_control.v's FlowControlWindow/ReceiveWindow shape +// deliberately -- same "dumb accounting primitive, caller decides policy" +// role, just for a different RFC-mandated limit. +pub struct AntiAmplificationLimiter { +mut: + received u64 + sent u64 + validated bool +} + +// note_received records `n` more bytes as received from this (still +// possibly unvalidated) address. RFC 9000 §8.1: "servers MUST count all of +// the payload bytes received in datagrams that are uniquely attributed to a +// single connection. This includes datagrams that contain packets that are +// successfully processed and datagrams that contain packets that are all +// discarded" -- the caller must count full UDP datagram payload bytes for +// every datagram attributed to this connection attempt, including ones +// this endpoint ultimately drops, not just the bytes of packets it +// successfully processes; this type has no visibility into that +// distinction and trusts the caller's count entirely. +pub fn (mut l AntiAmplificationLimiter) note_received(n u64) { + l.received += n +} + +// mark_validated permanently lifts the send limit -- RFC 9000 §8.1 imposes +// it only "prior to validating the client address"; once validated, this +// endpoint is constrained solely by its congestion controller, a +// completely separate mechanism (loss_detection.v/congestion_control.v) +// this type has no relationship to. There is no corresponding "un-validate" +// -- address validation, once achieved, does not lapse. +pub fn (mut l AntiAmplificationLimiter) mark_validated() { + l.validated = true +} + +// is_validated reports whether mark_validated has been called. +pub fn (l &AntiAmplificationLimiter) is_validated() bool { + return l.validated +} + +// available_to_send reports how many more bytes this endpoint may send +// right now without exceeding RFC 9000 §8.1's 3x limit -- max_u64 once +// validated, meaning this limit no longer applies at all, not merely that +// it has become large. +pub fn (l &AntiAmplificationLimiter) available_to_send() u64 { + if l.validated { + return max_u64 + } + limit := l.received * 3 + if l.sent >= limit { + return 0 + } + return limit - l.sent +} + +// note_sent records `n` more bytes as sent to this address, failing if that +// would exceed the current limit -- callers must check available_to_send() +// (or catch this error) BEFORE actually sending, never discover the +// violation only after the fact, the same convention +// FlowControlWindow.consume() already establishes for the analogous +// send-side check elsewhere in this module. +pub fn (mut l AntiAmplificationLimiter) note_sent(n u64) ! { + if !l.validated && l.sent + n > l.received * 3 { + return error('quic: anti-amplification limit exceeded: attempted to send ${n} bytes, only ${l.available_to_send()} available (received ${l.received}, already sent ${l.sent})') + } + l.sent += n +} diff --git a/vlib/net/quic/anti_amplification_test.v b/vlib/net/quic/anti_amplification_test.v new file mode 100644 index 00000000000000..e099ffd986e2d8 --- /dev/null +++ b/vlib/net/quic/anti_amplification_test.v @@ -0,0 +1,65 @@ +module quic + +fn test_anti_amplification_starts_at_zero() { + lim := AntiAmplificationLimiter{} + assert lim.available_to_send() == 0 + assert lim.is_validated() == false +} + +fn test_anti_amplification_allows_up_to_3x_received() { + mut lim := AntiAmplificationLimiter{} + lim.note_received(100) + assert lim.available_to_send() == 300 + lim.note_sent(300)! + assert lim.available_to_send() == 0 +} + +fn test_anti_amplification_rejects_over_limit_send() { + mut lim := AntiAmplificationLimiter{} + lim.note_received(100) + lim.note_sent(301) or { + assert err.msg().contains('anti-amplification limit exceeded') + return + } + assert false, 'expected sending 301 bytes on a 300-byte budget to fail' +} + +fn test_anti_amplification_accumulates_across_multiple_receives() { + mut lim := AntiAmplificationLimiter{} + lim.note_received(50) + lim.note_received(50) + assert lim.available_to_send() == 300 +} + +fn test_anti_amplification_tracks_remaining_budget_across_sends() { + mut lim := AntiAmplificationLimiter{} + lim.note_received(100) + lim.note_sent(120)! + assert lim.available_to_send() == 180 + lim.note_sent(180)! + assert lim.available_to_send() == 0 +} + +fn test_anti_amplification_mark_validated_lifts_the_limit() { + mut lim := AntiAmplificationLimiter{} + lim.note_received(10) + lim.note_sent(30)! + assert lim.available_to_send() == 0 + + lim.mark_validated() + assert lim.is_validated() + assert lim.available_to_send() == max_u64 + // A send far exceeding any pre-validation budget must now succeed. + lim.note_sent(1_000_000_000)! +} + +fn test_anti_amplification_receiving_more_raises_the_budget_even_after_a_send() { + mut lim := AntiAmplificationLimiter{} + lim.note_received(100) + lim.note_sent(300)! + assert lim.available_to_send() == 0 + + lim.note_received(50) + // New limit is 3*150=450, already sent 300 -> 150 more available. + assert lim.available_to_send() == 150 +} diff --git a/vlib/net/quic/retry.v b/vlib/net/quic/retry.v index 1440416989fbb3..aa97d6916b2865 100644 --- a/vlib/net/quic/retry.v +++ b/vlib/net/quic/retry.v @@ -107,6 +107,84 @@ pub fn parse_retry_packet(buf []u8, original_dcid []u8, original_scid []u8) !Qui } } +// RetryPacketParams is everything encode_retry_packet needs. +pub struct RetryPacketParams { +pub: + // The Source Connection ID from the client's Initial packet that + // provoked this Retry -- becomes this Retry packet's OWN Destination + // Connection ID (an echo, not a new value; see parse_retry_packet's + // doc comment for the same field semantics on the parse side). + client_scid []u8 + // This server's newly chosen connection ID for the retried connection + // attempt -- becomes this Retry packet's Source Connection ID. RFC + // 9000 §17.2.5.1: "This value MUST NOT be equal to the Destination + // Connection ID field of the packet sent by the client" -- checked + // below (against `original_dcid`, not `client_scid`; a Retry echoing + // the client's SCID back as its own new DCID is normal, expected + // behavior, not the degenerate case this MUST forbids). + server_scid []u8 + // The Destination Connection ID from the client's Initial packet -- + // required for BOTH compute_retry_integrity_tag's own AAD (RFC 9001 + // §5.8) and as the address-validation token's own claim (see + // generate_retry_token, retry_token.v). + original_dcid []u8 + // The AEAD key this server uses for its own address-validation + // tokens -- see retry_token.v's own key-length/rotation doc comments. + token_key []u8 + // A caller-serialized identifier for the client's current source + // address (IP + port), bound into the token so a later Initial + // presenting it can be checked against the ADDRESS IT ARRIVES FROM -- + // opaque to this function and retry_token.v alike; the caller decides + // the exact byte representation as long as it's used consistently + // between issuance and validation. + client_addr []u8 + // A caller-supplied monotonic timestamp (matching this module's + // existing time.sys_mono_now()-sourced convention, e.g. + // idle_timeout.v's `now u64` parameters) recording when this token was + // issued, for the short-expiry check RFC 9000 §8.1.4 recommends + // ("SHOULD ensure that tokens sent in Retry packets are only accepted + // for a short time"). + issued_at_ms u64 +} + +// encode_retry_packet constructs a complete Retry packet (RFC 9000 +// §17.2.5), including a fresh address-validation token (generate_retry_token, +// retry_token.v) and the Retry Integrity Tag (compute_retry_integrity_tag, +// this file -- already side-agnostic, reused directly rather than +// duplicated). The header's Unused 4 bits (RFC 9000 §17.2.5, Figure 18) are +// set to zero: "The value in the Unused field is set to an arbitrary value +// by the server; a client MUST ignore these bits" -- zero is as arbitrary +// as any other value and keeps the packet deterministic for testing. +pub fn encode_retry_packet(p RetryPacketParams) ![]u8 { + // RFC 9000 §17.2.5.1: "This value MUST NOT be equal to the Destination + // Connection ID field of the packet sent by the client" -- a Retry + // violating this would be silently discarded by any compliant client + // (parse_retry_packet's own anti-loop check, this file), so refusing + // to construct one here catches a caller's CID-generation bug before + // it produces a packet that could never actually complete a handshake. + if p.server_scid == p.original_dcid { + return error("quic: Retry server_scid must not equal the client's original Initial dcid (RFC 9000 §17.2.5.1)") + } + + token := generate_retry_token(p.token_key, RetryTokenClaims{ + client_addr: p.client_addr + original_dcid: p.original_dcid + issued_at_ms: p.issued_at_ms + })! + + header := QuicLongHeader{ + typ: .retry + version: quic_v1 + dcid: p.client_scid + scid: p.server_scid + } + mut packet := encode_long_header(header, 0, 0)! + packet << token + tag := compute_retry_integrity_tag(p.original_dcid, packet)! + packet << tag + return packet +} + // compute_retry_integrity_tag computes the expected 16-byte Retry // Integrity Tag (RFC 9001 §5.8) given the ORIGINAL destination connection // ID the client used in the Initial packet that provoked this Retry diff --git a/vlib/net/quic/retry_test.v b/vlib/net/quic/retry_test.v index fa5cadfce163ae..51e547ae09e38d 100644 --- a/vlib/net/quic/retry_test.v +++ b/vlib/net/quic/retry_test.v @@ -193,3 +193,58 @@ fn test_verify_retry_integrity_tag_discards_when_already_processed_other_packet( ok := verify_retry_integrity_tag(original_dcid, packet, true)! assert ok == false } + +// encode_retry_packet (Phase 13b, server-role construction). Round-tripped +// through this same file's own verify_retry_integrity_tag/ +// parse_retry_packet -- the client-role functions, written and tested in an +// earlier phase, completely independently of this server-role code. This +// is the strongest possible cross-check available in this module: it isn't +// merely "does this look like a well-formed packet," it's "does the exact +// client code that will actually receive this in production accept it." + +fn test_encode_retry_packet_round_trips_through_client_verification() { + client_scid := [u8(0x55), 0x66, 0x77, 0x88] + original_dcid := [u8(0xaa), 0xbb, 0xcc, 0xdd] + server_scid := [u8(9), 10, 11, 12] + key := []u8{len: retry_token_key_len, init: 0x42} + client_addr := [u8(192), 168, 1, 1, 0x1f, 0x90] + + packet := encode_retry_packet( + client_scid: client_scid + server_scid: server_scid + original_dcid: original_dcid + token_key: key + client_addr: client_addr + issued_at_ms: 500 + )! + + ok := verify_retry_integrity_tag(original_dcid, packet, false)! + assert ok + parsed := parse_retry_packet(packet, original_dcid, client_scid)! + assert parsed.dcid == client_scid + assert parsed.scid == server_scid + assert parsed.retry_token.len > 0 + + // The retried Initial's token is exactly parsed.retry_token -- validate + // it the way the server's own future connection-acceptance path would. + claims := validate_retry_token_for_attempt(key, parsed.retry_token, client_addr, 500, 30000)! + assert claims.original_dcid == original_dcid + assert claims.client_addr == client_addr + assert claims.issued_at_ms == 500 +} + +fn test_encode_retry_packet_rejects_server_scid_equal_to_original_dcid() { + original_dcid := [u8(0xaa), 0xbb, 0xcc, 0xdd] + encode_retry_packet( + client_scid: [u8(1), 2, 3, 4] + server_scid: original_dcid // degenerate: RFC 9000 §17.2.5.1 forbids this + original_dcid: original_dcid + token_key: []u8{len: retry_token_key_len} + client_addr: [u8(1)] + issued_at_ms: 0 + ) or { + assert err.msg().contains('must not equal') + return + } + assert false, 'expected an error when server_scid equals original_dcid' +} diff --git a/vlib/net/quic/retry_token.v b/vlib/net/quic/retry_token.v new file mode 100644 index 00000000000000..faddde90412805 --- /dev/null +++ b/vlib/net/quic/retry_token.v @@ -0,0 +1,208 @@ +module quic + +import crypto.aes +import crypto.rand + +// Server-side address-validation token generation and validation (RFC 9000 +// §8.1.1/§8.1.2/§8.1.4). v1 only issues tokens via Retry packets -- NEW_TOKEN +// frame issuance (§8.1.3, tokens usable across separate future connections) +// is out of scope, the same class of deliberate-defer choice this project +// makes elsewhere (see PROGRESS.md's Phase 13 checklist); RFC 9000 §8.1.1's +// only real cross-cutting requirement ("a token... MUST be constructed in a +// way that allows the server to identify how it was provided") has no +// second source to distinguish from yet, so it doesn't need addressing +// until NEW_TOKEN support, if ever, is added. +// +// There is no single well-defined wire format for a token (RFC 9000 +// §8.1.4: "There is no need for a single well-defined format for the token +// because the server that generates the token also consumes it") -- this +// module's own choice: a random nonce, then an AES-128-GCM-sealed blob of +// RetryTokenClaims, authenticated (not merely encrypted) so a client can +// never forge or usefully tamper with one. AEAD authentication alone +// satisfies §8.1.4's "MUST be difficult to guess" requirement (a GCM tag +// provides far more than the RFC's suggested 128 bits of resistance) without +// needing a SEPARATE random component beyond the nonce GCM itself requires +// for security. + +pub const retry_token_key_len = 16 + +// retry_token_nonce_len matches crypto.aes.AesGcm's own nonce_size() (12 +// bytes) -- duplicated as a const rather than computed from a live AesGcm +// instance so token length can be validated before constructing one. +pub const retry_token_nonce_len = 12 + +// RetryTokenClaims is everything a validated token proves about the +// address-validation attempt it was issued for. +pub struct RetryTokenClaims { +pub: + // A caller-serialized identifier for the client's source address (IP + + // port) at issuance time -- opaque to this module; the caller decides + // the exact byte representation as long as it's produced the same way + // at issuance and at validation. RFC 9000 §8.1.4: "Tokens sent in + // Retry packets SHOULD include information that allows the server to + // verify that the source IP address and port in client packets remain + // constant." + client_addr []u8 + // The client's own Destination Connection ID on the Initial packet + // that provoked the Retry this token was issued in -- becomes the + // connection's original_destination_connection_id transport parameter + // once the retried Initial arrives (RFC 9000 §7.3/§18.2), and lets + // validation reject a token replayed against a DIFFERENT original + // DCID than the one it was actually issued for. + original_dcid []u8 + // A caller-supplied monotonic timestamp (time.sys_mono_now()-sourced, + // matching this module's existing idle_timeout.v convention) recording + // when this token was issued. + issued_at_ms u64 +} + +// encode_retry_token_claims serializes RetryTokenClaims to the plaintext +// this module encrypts -- a simple length-prefixed layout, since (per this +// file's own doc comment) there is no wire-interop requirement to satisfy, +// only round-trip fidelity with decode_retry_token_claims. +fn encode_retry_token_claims(c RetryTokenClaims) ![]u8 { + if c.client_addr.len > 0xff { + return error('quic: retry token client_addr too long: ${c.client_addr.len} bytes') + } + if c.original_dcid.len > 0xff { + return error('quic: retry token original_dcid too long: ${c.original_dcid.len} bytes') + } + mut out := []u8{} + out << u8(c.client_addr.len) + out << c.client_addr + out << u8(c.original_dcid.len) + out << c.original_dcid + out << u8(c.issued_at_ms >> 56) + out << u8(c.issued_at_ms >> 48) + out << u8(c.issued_at_ms >> 40) + out << u8(c.issued_at_ms >> 32) + out << u8(c.issued_at_ms >> 24) + out << u8(c.issued_at_ms >> 16) + out << u8(c.issued_at_ms >> 8) + out << u8(c.issued_at_ms) + return out +} + +fn decode_retry_token_claims(buf []u8) !RetryTokenClaims { + if buf.len < 1 { + return error('quic: truncated retry token claims: missing client_addr length') + } + mut cursor := 0 + addr_len := int(buf[cursor]) + cursor += 1 + if cursor + addr_len > buf.len { + return error('quic: truncated retry token claims: client_addr declares ${addr_len} bytes exceeding the remaining buffer') + } + client_addr := buf[cursor..cursor + addr_len].clone() + cursor += addr_len + + if cursor >= buf.len { + return error('quic: truncated retry token claims: missing original_dcid length') + } + dcid_len := int(buf[cursor]) + cursor += 1 + if cursor + dcid_len > buf.len { + return error('quic: truncated retry token claims: original_dcid declares ${dcid_len} bytes exceeding the remaining buffer') + } + original_dcid := buf[cursor..cursor + dcid_len].clone() + cursor += dcid_len + + if buf.len - cursor != 8 { + return error('quic: truncated retry token claims: need exactly 8 bytes for issued_at_ms, have ${buf.len - cursor}') + } + issued_at_ms := (u64(buf[cursor]) << 56) | (u64(buf[cursor + 1]) << 48) | (u64(buf[cursor + 2]) << 40) | (u64(buf[ + cursor + 3]) << 32) | (u64(buf[cursor + 4]) << 24) | (u64(buf[cursor + 5]) << 16) | (u64(buf[ + cursor + 6]) << 8) | u64(buf[cursor + 7]) + + return RetryTokenClaims{ + client_addr: client_addr + original_dcid: original_dcid + issued_at_ms: issued_at_ms + } +} + +// generate_retry_token produces a fresh, authenticated address-validation +// token for `claims`, encrypted under `key` (exactly retry_token_key_len +// bytes -- a server-instance-local secret the caller generates once, e.g. +// via crypto.rand.bytes(retry_token_key_len), and keeps ONLY on the server; +// RFC 9000 §8.1.4: "Only the server requires access to the integrity +// protection key for tokens"). The nonce is freshly randomized every call +// via crypto.rand -- the OS-backed CSPRNG this file imports, deliberately +// NOT V's general-purpose `rand` module (wyrand-backed, not cryptographically +// secure) -- and prepended to the sealed output, since AES-GCM requires a +// unique nonce per encryption under the same key and the server has no +// per-token state to derive one from statelessly. +pub fn generate_retry_token(key []u8, claims RetryTokenClaims) ![]u8 { + if key.len != retry_token_key_len { + return error('quic: retry token key must be exactly ${retry_token_key_len} bytes, got ${key.len}') + } + nonce := rand.bytes(retry_token_nonce_len)! + plaintext := encode_retry_token_claims(claims)! + aead := aes.new_aes_gcm(key)! + sealed := aead.encrypt(plaintext, nonce, []u8{})! + + mut token := []u8{cap: nonce.len + sealed.len} + token << nonce + token << sealed + return token +} + +// validate_retry_token authenticates and decrypts `token`, returning its +// claims. A failure here (bad key, tampered bytes, or truncated input) +// means the token must be rejected outright -- RFC 9000 §8.1.2: "If a +// server receives a client Initial that contains an invalid Retry token... +// the server SHOULD immediately close the connection with an INVALID_TOKEN +// error." This function only performs the CRYPTOGRAPHIC check; the caller +// (validate_retry_token_for_attempt, below, or a future connection- +// acceptance path) is responsible for the address/expiry checks that need +// context this function doesn't have. +pub fn validate_retry_token(key []u8, token []u8) !RetryTokenClaims { + if key.len != retry_token_key_len { + return error('quic: retry token key must be exactly ${retry_token_key_len} bytes, got ${key.len}') + } + if token.len <= retry_token_nonce_len { + return error('quic: retry token too short to contain a nonce and sealed payload') + } + nonce := token[..retry_token_nonce_len] + sealed := token[retry_token_nonce_len..] + aead := aes.new_aes_gcm(key)! + plaintext := aead.decrypt(sealed, nonce, []u8{})! + return decode_retry_token_claims(plaintext)! +} + +// validate_retry_token_for_attempt is validate_retry_token plus the two +// context-dependent checks RFC 9000 §8.1.4 calls for: the token's bound +// client address must match where THIS Initial actually arrived from (a +// changed source address invalidates the token even if cryptographically +// genuine -- it was issued for a different address), and it must not have +// expired. `max_age_ms` is the caller's chosen short-lived window (RFC 9000 +// §8.1.4: "Servers SHOULD ensure that tokens sent in Retry packets are only +// accepted for a short time, as they are returned immediately by clients"). +// +// `now_ms`/the claims' own `issued_at_ms` are both expected to be +// time.sys_mono_now()-sourced instants from the SAME continuously-running +// server process that issued the token (this module's established +// convention, e.g. idle_timeout.v) -- under that assumption `now_ms` can +// never legitimately precede `issued_at_ms`. The check below still treats +// that case as expired rather than underflowing the u64 subtraction, purely +// as a defensive guard against a caller passing an inconsistent `now_ms`, +// not because it's an expected code path. +// +// Single-use / replay-prevention beyond this short expiry window is NOT +// implemented -- RFC 9000 §8.1.4's "MUST ensure that replay of tokens is +// prevented or limited" (the OR: limited) is what a short max_age_ms +// satisfies; full single-use tracking would need either persistent server +// state (contradicting the stateless design this module otherwise follows) +// or a short-lived consumed-token cache, which belongs with 13d's +// connection-acceptance path once a real listening socket exists to own +// that cache's lifetime, not this stateless primitive. +pub fn validate_retry_token_for_attempt(key []u8, token []u8, client_addr []u8, now_ms u64, max_age_ms u64) !RetryTokenClaims { + claims := validate_retry_token(key, token)! + if claims.client_addr != client_addr { + return error('quic: retry token was issued for a different client address') + } + if now_ms < claims.issued_at_ms || now_ms - claims.issued_at_ms > max_age_ms { + return error('quic: retry token has expired') + } + return claims +} diff --git a/vlib/net/quic/retry_token_test.v b/vlib/net/quic/retry_token_test.v new file mode 100644 index 00000000000000..71410e7518dd74 --- /dev/null +++ b/vlib/net/quic/retry_token_test.v @@ -0,0 +1,154 @@ +// vtest build: present_openssl? +module quic + +fn test_retry_token_round_trip() { + key := []u8{len: retry_token_key_len, init: 0x11} + claims := RetryTokenClaims{ + client_addr: [u8(127), 0, 0, 1, 0x1f, 0x90] + original_dcid: [u8(1), 2, 3, 4, 5, 6, 7, 8] + issued_at_ms: 123456 + } + token := generate_retry_token(key, claims)! + got := validate_retry_token(key, token)! + assert got.client_addr == claims.client_addr + assert got.original_dcid == claims.original_dcid + assert got.issued_at_ms == claims.issued_at_ms +} + +fn test_retry_token_is_randomized_per_call() { + // Two tokens for the IDENTICAL claims must differ -- the nonce is + // freshly randomized every call (required for AES-GCM security under a + // reused key; a constant nonce would be a real, exploitable bug, not + // just a test-coverage gap). + key := []u8{len: retry_token_key_len, init: 0x22} + claims := RetryTokenClaims{ + client_addr: [u8(1), 2, 3, 4] + original_dcid: [u8(5), 6, 7, 8] + issued_at_ms: 1000 + } + token1 := generate_retry_token(key, claims)! + token2 := generate_retry_token(key, claims)! + assert token1 != token2 + // Both must still independently validate to the SAME claims. + assert validate_retry_token(key, token1)!.issued_at_ms == 1000 + assert validate_retry_token(key, token2)!.issued_at_ms == 1000 +} + +fn test_retry_token_rejects_wrong_key() { + key := []u8{len: retry_token_key_len, init: 0x33} + other_key := []u8{len: retry_token_key_len, init: 0x44} + token := generate_retry_token(key, RetryTokenClaims{ + client_addr: [u8(1)] + original_dcid: [u8(2)] + issued_at_ms: 0 + })! + validate_retry_token(other_key, token) or { return } + assert false, 'expected a wrong key to fail validation' +} + +fn test_retry_token_rejects_tampered_bytes() { + key := []u8{len: retry_token_key_len, init: 0x55} + mut token := generate_retry_token(key, RetryTokenClaims{ + client_addr: [u8(1), 2] + original_dcid: [u8(3)] + issued_at_ms: 0 + })! + token[token.len - 1] ^= 0x01 + validate_retry_token(key, token) or { return } + assert false, 'expected a tampered token to fail validation' +} + +fn test_retry_token_rejects_short_input() { + key := []u8{len: retry_token_key_len} + validate_retry_token(key, []u8{len: retry_token_nonce_len}) or { + assert err.msg().contains('too short') + return + } + assert false, 'expected a too-short token to be rejected' +} + +fn test_retry_token_rejects_wrong_key_length() { + generate_retry_token([]u8{len: 10}, RetryTokenClaims{}) or { + assert err.msg().contains('${retry_token_key_len}') + return + } + assert false, 'expected a wrong-length key to be rejected' +} + +fn test_validate_retry_token_for_attempt_accepts_within_window() { + key := []u8{len: retry_token_key_len, init: 0x66} + client_addr := [u8(10), 0, 0, 1, 0x00, 0x50] + token := generate_retry_token(key, RetryTokenClaims{ + client_addr: client_addr + original_dcid: [u8(1), 2, 3, 4] + issued_at_ms: 10_000 + })! + claims := validate_retry_token_for_attempt(key, token, client_addr, 15_000, 30_000)! + assert claims.issued_at_ms == 10_000 +} + +fn test_validate_retry_token_for_attempt_rejects_expired() { + key := []u8{len: retry_token_key_len, init: 0x77} + client_addr := [u8(10), 0, 0, 1, 0x00, 0x50] + token := generate_retry_token(key, RetryTokenClaims{ + client_addr: client_addr + original_dcid: [u8(1), 2, 3, 4] + issued_at_ms: 10_000 + })! + // now_ms is 40_001 ms after issuance, exceeding a 30_000ms window by + // exactly 1ms -- an off-by-one boundary test, not just "way expired." + validate_retry_token_for_attempt(key, token, client_addr, 50_001, 30_000) or { + assert err.msg().contains('expired') + return + } + assert false, 'expected an expired token to be rejected' +} + +fn test_validate_retry_token_for_attempt_accepts_at_exact_boundary() { + key := []u8{len: retry_token_key_len, init: 0x88} + client_addr := [u8(10), 0, 0, 1, 0x00, 0x50] + token := generate_retry_token(key, RetryTokenClaims{ + client_addr: client_addr + original_dcid: [u8(1), 2, 3, 4] + issued_at_ms: 10_000 + })! + // Exactly max_age_ms after issuance must still be accepted (the check + // is `> max_age_ms`, not `>=`). + claims := validate_retry_token_for_attempt(key, token, client_addr, 40_000, 30_000)! + assert claims.issued_at_ms == 10_000 +} + +fn test_validate_retry_token_for_attempt_rejects_address_mismatch() { + key := []u8{len: retry_token_key_len, init: 0x99} + issued_addr := [u8(10), 0, 0, 1, 0x00, 0x50] + different_addr := [u8(10), 0, 0, 2, 0x00, 0x50] + token := generate_retry_token(key, RetryTokenClaims{ + client_addr: issued_addr + original_dcid: [u8(1), 2, 3, 4] + issued_at_ms: 0 + })! + validate_retry_token_for_attempt(key, token, different_addr, 0, 30_000) or { + assert err.msg().contains('different client address') + return + } + assert false, 'expected an address mismatch to be rejected' +} + +fn test_validate_retry_token_for_attempt_rejects_now_before_issued_at() { + // Defensive guard against a caller-supplied now_ms that precedes + // issued_at_ms -- see validate_retry_token_for_attempt's own doc + // comment for why this shouldn't happen in real use, and why it's + // still checked rather than left to underflow the u64 subtraction. + key := []u8{len: retry_token_key_len, init: 0xaa} + client_addr := [u8(1)] + token := generate_retry_token(key, RetryTokenClaims{ + client_addr: client_addr + original_dcid: [u8(2)] + issued_at_ms: 10_000 + })! + validate_retry_token_for_attempt(key, token, client_addr, 9_999, 30_000) or { + assert err.msg().contains('expired') + return + } + assert false, 'expected now_ms before issued_at_ms to be rejected, not underflow' +} From bc5f108b9c819e99b86519ae36a014093cff7045 Mon Sep 17 00:00:00 2001 From: Richard Wheeler Date: Mon, 24 Aug 2026 15:48:52 -0400 Subject: [PATCH 08/10] net.quic: 13c - connection ID lifecycle (NEW_CONNECTION_ID/RETIRE_CONNECTION_ID) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the RFC 9000 §19.15/§19.16 wire codec for NEW_CONNECTION_ID/ RETIRE_CONNECTION_ID frames (frame.v), previously falling through parse_frame's generic "not yet implemented" branch, plus generate_stateless_reset_token (stateless_reset.v) implementing RFC 9000 §10.3.2's recommended HMAC-SHA-256(static_key, connection_id) derivation, cross-checked against the existing StatelessResetTracker.is_stateless_reset. Also updates QuicConn.dispatch_one_rtt_frame (conn.v) to explicitly acknowledge the two new frame types now flow through it instead of silently landing in the generic informational-hint else-arm, documenting why they're accepted-but-not-yet-acted-upon pending the (deliberately deferred) active-connection-ID-set state machine. Driving that active set (issuing more CIDs, active_connection_id_limit accounting) remains out of scope, same as before this commit. --- vlib/net/quic/PROGRESS.md | 38 +++++++- vlib/net/quic/conn.v | 18 ++++ vlib/net/quic/frame.v | 129 ++++++++++++++++++++++++ vlib/net/quic/frame_test.v | 140 +++++++++++++++++++++++++-- vlib/net/quic/stateless_reset.v | 29 ++++++ vlib/net/quic/stateless_reset_test.v | 52 ++++++++++ 6 files changed, 393 insertions(+), 13 deletions(-) diff --git a/vlib/net/quic/PROGRESS.md b/vlib/net/quic/PROGRESS.md index d1e5c96ad31e68..be5bc0616c6902 100644 --- a/vlib/net/quic/PROGRESS.md +++ b/vlib/net/quic/PROGRESS.md @@ -1321,11 +1321,39 @@ stacked-PR convention as Phase 12's 12a-12d. instead (same API, OS-backed, already used elsewhere in this codebase). This is a real gap in already-merged code (Phase 9, PR #28129), not Phase 13 work — flagged as a separate follow-up task, not fixed inline. -- [ ] **13c** — Connection ID lifecycle: `NEW_CONNECTION_ID`/ - `RETIRE_CONNECTION_ID` frames (currently fall through `frame.v`'s - generic "not yet implemented" branch), stateless reset token - generation per issued CID (`StatelessResetTracker` currently only - checks incoming tokens). +- [x] **13c** — Connection ID lifecycle: + - [x] `NewConnectionIdFrame`/`RetireConnectionIdFrame` wire codec + (`frame.v`) — `encode_new_connection_id_frame`/ + `parse_new_connection_id_frame` and their RETIRE_CONNECTION_ID + counterparts, types 0x18/0x19, previously falling through + `parse_frame`'s generic "not yet implemented" branch. Enforces the + two frame-local RFC 9000 §19.15 requirements (`retire_prior_to` ≤ + `sequence_number`; connection ID length in 1-20 bytes), on both the + encode and decode sides so a caller can't construct a frame this + module's own parser would then reject. Every OTHER §19.15/§19.16 + requirement (zero-length-DCID prohibition, duplicate/conflicting + sequence numbers, a RETIRE_CONNECTION_ID referencing the current + packet's own DCID) needs connection state `parse_frame` doesn't + have — deferred to the caller, the same division already + established for `HandshakeDoneFrame`'s role check. + - [x] `generate_stateless_reset_token` (`stateless_reset.v`) — RFC 9000 + §10.3.2's recommended construction, `HMAC-SHA-256(static_key, + connection_id)` truncated to 16 bytes: a server-instance-local + secret plus the connection ID deterministically reproduces the + SAME token, so an endpoint that has lost all per-connection state + (the entire premise of a stateless reset) can still recompute it. + Cross-checked against `StatelessResetTracker.is_stateless_reset` + (already-existing, independently-written matching logic) — proving + a token this function generates is actually recognized by the + exact code that would validate it in production. + - **Deliberately still out of scope** (per `stateless_reset.v`'s own + long-standing note, unchanged by 13c): driving an ACTIVE SET of usable + connection IDs — issuing more as the peer retires them, + `active_connection_id_limit` accounting, `CONNECTION_ID_LIMIT_ERROR` + enforcement. That full lifecycle exists to support connection + migration, which PROGRESS.md already lists as a separate, explicitly + deferrable follow-up below — 13c ships the wire codec and the token + primitive it depends on, not the state machine that would consume them. - [ ] **13d** — UDP listener + connection demux: one socket routing many concurrent connections by connection ID (not 4-tuple, since QUIC supports migration) — no analog in the client's transport today; the diff --git a/vlib/net/quic/conn.v b/vlib/net/quic/conn.v index d7ce5f3395880a..bd3c912eb7fe77 100644 --- a/vlib/net/quic/conn.v +++ b/vlib/net/quic/conn.v @@ -1029,6 +1029,24 @@ fn (mut c QuicConn) dispatch_one_rtt_frame(frame QuicFrame, now u64, mut result } } } + NewConnectionIdFrame, RetireConnectionIdFrame { + // Legal here (RFC 9000 §12.4 Table 3 marks both 1-RTT-only, + // consistent with dispatch_pre_confirm_frame's own rejection + // of them in the Initial/Handshake spaces), accepted, but not + // yet acted upon: driving an ACTIVE SET of usable connection + // IDs (issuing more as the peer retires them, + // active_connection_id_limit accounting, the §19.15/§19.16 + // requirements that need that same state -- e.g. §19.16's + // "sequence number greater than any previously sent" check) + // is a deliberately deferred state machine, not yet built -- + // see stateless_reset.v's doc comment and PROGRESS.md's Phase + // 13c scope note. The wire codec these frames now decode + // through (frame.v) exists so that state machine has + // something to consume once it lands; until then, a peer + // sending either is simply ignored, the same as this + // function's own CryptoFrame arm ignores post-handshake + // CRYPTO for an unimplemented feature. + } else { // DATA_BLOCKED/STREAM_DATA_BLOCKED/STREAMS_BLOCKED: purely // informational hints (RFC 9000 §19.12-§19.14 impose no MUST diff --git a/vlib/net/quic/frame.v b/vlib/net/quic/frame.v index 4443dad7b40f66..d857439023f1ad 100644 --- a/vlib/net/quic/frame.v +++ b/vlib/net/quic/frame.v @@ -211,6 +211,37 @@ pub: // analogous note). pub struct HandshakeDoneFrame {} +// NewConnectionIdFrame represents a NEW_CONNECTION_ID frame (type 0x18, RFC +// 9000 §19.15): the sender offering an additional connection ID (and its +// associated stateless-reset token, see generate_stateless_reset_token in +// stateless_reset.v) for the peer to use. `retire_prior_to` is validated +// against `sequence_number` (§19.15's "MUST be less than or equal to" +// requirement) by both parse_frame and encode_new_connection_id_frame; +// every other requirement in that section (the zero-length-DCID +// prohibition, duplicate-sequence-number handling) needs connection state +// parse_frame doesn't have, so -- same division as HandshakeDoneFrame's +// role check above -- it is deferred to the caller. +pub struct NewConnectionIdFrame { +pub: + sequence_number u64 + retire_prior_to u64 + connection_id []u8 + stateless_reset_token []u8 // always exactly 16 bytes (RFC 9000 §19.15) +} + +// RetireConnectionIdFrame represents a RETIRE_CONNECTION_ID frame (type +// 0x19, RFC 9000 §19.16): the sender will no longer use the connection ID +// with this sequence number. Every normative check in §19.16 (sequence +// number not greater than any previously sent, not equal to the DCID of +// the packet carrying this frame, not sent by an endpoint using a +// zero-length CID) needs connection state parse_frame doesn't have, so -- +// same division as HandshakeDoneFrame's role check above -- it is deferred +// to the caller. +pub struct RetireConnectionIdFrame { +pub: + sequence_number u64 +} + pub type QuicFrame = AckFrame | ConnectionCloseFrame | CryptoFrame @@ -219,9 +250,11 @@ pub type QuicFrame = AckFrame | MaxDataFrame | MaxStreamDataFrame | MaxStreamsFrame + | NewConnectionIdFrame | PaddingFrame | PingFrame | ResetStreamFrame + | RetireConnectionIdFrame | StopSendingFrame | StreamDataBlockedFrame | StreamFrame @@ -243,6 +276,8 @@ const frame_type_data_blocked = u64(0x14) const frame_type_stream_data_blocked = u64(0x15) const frame_type_streams_blocked_bidi = u64(0x16) const frame_type_streams_blocked_uni = u64(0x17) +const frame_type_new_connection_id = u64(0x18) +const frame_type_retire_connection_id = u64(0x19) const frame_type_connection_close_transport = u64(0x1c) const frame_type_connection_close_application = u64(0x1d) const frame_type_handshake_done = u64(0x1e) @@ -314,6 +349,14 @@ pub fn parse_frame(buf []u8) !(QuicFrame, int) { return parse_streams_blocked_frame(buf, typ_len, typ == frame_type_streams_blocked_uni) } + if typ == frame_type_new_connection_id { + return parse_new_connection_id_frame(buf, typ_len) + } + + if typ == frame_type_retire_connection_id { + return parse_retire_connection_id_frame(buf, typ_len) + } + if typ == frame_type_connection_close_transport || typ == frame_type_connection_close_application { return parse_connection_close_frame(buf, typ_len, @@ -598,6 +641,60 @@ fn parse_streams_blocked_frame(buf []u8, start int, is_uni bool) !(QuicFrame, in }), start + n1 } +fn parse_new_connection_id_frame(buf []u8, start int) !(QuicFrame, int) { + mut offset := start + sequence_number, n1 := decode_varint(buf[offset..])! + offset += n1 + retire_prior_to, n2 := decode_varint(buf[offset..])! + offset += n2 + + // RFC 9000 §19.15: "The value in the Retire Prior To field MUST be + // less than or equal to the value in the Sequence Number field. + // Receiving a value in the Retire Prior To field that is greater than + // that in the Sequence Number field MUST be treated as a connection + // error of type FRAME_ENCODING_ERROR." + if retire_prior_to > sequence_number { + return error('quic: NEW_CONNECTION_ID frame: retire_prior_to ${retire_prior_to} exceeds sequence_number ${sequence_number} (RFC 9000 §19.15)') + } + + if offset >= buf.len { + return error('quic: NEW_CONNECTION_ID frame: missing Length field') + } + length := int(buf[offset]) + offset += 1 + // RFC 9000 §19.15: "Values less than 1 and greater than 20 are invalid + // and MUST be treated as a connection error of type + // FRAME_ENCODING_ERROR." 20 is quic_v1_max_cid_len (header.v). + if length < 1 || length > quic_v1_max_cid_len { + return error('quic: NEW_CONNECTION_ID frame: connection ID length ${length} is outside the valid 1-${quic_v1_max_cid_len} range (RFC 9000 §19.15)') + } + if offset + length > buf.len { + return error('quic: NEW_CONNECTION_ID frame: declares a ${length}-byte connection ID exceeding the remaining buffer') + } + connection_id := buf[offset..offset + length].clone() + offset += length + + if offset + 16 > buf.len { + return error('quic: NEW_CONNECTION_ID frame: missing 16-byte stateless reset token') + } + stateless_reset_token := buf[offset..offset + 16].clone() + offset += 16 + + return QuicFrame(NewConnectionIdFrame{ + sequence_number: sequence_number + retire_prior_to: retire_prior_to + connection_id: connection_id + stateless_reset_token: stateless_reset_token + }), offset +} + +fn parse_retire_connection_id_frame(buf []u8, start int) !(QuicFrame, int) { + sequence_number, n1 := decode_varint(buf[start..])! + return QuicFrame(RetireConnectionIdFrame{ + sequence_number: sequence_number + }), start + n1 +} + fn parse_connection_close_frame(buf []u8, start int, is_application_error bool) !(QuicFrame, int) { mut offset := start error_code, n1 := decode_varint(buf[offset..])! @@ -840,6 +937,38 @@ pub fn encode_streams_blocked_frame(direction StreamDirection, maximum_streams u return out } +// encode_new_connection_id_frame serializes a NEW_CONNECTION_ID frame. +// Mirrors parse_new_connection_id_frame's exact validation (RFC 9000 +// §19.15) so a caller cannot construct a frame this same module's own +// parser would then reject. +pub fn encode_new_connection_id_frame(sequence_number u64, retire_prior_to u64, connection_id []u8, stateless_reset_token []u8) ![]u8 { + if retire_prior_to > sequence_number { + return error('quic: encode_new_connection_id_frame: retire_prior_to ${retire_prior_to} exceeds sequence_number ${sequence_number} (RFC 9000 §19.15)') + } + if connection_id.len < 1 || connection_id.len > quic_v1_max_cid_len { + return error('quic: encode_new_connection_id_frame: connection ID length ${connection_id.len} is outside the valid 1-${quic_v1_max_cid_len} range (RFC 9000 §19.15)') + } + if stateless_reset_token.len != 16 { + return error('quic: encode_new_connection_id_frame: stateless reset token must be exactly 16 bytes, got ${stateless_reset_token.len}') + } + mut out := encode_varint(frame_type_new_connection_id)! + out << encode_varint(sequence_number)! + out << encode_varint(retire_prior_to)! + out << u8(connection_id.len) + out << connection_id + out << stateless_reset_token + return out +} + +// encode_retire_connection_id_frame serializes a RETIRE_CONNECTION_ID +// frame. See RetireConnectionIdFrame's doc comment for which §19.16 +// requirements are the caller's responsibility rather than this function's. +pub fn encode_retire_connection_id_frame(sequence_number u64) ![]u8 { + mut out := encode_varint(frame_type_retire_connection_id)! + out << encode_varint(sequence_number)! + return out +} + // encode_connection_close_frame serializes a CONNECTION_CLOSE frame. // `frame_type` is ignored (the Frame Type field is OMITTED from the wire // entirely, not encoded as a zero value) when `is_application_error` is diff --git a/vlib/net/quic/frame_test.v b/vlib/net/quic/frame_test.v index 7d0af63f275521..aa79e898706525 100644 --- a/vlib/net/quic/frame_test.v +++ b/vlib/net/quic/frame_test.v @@ -259,14 +259,14 @@ fn test_scaled_ack_delay_micros_saturates_at_exponent_ge_64() { } fn test_parse_frame_rejects_unimplemented_frame_type() { - // 0x18 (NEW_CONNECTION_ID) is a real, valid QUIC frame type this module - // simply doesn't implement yet -- connection ID rotation/migration is - // explicitly out of v1 scope (see stateless_reset.v's own doc comment) - // -- must be a clear "not implemented" error, not a wire-format error - // or a panic. (0x08 STREAM and 0x1e HANDSHAKE_DONE, both used here - // before their respective phases implemented them, would no longer - // demonstrate this.) - parse_frame([u8(0x18)]) or { + // 0x1a (PATH_CHALLENGE) is a real, valid QUIC frame type this module + // simply doesn't implement yet -- connection migration is explicitly a + // separate, deferrable follow-up (see PROGRESS.md's Phase 13 notes) -- + // must be a clear "not implemented" error, not a wire-format error or + // a panic. (0x08 STREAM, 0x18 NEW_CONNECTION_ID, and 0x1e + // HANDSHAKE_DONE, all used here before their respective phases + // implemented them, would no longer demonstrate this.) + parse_frame([u8(0x1a)]) or { assert err.msg().contains('not yet implemented') return } @@ -736,3 +736,127 @@ fn test_streams_blocked_frame_round_trip_both_directions() { } } } + +fn test_new_connection_id_frame_round_trip() { + cid := [u8(1), 2, 3, 4, 5, 6, 7, 8] + token := []u8{len: 16, init: 0xab} + encoded := encode_new_connection_id_frame(3, 1, cid, token)! + assert encoded[0] == 0x18 + frame, n := parse_frame(encoded)! + assert n == encoded.len + match frame { + NewConnectionIdFrame { + assert frame.sequence_number == 3 + assert frame.retire_prior_to == 1 + assert frame.connection_id == cid + assert frame.stateless_reset_token == token + } + else { + assert false, 'expected a NewConnectionIdFrame' + } + } +} + +fn test_new_connection_id_frame_round_trip_at_max_cid_length() { + cid := []u8{len: quic_v1_max_cid_len, init: 0x42} + token := []u8{len: 16, init: 0} + encoded := encode_new_connection_id_frame(0, 0, cid, token)! + frame, _ := parse_frame(encoded)! + match frame { + NewConnectionIdFrame { + assert frame.connection_id == cid + } + else { + assert false, 'expected a NewConnectionIdFrame' + } + } +} + +fn test_encode_new_connection_id_frame_rejects_retire_prior_to_above_sequence_number() { + encode_new_connection_id_frame(1, 2, [u8(1)], []u8{len: 16}) or { + assert err.msg().contains('retire_prior_to') + return + } + assert false, 'expected retire_prior_to > sequence_number to be rejected' +} + +fn test_parse_new_connection_id_frame_rejects_retire_prior_to_above_sequence_number() { + mut buf := encode_varint(frame_type_new_connection_id)! + buf << encode_varint(u64(1))! // sequence_number + buf << encode_varint(u64(2))! // retire_prior_to > sequence_number + buf << u8(1) + buf << [u8(0xff)] + buf << []u8{len: 16} + parse_frame(buf) or { + assert err.msg().contains('retire_prior_to') + return + } + assert false, 'expected retire_prior_to > sequence_number to be rejected' +} + +fn test_encode_new_connection_id_frame_rejects_zero_length_connection_id() { + encode_new_connection_id_frame(0, 0, []u8{}, []u8{len: 16}) or { + assert err.msg().contains('connection ID length') + return + } + assert false, 'expected a zero-length connection ID to be rejected' +} + +fn test_encode_new_connection_id_frame_rejects_connection_id_above_20_bytes() { + encode_new_connection_id_frame(0, 0, []u8{len: quic_v1_max_cid_len + 1}, []u8{len: 16}) or { + assert err.msg().contains('connection ID length') + return + } + assert false, 'expected a connection ID longer than 20 bytes to be rejected' +} + +fn test_parse_new_connection_id_frame_rejects_length_above_20_bytes() { + mut buf := encode_varint(frame_type_new_connection_id)! + buf << encode_varint(u64(0))! + buf << encode_varint(u64(0))! + buf << u8(21) // declares an invalid, over-20-byte connection ID length + buf << []u8{len: 21} + buf << []u8{len: 16} + parse_frame(buf) or { + assert err.msg().contains('connection ID length') + return + } + assert false, 'expected a declared length above 20 bytes to be rejected' +} + +fn test_encode_new_connection_id_frame_rejects_wrong_token_length() { + encode_new_connection_id_frame(0, 0, [u8(1)], []u8{len: 15}) or { + assert err.msg().contains('16 bytes') + return + } + assert false, 'expected a non-16-byte stateless reset token to be rejected' +} + +fn test_parse_new_connection_id_frame_rejects_truncated_token() { + mut buf := encode_varint(frame_type_new_connection_id)! + buf << encode_varint(u64(0))! + buf << encode_varint(u64(0))! + buf << u8(1) + buf << [u8(0xff)] + buf << []u8{len: 10} // short of the required 16-byte token + parse_frame(buf) or { + assert err.msg().contains('stateless reset token') + return + } + assert false, 'expected a truncated stateless reset token to be rejected' +} + +fn test_retire_connection_id_frame_round_trip() { + encoded := encode_retire_connection_id_frame(7)! + assert encoded[0] == 0x19 + frame, n := parse_frame(encoded)! + assert n == encoded.len + match frame { + RetireConnectionIdFrame { + assert frame.sequence_number == 7 + } + else { + assert false, 'expected a RetireConnectionIdFrame' + } + } +} diff --git a/vlib/net/quic/stateless_reset.v b/vlib/net/quic/stateless_reset.v index 87ab4965a33943..de8227519709f8 100644 --- a/vlib/net/quic/stateless_reset.v +++ b/vlib/net/quic/stateless_reset.v @@ -1,5 +1,7 @@ module quic +import crypto.hmac +import crypto.sha256 import crypto.subtle // RFC 9000 §10.3 — Stateless Reset. A stateless reset packet is @@ -58,3 +60,30 @@ pub fn (t &StatelessResetTracker) is_stateless_reset(connection_id []u8, datagra trailing := datagram[datagram.len - 16..] return subtle.constant_time_compare(token, trailing) == 1 } + +// generate_stateless_reset_token derives the stateless-reset token this +// endpoint should advertise for `connection_id`, using RFC 9000 §10.3.2's +// recommended construction: "A single static key can be used across all +// connections to the same endpoint by generating the proof using a +// pseudorandom function that takes a static key and the connection ID... +// An endpoint could use HMAC... (for example, HMAC(static_key, +// connection_id))... truncated to 16 bytes." Deriving the token this way +// -- rather than randomly generating and storing one per connection -- is +// the entire point of a stateless reset (§10.3: it must remain computable +// after this endpoint has lost all per-connection state); `static_key` is +// a server-instance-local secret generated once (e.g. via +// crypto.rand.bytes) and never sent on the wire. HMAC-SHA-256 is an +// arbitrary but fixed choice among the RFC's own listed examples (HMAC or +// HKDF, with any hash) -- nothing about the wire format depends on which +// PRF produced the token, since only this endpoint itself ever +// recomputes it. +pub fn generate_stateless_reset_token(static_key []u8, connection_id []u8) ![]u8 { + if connection_id.len == 0 { + // RFC 9000 §10.3.2: this construction "cannot provide a + // zero-length connection ID" -- there is no connection-ID + // material left to key the derivation on. + return error('quic: cannot generate a stateless reset token for a zero-length connection ID (RFC 9000 §10.3.2)') + } + mac := hmac.new(static_key, connection_id, sha256.sum256, sha256.block_size) + return mac[..16].clone() +} diff --git a/vlib/net/quic/stateless_reset_test.v b/vlib/net/quic/stateless_reset_test.v index 087bd321b23cd0..91bac655ab3e0a 100644 --- a/vlib/net/quic/stateless_reset_test.v +++ b/vlib/net/quic/stateless_reset_test.v @@ -53,3 +53,55 @@ fn test_is_stateless_reset_false_for_datagram_shorter_than_a_token() { short := []u8{len: 10, init: 0x42} assert !t.is_stateless_reset(cid, short) } + +fn test_generate_stateless_reset_token_is_16_bytes_and_deterministic() { + static_key := []u8{len: 16, init: 0x11} + cid := [u8(1), 2, 3, 4] + token1 := generate_stateless_reset_token(static_key, cid)! + assert token1.len == 16 + token2 := generate_stateless_reset_token(static_key, cid)! + assert token1 == token2 +} + +fn test_generate_stateless_reset_token_differs_per_connection_id() { + static_key := []u8{len: 16, init: 0x11} + token_a := generate_stateless_reset_token(static_key, [u8(1), 2, 3, 4])! + token_b := generate_stateless_reset_token(static_key, [u8(1), 2, 3, 5])! + assert token_a != token_b +} + +fn test_generate_stateless_reset_token_differs_per_static_key() { + cid := [u8(1), 2, 3, 4] + token_a := generate_stateless_reset_token([u8(1), 1, 1, 1], cid)! + token_b := generate_stateless_reset_token([u8(2), 2, 2, 2], cid)! + assert token_a != token_b +} + +fn test_generate_stateless_reset_token_rejects_zero_length_connection_id() { + static_key := []u8{len: 16, init: 0x11} + generate_stateless_reset_token(static_key, []u8{}) or { + assert err.msg().contains('zero-length') + return + } + assert false, 'expected a zero-length connection ID to be rejected' +} + +// test_generated_token_is_recognized_by_the_matching_tracker cross-checks +// generate_stateless_reset_token against StatelessResetTracker's own, +// independently-written is_stateless_reset -- the same "does the exact code +// that will consume this in production accept it" pattern used elsewhere +// in this module (e.g. retry_test.v's client-verification round trip). +fn test_generated_token_is_recognized_by_the_matching_tracker() { + static_key := []u8{len: 16, init: 0x11} + cid := [u8(0xaa), 0xbb, 0xcc, 0xdd] + token := generate_stateless_reset_token(static_key, cid)! + + mut t := new_stateless_reset_tracker() + t.record_token(cid, token)! + + mut datagram := []u8{len: 40, init: 0x99} + for i in 0 .. 16 { + datagram[datagram.len - 16 + i] = token[i] + } + assert t.is_stateless_reset(cid, datagram) +} From 3a97c5962f3d8798975b09d1fa2874bbe223d7cf Mon Sep 17 00:00:00 2001 From: Richard Wheeler Date: Mon, 24 Aug 2026 23:50:37 -0400 Subject: [PATCH 09/10] net.quic: v3: 13d-1 -- wire a server-role accept() handshake path into QuicConn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds server-role support to QuicConn (previously client-only) plus a new accept() constructor mirroring dial(): role-aware directional key selection, role-branched handshake dispatch (dispatch_server_handshake_message), role-asymmetric handshake-confirmation semantics (RFC 9001 §4.1.2), and server-side HANDSHAKE_DONE sending -- all reusing the existing client-role packet-building/CRYPTO-reassembly/drain machinery. Two RFC-conformance bugs found and fixed via an adversarial multi-agent review before commit, both independently re-verified: - RFC 9000 §7.2: a client's Initial-space packets address their DCID field to its own original_dcid until it has processed a reply, so the DCID match in process_initial_or_handshake now accepts a server-role connection's bootstrap ClientHello even though the server's real scid didn't exist yet when the client sent it. - RFC 9001 §4.9.1: Initial-key discard is send-triggered for a client but receive-triggered for a server -- applying the client's trigger to both roles made accept()'s single poll() call discard the server's Initial keys before the client had sent anything back, silently dropping any ClientHello retransmission and blocking the server's own PTO retransmission of its first flight. Split into a client-only send trigger (build_handshake_packet) and a new server receive trigger (process_initial_or_handshake, right after a Handshake-space packet from the client decrypts). Also fixes a missing RFC 9000 §14.1 anti-amplification check: accept() now rejects any datagram under the 1200-byte floor before doing any work, closing a reflection-amplification gap. accept() deliberately does not decide Retry-vs-direct-accept policy (deferred to 13d-2's UDP listener, which has the cross-connection-attempt state that decision needs) and does not fragment a large certificate chain's Handshake-space CRYPTO flight across multiple packets (documented scope limits, not blockers for this repo's own small test certificate). Tests: new accept_test.v drives a real dial()/accept() pair through a full handshake (including real Certificate/CertificateVerify chain verification against a freshly generated EC cert -- 13a's own PROGRESS.md noted gap) and bidirectional stream exchange, plus regression assertions for both RFC-conformance fixes above and a rejection test for the anti-amplification floor. Full suite: 58/58 passing. Co-Authored-By: Claude Sonnet 5 --- vlib/net/quic/accept.v | 189 ++++++++++++++++++++ vlib/net/quic/accept_test.v | 266 +++++++++++++++++++++++++++ vlib/net/quic/conn.v | 337 ++++++++++++++++++++++++++++++----- vlib/net/quic/conn_test.v | 82 ++++----- vlib/net/quic/frame.v | 10 ++ vlib/net/quic/h3_conn_test.v | 46 ++--- vlib/net/quic/stream.v | 7 +- 7 files changed, 828 insertions(+), 109 deletions(-) create mode 100644 vlib/net/quic/accept.v create mode 100644 vlib/net/quic/accept_test.v diff --git a/vlib/net/quic/accept.v b/vlib/net/quic/accept.v new file mode 100644 index 00000000000000..862d6f66507a0b --- /dev/null +++ b/vlib/net/quic/accept.v @@ -0,0 +1,189 @@ +module quic + +import crypto.ecdsa +import crypto.rand + +// accept.v: the server-role counterpart to dial() (conn.v). Split into its +// own file rather than added to conn.v directly for one purely mechanical +// reason: this file needs crypto.rand (a real CSPRNG) for the server's own +// connection ID and ServerHello random, and conn.v already imports the +// plain `rand` module (non-cryptographic, used by dial() -- a known, +// separately-tracked gap, not repeated here) under the same bare name -- +// V requires disambiguating two same-named imports, and a fresh file needing +// only the secure one is simpler than an alias in an already-large file. +// +// Everything else server-role lives in conn.v itself (the struct fields, +// dispatch_handshake_message's role branch, the own/peer_*_keys and +// is_handshake_confirmed role-aware helpers, drain_outgoing's HANDSHAKE_DONE +// send) -- accept() is deliberately thin: it does just enough to construct +// a QuicConn with the right identity (original_dcid/dcid/scid/peer_scid) +// and Initial-space keys to make the FIRST incoming datagram decryptable, +// then hands that exact datagram to poll() -- the same, already-tested +// entry point every SUBSEQUENT datagram goes through. Everything else +// (decrypting, reassembling the ClientHello's CRYPTO frame(s), running +// Tls13ServerHandshake.respond_to_client_hello, deriving Handshake/ +// Application keys, queuing the response flight, and draining it into +// actual outgoing datagrams) happens exactly where it already happens for +// every OTHER handshake message, via dispatch_handshake_message's own +// server branch -- there is no separate, parallel decrypt/dispatch path +// here to keep in sync with process_initial_or_handshake's. + +// AcceptParams is everything accept() needs beyond what it decides for +// itself (this server's own connection ID, ServerHello random) -- the +// server-role mirror of DialParams. +pub struct AcceptParams { +pub: + // This server's own OFFERED transport parameters. accept() overrides + // initial_source_connection_id and original_destination_connection_id + // with values it derives itself (RFC 9000 §7.3) regardless of what the + // caller set there, the same override dial() already applies to its own + // initial_source_connection_id. + transport_parameters QuicTransportParameters + // Application protocols this server supports, most preferred first + // (RFC 7301 §3.2 -- the server picks, in ITS OWN preference order, from + // among what the client also offered). Named to match DialParams' + // alpn_protocols field, even though ServerHandshakeParams' own + // equivalent field is named supported_alpn_protocols. + alpn_protocols []string + // This server's own certificate chain, leaf-first, and the long-lived + // private key matching the leaf's public key -- see + // ServerHandshakeParams' own doc comments (tls13_server_handshake.v) + // for what accept() does NOT own here (loading these from a PEM file is + // a future caller's job, same scope note that struct already states). + certificate_chain []CertificateEntry + signing_key ecdsa.PrivateKey +} + +// accept constructs a new server-role QuicConn from `raw_datagram` -- the +// first UDP datagram of a new connection attempt, expected to contain +// exactly one Initial packet carrying the client's ClientHello (this +// codebase's own dial() never sends anything else in its first flight; +// see the loop below for what happens if that assumption doesn't hold). +// Returns the new connection AND the PollResult from processing that same +// datagram through it -- typically the response flight (ServerHello under +// Initial protection, EncryptedExtensions..this server's own Finished +// under Handshake protection) queued in PollResult.outgoing, ready for the +// caller to actually send. +// +// Deliberately does NOT decide whether to accept directly or send a Retry +// first (RFC 9000 §8.1's address-validation policy) -- that decision needs +// state (has this source address been seen before? is the anti- +// amplification budget already exhausted?) that only a caller tracking +// MANY connection attempts across MANY source addresses can have; a single +// accept() call has no such context. 13b's encode_retry_packet/ +// generate_retry_token/AntiAmplificationLimiter already exist for a caller +// to make and act on that decision BEFORE ever calling accept() -- wiring +// them together is 13d-2's job (the UDP listener), not this constructor's. +// +// KNOWN SCOPE LIMIT, not fixed here: the server's Handshake-space CRYPTO +// flight (EncryptedExtensions+Certificate+CertificateVerify+Finished, +// dispatch_handshake_message's server bootstrap branch queues it as one +// blob in pending_handshake_crypto) is flushed by drain_outgoing's +// pre-existing logic as a SINGLE CRYPTO frame in a SINGLE Handshake packet +// -- correct for this repo's own small test certificate, but not +// fragmented across multiple packets if a real-world certificate chain +// doesn't fit one packet's payload. dial()'s own ClientHello flush has the +// identical shape but was never previously exercised with anything large +// enough to expose it, since a ClientHello (no certificate) is always +// small. Splitting a CRYPTO stream write across multiple packets is a +// real, separate piece of work, not attempted here. +pub fn accept(raw_datagram []u8, params AcceptParams, now u64) !(&QuicConn, PollResult) { + // RFC 9000 §14.1: "A server MUST discard an Initial packet that is + // carried in a UDP datagram with a payload that is smaller than the + // smallest allowed maximum datagram size of 1200 bytes" -- an + // anti-amplification measure: without this, a spoofed-source, undersized + // trigger datagram gets a full ServerHello+EncryptedExtensions+ + // Certificate+CertificateVerify+Finished response flight, routinely + // several times larger than the trigger, aimed at whatever address the + // attacker claimed. coalesce.v's split_coalesced_datagram deliberately + // does NOT enforce this itself (see its own doc comment) -- it's a + // stateless, role-agnostic splitter also used for datagrams this + // endpoint SENDS, where a legitimate reply smaller than 1200 bytes + // (e.g. ACK-only) is allowed; that comment explicitly defers the real, + // role-aware check to "a later phase with that visibility," which is + // this one: the single call site that always knows a datagram reaching + // it is being RECEIVED, by a SERVER, before address validation. Found + // by 13d-1's adversarial review (v-quality lens). + if raw_datagram.len < min_initial_datagram_size { + return error('quic: accept: datagram (${raw_datagram.len} bytes) is smaller than the RFC 9000 §14.1 anti-amplification floor of ${min_initial_datagram_size} bytes') + } + packets := split_coalesced_datagram(raw_datagram)! + mut initial_header := ?QuicLongHeader(none) + for p in packets { + if p.form != .long { + continue + } + h, _ := parse_long_header(p.bytes) or { continue } + if h.typ == .initial { + initial_header = h + break + } + } + header := initial_header or { + return error('quic: accept: raw_datagram contains no Initial packet') + } + + // header.dcid is the client's freshly-chosen original_dcid -- RFC 9001 + // §5.2 derives Initial secrets from it, identically on both sides (the + // same derive_initial_secrets dial() itself calls for the client half + // of this exact derivation). + initial_secrets := derive_initial_secrets(header.dcid)! + initial_keys_client := derive_packet_protection_keys(initial_secrets.client)! + initial_keys_server := derive_packet_protection_keys(initial_secrets.server)! + + scid := rand.bytes(local_cid_len) or { + return error('quic: accept: failed to generate this server\'s own connection ID: ${err.msg()}') + } + server_hello_random := rand.bytes(32) or { + return error('quic: accept: failed to generate ServerHello random: ${err.msg()}') + } + + mut own_params := params.transport_parameters + own_params.initial_source_connection_id = scid.clone() + // RFC 9000 §7.3: a server MUST send original_destination_connection_id, + // echoing the DCID the client's own Initial packet used -- the value + // ServerHandshakeParams' own doc comment names as this caller's + // responsibility to set (build_encrypted_extensions itself validates + // it's present, but does not know what value to fill in). + own_params.original_destination_connection_id = header.dcid.clone() + + own_max_idle_timeout_ms := own_params.max_idle_timeout or { u64(0) } + + mut c := &QuicConn{ + role: .server + state: .handshaking + original_dcid: header.dcid.clone() + dcid: header.scid.clone() + scid: scid + peer_scid: header.scid.clone() + token: []u8{} + server_handshake: none + server_accept_params: ServerHandshakeParams{ + transport_parameters: own_params + supported_alpn_protocols: params.alpn_protocols + certificate_chain: params.certificate_chain + signing_key: params.signing_key + server_hello_random: server_hello_random + } + handshake_completion: new_handshake_completion_state() + pn_spaces: new_packet_number_spaces() + initial_keys_client: initial_keys_client + initial_keys_server: initial_keys_server + initial_crypto: new_crypto_stream_reassembler() + handshake_crypto: new_crypto_stream_reassembler() + loss_detection: new_quic_loss_detection_timer() + congestion_control: new_newreno_congestion_control() + own_max_idle_timeout_ms: own_max_idle_timeout_ms + stateless_reset: new_stateless_reset_tracker() + connection_start: now + own_transport_parameters: own_params + streams: new_quic_stream_set(.server) + conn_send_window: new_flow_control_window(0) + conn_recv_window: new_receive_window(own_params.initial_max_data or { u64(0) }) + local_max_streams_bidi: own_params.initial_max_streams_bidi or { u64(0) } + local_max_streams_uni: own_params.initial_max_streams_uni or { u64(0) } + } + + result := c.poll(raw_datagram, now)! + return c, result +} diff --git a/vlib/net/quic/accept_test.v b/vlib/net/quic/accept_test.v new file mode 100644 index 00000000000000..cc067e34ffedf7 --- /dev/null +++ b/vlib/net/quic/accept_test.v @@ -0,0 +1,266 @@ +// vtest build: present_openssl? +module quic + +import crypto.ecdsa +import encoding.base64 + +// accept_test_cert_pem is a REAL, freshly-generated (openssl ecparam + +// openssl req -x509, this session), self-signed P-256 certificate for +// CN=localhost / SAN=DNS:localhost, WITH a critical CA:TRUE basic +// constraint -- unlike chain_test_cert_pem/conn_test_cert_pem (this same +// module's other test certs, both deliberately lacking CA:TRUE to test the +// REJECTION path), this one is built specifically so a real client can use +// it as its OWN trust anchor and have verify_server_certificate_chain +// actually SUCCEED. This is what lets the test below drive the ENTIRE real +// dial()/accept() flow -- including Certificate/CertificateVerify chain +// verification -- rather than bypassing it the way conn_test.v's own +// white-box tests do (injecting a synthetic VerifiedCertificateChain +// directly), closing the one gap 13a's own PROGRESS.md notes left open +// ("Certificate/CertificateVerify chain verification is not exercised +// end-to-end (no EC certificate fixture in this repo)"). +const accept_test_cert_pem = '-----BEGIN CERTIFICATE-----\nMIIBpTCCAUugAwIBAgIUetSYX9TDsFKNHR+Zy05VdXcp+1cwCgYIKoZIzj0EAwIw\nFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDgyNDIyNTkyMloYDzIxMjYwNzMx\nMjI1OTIyWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjO\nPQMBBwNCAAS6mM0J/l1Y65oZMLxYPHvySK8RJbkuECLMXmF3+yeIdqH9cCtKqumw\nDpY+Kz9IjfoVcqdyH5DPE5i7aquc1pwno3kwdzAdBgNVHQ4EFgQU/r32o4XKdpEk\nhx2iVbRtvYuVsXswHwYDVR0jBBgwFoAU/r32o4XKdpEkhx2iVbRtvYuVsXswDwYD\nVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAoQwFAYDVR0RBA0wC4IJbG9jYWxo\nb3N0MAoGCCqGSM49BAMCA0gAMEUCIGVFw0ddsmDoAyFGVy/K+MlbKnboWRZ0ibkM\n1lLBebL2AiEAvXkEh3aKEztQlrTJwIfjjO7l488gaFTZi63ZuWDIkWY=\n-----END CERTIFICATE-----\n' + +// accept_test_key_seed is the raw 32-byte private scalar (big-endian, SEC1 +// convention -- BN_bin2bn's own expected format, see +// crypto.ecdsa.evpkey_from_seed) of accept_test_cert_pem's matching P-256 +// private key, extracted via `openssl ec -text` from the same key that +// signed the certificate above. Feeding this into +// ecdsa.new_key_from_seed reconstructs the EXACT key pair whose public +// half is embedded in the certificate -- required for +// encode_certificate_verify's signature to actually validate against it. +const accept_test_key_seed = [ + u8(0x4f), + 0xd6, + 0x31, + 0x08, + 0x03, + 0xcb, + 0xd1, + 0x42, + 0xd5, + 0xc5, + 0xac, + 0xe4, + 0xd1, + 0xb1, + 0xca, + 0x06, + 0xfb, + 0xde, + 0xc0, + 0x5a, + 0xc0, + 0x6e, + 0xb9, + 0x58, + 0x60, + 0x01, + 0x3f, + 0x02, + 0x79, + 0x7c, + 0xb3, + 0x15, +] + +fn accept_test_pem_to_der(pem string) []u8 { + body := + pem.replace('-----BEGIN CERTIFICATE-----', '').replace('-----END CERTIFICATE-----', '').replace('\n', '').trim_space() + return base64.decode(body) +} + +fn accept_test_transport_parameters() QuicTransportParameters { + return QuicTransportParameters{ + max_idle_timeout: 30000 + initial_max_data: 1 << 20 + initial_max_stream_data_bidi_local: 1 << 16 + initial_max_stream_data_bidi_remote: 1 << 16 + initial_max_streams_bidi: 4 + initial_max_streams_uni: 4 + } +} + +// test_dial_and_accept_full_handshake_and_stream_exchange is 13d-1's own +// "real client-vs-server integration test," one layer up from +// tls13_server_handshake_test.v's identically-named TLS-only version: two +// genuine, independently-constructed QuicConn objects (one via dial(), one +// via accept()) drive each other's real poll() through a full RFC 9000 +// handshake AND a real application-data stream exchange, with actual UDP +// datagram bytes as the only channel between them (this test never reaches +// into either connection's internals to shortcut anything) -- proving the +// role-aware key selection (own/peer_*_keys), the server's handshake +// bootstrap (dispatch_handshake_message's server branch), and the server's +// HANDSHAKE_DONE send/client's receipt all genuinely agree end to end, not +// merely that each side is internally consistent. +fn test_dial_and_accept_full_handshake_and_stream_exchange() { + mut signing_key := ecdsa.new_key_from_seed(accept_test_key_seed, fixed_size: true)! + defer { + signing_key.free() + } + + dial_params := DialParams{ + server_name: 'localhost' + ca_bundle_pem: accept_test_cert_pem + alpn_protocols: ['h3'] + transport_parameters: accept_test_transport_parameters() + } + mut client, client_dg := dial(dial_params, 0)! + mut client_hs := client.client_handshake() + defer { + client_hs.free() + } + + accept_params := AcceptParams{ + transport_parameters: accept_test_transport_parameters() + alpn_protocols: ['h3'] + certificate_chain: [ + CertificateEntry{ + cert_data: accept_test_pem_to_der(accept_test_cert_pem) + }, + ] + signing_key: signing_key + } + mut server, mut server_result := accept(client_dg.bytes, accept_params, 0)! + defer { + if mut sh := server.server_handshake { + sh.free() + } + } + + // Regression check for a bug 13d-1's own adversarial review found: this + // single accept() call both processes the ClientHello AND flushes the + // server's own Handshake-space response flight (ServerHello is + // Initial-space and unaffected, but EncryptedExtensions/Certificate/ + // CertificateVerify/Finished go out via build_handshake_packet in the + // very same call) -- a naive send-based Initial-key-discard trigger + // (RFC 9001 §4.9.1 is send-based for the CLIENT, receive-based for the + // SERVER) would therefore discard the server's Initial keys here, + // before the client has sent anything back. That silently drops any + // ClientHello retransmission (ordinary client-side PTO/loss handling) + // and leaves the server unable to PTO-retransmit its own lost first + // flight either, stalling the handshake to idle timeout -- a failure + // mode this test's own lossless, single-round-trip datagram exchange + // below can never otherwise expose. + assert !server.initial_keys_discarded + + // Drive the two connections against each other, feeding each side's + // outgoing datagrams into the other's poll(), until neither produces + // anything more to send. Bounded (not a `for true`) so a genuine + // protocol disagreement between the two independently-written roles + // fails this test with an assertion, not a hang. + mut client_outgoing := []QuicDatagram{} + mut server_outgoing := server_result.outgoing.clone() + mut now := u64(0) + mut rounds := 0 + for (client_outgoing.len > 0 || server_outgoing.len > 0) && rounds < 20 { + rounds += 1 + now += 10 + mut next_client_outgoing := []QuicDatagram{} + mut next_server_outgoing := []QuicDatagram{} + for dg in server_outgoing { + r := client.poll(dg.bytes, now)! + next_client_outgoing << r.outgoing + } + for dg in client_outgoing { + r := server.poll(dg.bytes, now)! + next_server_outgoing << r.outgoing + } + client_outgoing = next_client_outgoing.clone() + server_outgoing = next_server_outgoing.clone() + } + assert rounds < 20, 'handshake did not converge within 20 rounds' + + assert client.state() == .established + assert server.state() == .established + // The receive-side mirror of the check above: once the round trip has + // actually delivered the client's Handshake-space Finished to the + // server, the new receive-based trigger in process_initial_or_handshake + // must have fired. + assert server.initial_keys_discarded + assert client.negotiated_alpn()? == 'h3' + assert server.negotiated_alpn()? == 'h3' + + // Application-data round trip: client opens a bidi stream and writes, + // server reads it and replies on the same stream, client reads the + // reply -- proving 1-RTT keys, CRYPTO-independent stream framing, and + // flow control all work correctly on a server-role connection, not + // just the handshake itself. + stream_id := client.open_stream(true)! + client.write_stream(stream_id, 'hello from client'.bytes(), false)! + + now += 10 + mut client_to_server := client.poll(none, now)! + assert client_to_server.outgoing.len > 0 + + mut server_read := []u8{} + server_outgoing = []QuicDatagram{} + for dg in client_to_server.outgoing { + r := server.poll(dg.bytes, now)! + server_outgoing << r.outgoing + if data := server.read_stream(stream_id) { + server_read << data + } + } + assert server_read.bytestr() == 'hello from client' + + server.write_stream(stream_id, 'hello from server'.bytes(), true)! + now += 10 + server_result = server.poll(none, now)! + server_outgoing << server_result.outgoing + assert server_outgoing.len > 0 + + mut client_read := []u8{} + for dg in server_outgoing { + client.poll(dg.bytes, now)! + if data := client.read_stream(stream_id) { + client_read << data + } + } + assert client_read.bytestr() == 'hello from server' +} + +// test_accept_rejects_undersized_initial_datagram is a regression test for +// RFC 9000 §14.1's anti-amplification floor, a gap 13d-1's own adversarial +// review found: accept() never checked the incoming datagram's length +// before deriving keys and queuing a full ServerHello+EncryptedExtensions+ +// Certificate+CertificateVerify+Finished response flight -- letting an +// attacker trigger a large response from a small, address-spoofed +// datagram. Uses a real dial()-produced ClientHello datagram (so the +// Initial packet itself is well-formed and would otherwise be accepted), +// truncated below the 1200-byte floor, to isolate the length check from +// every other rejection reason accept() might have. +fn test_accept_rejects_undersized_initial_datagram() { + dial_params := DialParams{ + server_name: 'localhost' + ca_bundle_pem: accept_test_cert_pem + alpn_protocols: ['h3'] + transport_parameters: accept_test_transport_parameters() + } + mut client, client_dg := dial(dial_params, 0)! + mut client_hs := client.client_handshake() + defer { + client_hs.free() + } + assert client_dg.bytes.len >= min_initial_datagram_size + + mut signing_key := ecdsa.new_key_from_seed(accept_test_key_seed, fixed_size: true)! + defer { + signing_key.free() + } + accept_params := AcceptParams{ + transport_parameters: accept_test_transport_parameters() + alpn_protocols: ['h3'] + certificate_chain: [ + CertificateEntry{ + cert_data: accept_test_pem_to_der(accept_test_cert_pem) + }, + ] + signing_key: signing_key + } + truncated := client_dg.bytes[..min_initial_datagram_size - 1].clone() + accept(truncated, accept_params, 0) or { + assert err.msg().contains('smaller than') + return + } + assert false, 'accept() must reject a datagram under the 1200-byte anti-amplification floor' +} diff --git a/vlib/net/quic/conn.v b/vlib/net/quic/conn.v index bd3c912eb7fe77..81c8cc9aaf12ce 100644 --- a/vlib/net/quic/conn.v +++ b/vlib/net/quic/conn.v @@ -29,9 +29,11 @@ import rand // exactly one local `scid` and one tracked peer `dcid`, no active CID set, // no NEW_CONNECTION_ID/RETIRE_CONNECTION_ID. -// local_cid_len is this client's own chosen connection-ID length -- within +// local_cid_len is this endpoint's own chosen connection-ID length -- within // RFC 9000 §17.2's 20-byte v1 limit, matching common real-world practice -// (quiche and others typically use 8). +// (quiche and others typically use 8). Shared by both roles: dial() (this +// client's own scid) and accept() (this server's own scid, accept.v) -- +// nothing requires the two roles to pick different lengths. const local_cid_len = 8 // aead_tag_len is AES-128-GCM's fixed authentication tag length (RFC 9001 @@ -120,9 +122,27 @@ mut: processed_first_server_packet bool retry_scid ?[]u8 - handshake &Tls13ClientHandshake - handshake_completion &HandshakeCompletionState - pn_spaces &QuicPacketNumberSpaces + // handshake is populated for role == .client (by dial()), server_handshake + // for role == .server -- see dispatch_handshake_message's role branch + // and accept()'s own doc comment for why these can't share one field the + // way most other role-parameterized state on this struct does (their + // state-machine shapes are fundamentally different, not just their + // concrete type -- see Tls13ServerHandshake's own doc comment). + // server_accept_params/server_negotiated_alpn are likewise server-only: + // server_accept_params is consumed exactly once, by + // dispatch_handshake_message's "no server_handshake yet" branch, to + // call Tls13ServerHandshake.respond_to_client_hello with the + // certificate/key/ALPN material accept() was given; server_negotiated_alpn + // is set at that same moment from the returned flight, mirroring + // Tls13ClientHandshake's own negotiated_alpn field for the other role + // (see negotiated_alpn()'s own doc comment for why this isn't instead a + // method on Tls13ServerHandshake itself). + handshake ?&Tls13ClientHandshake + server_handshake ?&Tls13ServerHandshake + server_accept_params ?ServerHandshakeParams + server_negotiated_alpn string + handshake_completion &HandshakeCompletionState + pn_spaces &QuicPacketNumberSpaces initial_keys_client QuicPacketProtectionKeys initial_keys_server QuicPacketProtectionKeys @@ -224,6 +244,15 @@ mut: pending_close ?PendingClose sent_close_payload ?[]u8 closing_deadline ?u64 + + // handshake_done_sent guards drain_outgoing's server-role HANDSHAKE_DONE + // send (RFC 9001 §4.1.2: sent exactly once, "as soon as the handshake + // is complete") against re-sending on every later poll() call once the + // handshake stays complete -- the same one-shot-flag shape as + // streams_blocked_sent_bidi/uni above, RFC 9000 §19.20's decode-side + // mirror of the send-once requirement enforced there by construction + // (a server sends it, at most, the one time this flag transitions). + handshake_done_sent bool } // PendingStreamWrite accumulates data queued via write_stream() awaiting @@ -253,8 +282,8 @@ pub fn (c &QuicConn) state() ConnectionState { return c.state } -// role reports which side of the connection this endpoint is. v1 is always -// .client (see stream.v's QuicRole doc comment). +// role reports which side of the connection this endpoint is -- .client for +// a dial()ed connection, .server for an accept()ed one (Phase 13d). pub fn (c &QuicConn) role() QuicRole { return c.role } @@ -263,9 +292,103 @@ pub fn (c &QuicConn) role() QuicRole { // before EncryptedExtensions has been processed (i.e. before // handshake_confirmed can even fire -- ALPN is settled well before the // handshake completes, so a caller checking this after handshake_confirmed -// always gets a value, never none). +// always gets a value, never none). Symmetric across roles: a server knows +// its own selection the moment it makes it (respond_to_client_hello), a +// client only once it decodes the server's EncryptedExtensions. pub fn (c &QuicConn) negotiated_alpn() ?string { - return c.handshake.negotiated_alpn() + if h := c.handshake { + return h.negotiated_alpn() + } + if c.server_negotiated_alpn == '' { + return none + } + return c.server_negotiated_alpn +} + +// peer_transport_parameters returns whichever handshake object is active +// for this connection's role's own view of the PEER's transport +// parameters -- Tls13ClientHandshake.peer_transport_parameters (populated +// once EncryptedExtensions is processed) for a client, or +// Tls13ServerHandshake.peer_transport_parameters (populated immediately by +// respond_to_client_hello, straight from the ClientHello) for a server. +// Returns a zero-value QuicTransportParameters{} in the brief window on a +// server connection between accept() constructing it and the ClientHello's +// CRYPTO frame actually being dispatched (dispatch_handshake_message's "no +// server_handshake yet" branch) -- every field this zero value could be +// read through (ensure_stream_windows, ack_delay_exponent, max_ack_delay, +// max_idle_timeout) already treats "peer hasn't told us yet" as "assume +// the conservative default," so this is a safe transient default, not a +// correctness gap; that window closes on the very first poll() call, which +// is exactly what dispatches the ClientHello that was already decrypted at +// accept() time. +fn (c &QuicConn) peer_transport_parameters() QuicTransportParameters { + if h := c.handshake { + return h.peer_transport_parameters + } + if h := c.server_handshake { + return h.peer_transport_parameters + } + return QuicTransportParameters{} +} + +// own_initial_keys/peer_initial_keys and own_handshake_keys/peer_handshake_keys +// resolve which of the two directional key sets (RFC 9001 §5.2: "client" +// and "server" Initial/Handshake secrets are derived from the same shared +// secret but are DIFFERENT keys) this connection's role uses for encrypting +// its OWN outgoing packets vs. decrypting packets received FROM the peer. +// Centralizing the swap here, rather than branching on c.role at each of +// the several encrypt/decrypt call sites individually, is what makes +// build_initial_packet/build_handshake_packet/process_initial_or_handshake +// (all pre-existing, client-only code before Phase 13d) correct for a +// server-role connection with a one-line change at each call site instead +// of a role branch duplicated at every one of them. +fn (c &QuicConn) own_initial_keys() QuicPacketProtectionKeys { + return if c.role == .client { c.initial_keys_client } else { c.initial_keys_server } +} + +fn (c &QuicConn) peer_initial_keys() QuicPacketProtectionKeys { + return if c.role == .client { c.initial_keys_server } else { c.initial_keys_client } +} + +fn (c &QuicConn) own_handshake_keys() ?QuicPacketProtectionKeys { + return if c.role == .client { c.handshake_keys_client } else { c.handshake_keys_server } +} + +fn (c &QuicConn) peer_handshake_keys() ?QuicPacketProtectionKeys { + return if c.role == .client { c.handshake_keys_server } else { c.handshake_keys_client } +} + +// is_handshake_confirmed reports RFC 9001 §4.1.2's "handshake confirmed" +// checkpoint FOR THIS CONNECTION'S ROLE -- the two roles reach it +// differently, per that section's own text: "the TLS handshake is +// considered confirmed at the SERVER when the handshake completes... At +// the CLIENT, the handshake is considered confirmed when a HANDSHAKE_DONE +// frame is received." HandshakeCompletionState.is_confirmed() itself only +// implements the client's half (see that struct's own, deliberately +// client-perspective doc comment, unchanged by Phase 13d); a server's +// confirmation is just is_complete() -- own Finished sent AND peer's +// Finished verified, RFC 9001 §4.1.1 -- with no HANDSHAKE_DONE-received +// dependency, since the server is the one that SENDS it (see +// drain_outgoing's server HANDSHAKE_DONE block). +fn (c &QuicConn) is_handshake_confirmed() bool { + if c.role == .client { + return c.handshake_completion.is_confirmed() + } + return c.handshake_completion.is_complete() +} + +// client_handshake unwraps c.handshake, panicking if it is none. Only +// meaningful on a role == .client connection (dial() always populates it; +// accept() never does -- see the `handshake` field's own doc comment). +// Exists so this package's own white-box tests (conn_test.v/h3_conn_test.v, +// both same-module, reaching into Tls13ClientHandshake's internal fields +// directly to drive/inspect handshake state step by step) don't need an +// `or {}` unwrap repeated at every one of their many call sites -- every +// production code path that needs role-safe access already goes through +// peer_transport_parameters()/negotiated_alpn()/dispatch_handshake_message's +// own role branch instead, none of which call this. +fn (c &QuicConn) client_handshake() &Tls13ClientHandshake { + return c.handshake or { panic('quic: client_handshake() called on a non-client connection') } } // ------------------------------------------------------------------------- @@ -283,7 +406,7 @@ pub fn (c &QuicConn) negotiated_alpn() ?string { // has_send/has_recv distinction through every call site). fn (mut c QuicConn) ensure_stream_windows(id StreamId) { if id.value !in c.stream_send_windows { - limit := initial_send_limit_for_stream(id, c.role, c.handshake.peer_transport_parameters) + limit := initial_send_limit_for_stream(id, c.role, c.peer_transport_parameters()) mut w := new_flow_control_window(limit) c.stream_send_windows[id.value] = &w } @@ -550,7 +673,7 @@ pub fn (mut c QuicConn) process_timeouts(now u64) !PollResult { c.drain_pending_close(now, mut result) if c.state == .handshaking || c.state == .established { - handshake_confirmed := c.handshake_completion.is_confirmed() + handshake_confirmed := c.is_handshake_confirmed() max_ack_delay := c.effective_max_ack_delay() timeout_result := c.loss_detection.on_loss_detection_timeout(now, handshake_confirmed, max_ack_delay) @@ -680,7 +803,22 @@ fn (mut c QuicConn) process_initial_or_handshake(space QuicPacketNumberSpace, ra // well-encrypted Initial packet with an ARBITRARY destination CID and // have it accepted as if it were a legitimate reply, redirecting the // client's subsequent packets. - if header.dcid != c.scid { + // + // A server's Initial-space packets are the one exception to "always + // c.scid": §7.2 also says "Until a packet is received from the + // server, the client MUST use the same Destination Connection ID + // value on all packets in this connection" -- i.e. a client's Initial + // packet sent BEFORE it has processed any reply (the ClientHello + // itself, or a same-flight retransmission of it) still carries + // c.original_dcid, not c.scid, since the client cannot address a + // value (this server's freshly generated scid) it has not learned + // yet. Only ONCE the client has processed this server's first reply + // does it switch to c.scid for anything further it sends -- which is + // why this exception is scoped to space == .initial only; by + // Handshake space every legitimate client packet already uses c.scid. + is_servers_bootstrap_packet := c.role == .server && space == .initial + && header.dcid == c.original_dcid + if header.dcid != c.scid && !is_servers_bootstrap_packet { return } // RFC 9000 §7.2 (verbatim): "Once a client has received a valid Initial @@ -714,9 +852,9 @@ fn (mut c QuicConn) process_initial_or_handshake(space QuicPacketNumberSpace, ra // worked around at the call site once it's an if-expression. mut keys := QuicPacketProtectionKeys{} if space == .initial { - keys = c.initial_keys_server + keys = c.peer_initial_keys() } else { - keys = c.handshake_keys_server or { return } + keys = c.peer_handshake_keys() or { return } } mut packet := raw.clone() largest_pn := if space == .initial { @@ -726,6 +864,16 @@ fn (mut c QuicConn) process_initial_or_handshake(space QuicPacketNumberSpace, ra } unprotected := unprotect_packet(mut packet, offset, .long, keys, largest_pn) or { return } + // RFC 9001 §4.9.1: the SERVER-side mirror of build_handshake_packet's + // client-only send-based discard -- a server discards Initial keys once + // it has successfully processed (here: successfully decrypted) its + // first Handshake-space packet from the client, independent of anything + // the server itself has sent. See build_handshake_packet's own doc + // comment for why the two roles need genuinely different triggers. + if c.role == .server && space == .handshake && !c.initial_keys_discarded { + c.discard_initial_keys() + } + c.processed_first_server_packet = true // RFC 9000 §10.1: receiving and successfully processing ANY packet // restarts the idle timer, not just an ack-eliciting one -- see @@ -983,10 +1131,12 @@ fn (mut c QuicConn) dispatch_one_rtt_frame(frame QuicFrame, now u64, mut result // RFC 9000 §19.20: "A HANDSHAKE_DONE frame can only be sent by // the server... A server MUST treat receipt of a // HANDSHAKE_DONE frame as a connection error of type - // PROTOCOL_VIOLATION." Currently unreachable in real use (v1 - // only ever constructs clients, role is always .client) but - // kept so this dispatch code needs no rework when server - // support (Phase 13) lands -- see + // PROTOCOL_VIOLATION." A genuine, reachable defensive check as + // of Phase 13d: accept() (accept.v) now constructs real + // server-role connections, and the 1-RTT receive path has no + // role gate before dispatching frames here, so a malicious or + // buggy peer sending HANDSHAKE_DONE to a real server hits this + // branch in production -- not just in // test_handshake_done_rejected_when_role_is_server. if c.role == .server { return error('quic: PROTOCOL_VIOLATION: server received HANDSHAKE_DONE (RFC 9000 §19.20)') @@ -1229,14 +1379,14 @@ fn (mut c QuicConn) handle_stop_sending_frame(frame StopSendingFrame) { } fn (mut c QuicConn) handle_ack_frame(space QuicPacketNumberSpace, frame AckFrame, now u64) { - handshake_confirmed := c.handshake_completion.is_confirmed() + handshake_confirmed := c.is_handshake_confirmed() max_ack_delay := c.effective_max_ack_delay() // on_ack_received's own doc comment: "ack_delay_exponent is the PEER's // own ack_delay_exponent transport parameter" -- this ACK frame's raw // ack_delay field was encoded by the peer using ITS advertised value // (RFC 9000 §18.2), not ours, and not the RFC's own default-if-absent // value unconditionally. - peer_ack_delay_exponent := c.handshake.peer_transport_parameters.ack_delay_exponent or { + peer_ack_delay_exponent := c.peer_transport_parameters().ack_delay_exponent or { default_ack_delay_exponent } @@ -1304,17 +1454,25 @@ fn (mut c QuicConn) pump_handshake_messages(space QuicPacketNumberSpace) ! { } fn (mut c QuicConn) dispatch_handshake_message(msg HandshakeMessage, framed []u8) ! { - state := c.handshake.state() + if c.role == .server { + c.dispatch_server_handshake_message(msg, framed)! + return + } + mut handshake := c.handshake or { + return error('quic: internal error: client connection has no handshake state') + } + + state := handshake.state() match state { .wait_server_hello { - hs := c.handshake.process_server_hello(msg, framed)! + hs := handshake.process_server_hello(msg, framed)! c.handshake_keys_client = derive_packet_protection_keys(hs.client_secret)! c.handshake_keys_server = derive_packet_protection_keys(hs.server_secret)! } .wait_encrypted_extensions { - c.handshake.process_encrypted_extensions(msg, framed, c.peer_scid, c.original_dcid, + handshake.process_encrypted_extensions(msg, framed, c.peer_scid, c.original_dcid, c.retry_scid)! - peer_params := c.handshake.peer_transport_parameters + peer_params := handshake.peer_transport_parameters c.conn_send_window.raise_limit(peer_params.initial_max_data or { u64(0) }) c.peer_max_streams_bidi = peer_params.initial_max_streams_bidi or { u64(0) } c.peer_max_streams_uni = peer_params.initial_max_streams_uni or { u64(0) } @@ -1323,13 +1481,13 @@ fn (mut c QuicConn) dispatch_handshake_message(msg HandshakeMessage, framed []u8 } } .wait_certificate { - c.handshake.process_certificate_or_request(msg, framed)! + handshake.process_certificate_or_request(msg, framed)! } .wait_certificate_verify { - c.handshake.process_certificate_verify(msg, framed)! + handshake.process_certificate_verify(msg, framed)! } .wait_finished { - client_finished, app_secrets := c.handshake.process_finished(msg, framed)! + client_finished, app_secrets := handshake.process_finished(msg, framed)! c.app_write_keys = derive_packet_protection_keys(app_secrets.client_secret)! c.app_write_secret = app_secrets.client_secret.clone() c.app_write_generation = 0 @@ -1343,6 +1501,69 @@ fn (mut c QuicConn) dispatch_handshake_message(msg HandshakeMessage, framed []u8 } } +// dispatch_server_handshake_message is dispatch_handshake_message's +// server-role counterpart. Unlike the client's 6-state dispatch above, a +// server's FIRST handshake message (the ClientHello) is never routed +// through a state-machine branch here at all -- Tls13ServerHandshake has +// no wait_client_hello state, because respond_to_client_hello IS the act +// of constructing the handshake object, not a transition on an existing +// one (see that struct's own doc comment). So this function's real branch +// is "do we have a server_handshake object yet": no means `msg` must be +// the ClientHello and this is accept()'s deferred bootstrap step (accept() +// itself only decrypts the Initial packet and constructs the connection; +// it stashes server_accept_params and lets this function -- reached via +// the ordinary process_initial_or_handshake -> handle_crypto_frame -> +// pump_handshake_messages call chain every OTHER handshake message already +// goes through -- do the actual respond_to_client_hello call, so accept() +// doesn't need its own parallel decrypt/frame-dispatch path); yes means +// `msg` is the client's Finished, the server's only OTHER expected message. +fn (mut c QuicConn) dispatch_server_handshake_message(msg HandshakeMessage, framed []u8) ! { + mut sh := c.server_handshake or { + params := c.server_accept_params or { + return error('quic: internal error: server connection has no accept parameters to process a ClientHello with') + } + + hs, flight := Tls13ServerHandshake.respond_to_client_hello(msg, framed, params)! + c.server_handshake = hs + c.server_accept_params = none + c.handshake_keys_client = + derive_packet_protection_keys(flight.handshake_secrets.client_secret)! + c.handshake_keys_server = + derive_packet_protection_keys(flight.handshake_secrets.server_secret)! + // RFC 8446 §7.1/Figure 3: a server derives application traffic + // secrets right after its own Finished, with no dependency on the + // client's Finished -- unlike the client's .wait_finished arm + // above, these are ready here, not deferred to process_finished. + c.app_write_keys = derive_packet_protection_keys(flight.application_secrets.server_secret)! + c.app_write_secret = flight.application_secrets.server_secret.clone() + c.app_write_generation = 0 + c.app_read_keys = new_key_update_state(flight.application_secrets.client_secret)! + c.server_negotiated_alpn = flight.negotiated_alpn + c.pending_initial_crypto = flight.server_hello + c.pending_handshake_crypto = flight.handshake_messages + peer_params := hs.peer_transport_parameters + c.conn_send_window.raise_limit(peer_params.initial_max_data or { u64(0) }) + c.peer_max_streams_bidi = peer_params.initial_max_streams_bidi or { u64(0) } + c.peer_max_streams_uni = peer_params.initial_max_streams_uni or { u64(0) } + // A ClientHello's transport parameters have no stateless_reset_token + // field (RFC 9000 §18.2 -- only a SERVER sends that one), unlike the + // client's .wait_encrypted_extensions arm's identical-looking block + // above; nothing to record here. + return + } + + state := sh.state() + match state { + .wait_finished { + sh.process_finished(msg, framed)! + c.handshake_completion.mark_peer_finished_verified() + } + .connected { + return error('quic: unexpected handshake message after the handshake completed') + } + } +} + fn (mut c QuicConn) on_handshake_confirmed(mut result PollResult) { c.state = .established result.events << QuicEvent{ @@ -1417,7 +1638,7 @@ fn (mut c QuicConn) drain_outgoing(now u64, mut result PollResult) ! { c.pending_handshake_crypto = none c.handshake_completion.mark_own_finished_sent() } - if hs_keys := c.handshake_keys_client { + if hs_keys := c.own_handshake_keys() { _ := hs_keys if !c.handshake_keys_discarded && c.handshake_received_pns.len > 0 && c.handshake_ack_eliciting_pending { @@ -1430,6 +1651,22 @@ fn (mut c QuicConn) drain_outgoing(now u64, mut result PollResult) ! { } if _ := c.app_write_keys { + // RFC 9001 §4.1.2: "The server MUST send a HANDSHAKE_DONE frame as + // soon as the handshake is complete" -- is_complete() rather than + // is_handshake_confirmed() deliberately: for a server, confirmed IS + // complete (see is_handshake_confirmed's own doc comment), so + // checking complete() here and calling on_handshake_confirmed + // immediately after sending is the correct, non-circular ordering + // (confirmation depends on this send, not the other way around). + // handshake_done_sent guards this to fire at most once, the same + // one-shot shape as streams_blocked_sent_bidi/uni. + if c.role == .server && c.handshake_completion.is_complete() && !c.handshake_done_sent { + frame := encode_handshake_done_frame()! + datagram := c.build_one_rtt_packet(frame, true, now)! + result.outgoing << datagram + c.handshake_done_sent = true + c.on_handshake_confirmed(mut result) + } if c.app_received_pns.len > 0 && c.app_ack_eliciting_pending { ack_frame := c.build_ack_frame_for(.application_data)! datagram := c.build_one_rtt_packet(ack_frame, false, now)! @@ -1600,8 +1837,7 @@ fn (mut c QuicConn) build_initial_packet(payload []u8, is_ack_eliciting bool, no mut header := encode_long_header(h, 0, u8(pn_length - 1))! header << pn_bytes - protected := - protect_packet(header, .long, pn, pn_length, padded_payload, c.initial_keys_client)! + protected := protect_packet(header, .long, pn, pn_length, padded_payload, c.own_initial_keys())! c.loss_detection.on_packet_sent(.initial, pn, u64(protected.len), is_ack_eliciting, true, now) c.congestion_control.on_packet_sent_cc(u64(protected.len)) @@ -1612,7 +1848,7 @@ fn (mut c QuicConn) build_initial_packet(payload []u8, is_ack_eliciting bool, no } fn (mut c QuicConn) build_handshake_packet(payload []u8, is_ack_eliciting bool, now u64) !QuicDatagram { - keys := c.handshake_keys_client or { + keys := c.own_handshake_keys() or { return error('quic: internal error: no Handshake write keys available yet') } @@ -1640,11 +1876,30 @@ fn (mut c QuicConn) build_handshake_packet(payload []u8, is_ack_eliciting bool, } c.idle_timeout.note_packet_sent(now) - // RFC 9001 §4.9.1: discard Initial keys once the first Handshake-space - // packet has been sent. - c.handshake_completion.mark_sent_first_handshake_packet() - if c.handshake_completion.should_discard_initial_keys() && !c.initial_keys_discarded { - c.discard_initial_keys() + // RFC 9001 §4.9.1: "a client MUST discard Initial keys when it first + // sends a Handshake packet, and a server MUST discard Initial keys when + // it first successfully processes a Handshake packet" -- two DIFFERENT + // triggers per role, send vs. receive. Only the client's is send-based, + // so this is scoped to c.role == .client; the server's receive-based + // trigger lives in process_initial_or_handshake instead (right after a + // Handshake-space packet from the client successfully decrypts). + // Applying this send-based check to both roles (the diff's original + // shape) made a server discard its Initial keys within the very same + // accept()/poll() call that processed the ClientHello -- before the + // client had sent anything back -- which then silently dropped any + // ClientHello retransmission (RFC 9000 §7.2's own DCID exception is + // keyed off c.original_dcid, but process_initial_or_handshake's very + // first check already rejects the whole Initial space once + // initial_keys_discarded is true) and left the server unable to + // PTO-retransmit its own first flight, stalling the handshake on + // ordinary first-round-trip packet loss. Found by 13d-1's adversarial + // review (protocol lens); accept_test.v's own lossless, zero-loss + // integration test can't exercise this since nothing is ever lost. + if c.role == .client { + c.handshake_completion.mark_sent_first_handshake_packet() + if c.handshake_completion.should_discard_initial_keys() && !c.initial_keys_discarded { + c.discard_initial_keys() + } } return QuicDatagram{ bytes: protected @@ -1705,7 +1960,7 @@ fn (mut c QuicConn) send_pto_probe(space QuicPacketNumberSpace, now u64, mut res if c.handshake_keys_discarded { return } - _ := c.handshake_keys_client or { return } + _ := c.own_handshake_keys() or { return } datagram := c.build_handshake_packet(ping, true, now)! result.outgoing << datagram } @@ -1823,7 +2078,7 @@ fn (mut c QuicConn) build_best_effort_close_packet(frame []u8, now u64) !QuicDat return c.build_one_rtt_packet(frame, false, now) } if !c.handshake_keys_discarded { - if _ := c.handshake_keys_client { + if _ := c.own_handshake_keys() { return c.build_handshake_packet(frame, false, now) } } @@ -1841,7 +2096,7 @@ fn (c &QuicConn) effective_max_ack_delay() time.Duration { if !c.handshake_completion.is_complete() { return time.Duration(0) } - v := c.handshake.peer_transport_parameters.max_ack_delay or { default_max_ack_delay_ms } + v := c.peer_transport_parameters().max_ack_delay or { default_max_ack_delay_ms } return time.Duration(i64(v) * i64(time.millisecond)) } @@ -1860,14 +2115,14 @@ fn (c &QuicConn) effective_max_ack_delay() time.Duration { // `u64(timeout)` was correct all along; the real, adjacent bug was in // `closing_or_draining_deadline`, which had the identical mistake.) fn (c &QuicConn) idle_timeout_deadline() ?u64 { - peer_max := c.handshake.peer_transport_parameters.max_idle_timeout or { u64(0) } + peer_max := c.peer_transport_parameters().max_idle_timeout or { u64(0) } timeout := effective_idle_timeout(c.own_max_idle_timeout_ms, peer_max) or { return none } baseline := c.idle_timeout.last_reset or { c.connection_start } return baseline + u64(timeout) } fn (mut c QuicConn) compute_next_timeout() ?u64 { - handshake_confirmed := c.handshake_completion.is_confirmed() + handshake_confirmed := c.is_handshake_confirmed() max_ack_delay := c.effective_max_ack_delay() mut deadline := ?u64(none) if t, _ := c.loss_detection.next_timeout(handshake_confirmed, max_ack_delay, diff --git a/vlib/net/quic/conn_test.v b/vlib/net/quic/conn_test.v index 565eb11f5729ec..e5b33f41342c18 100644 --- a/vlib/net/quic/conn_test.v +++ b/vlib/net/quic/conn_test.v @@ -341,7 +341,7 @@ fn drive_to_established(own_params QuicTransportParameters, peer_params QuicTran sh_payload, c.initial_keys_server)! result1 := c.poll(sh_datagram.bytes, now)! assert result1.events.len == 0 - assert c.handshake.state() == .wait_encrypted_extensions + assert c.client_handshake().state() == .wait_encrypted_extensions now += 10 // --- Deliver EncryptedExtensions as a real, protected Handshake packet --- @@ -355,14 +355,14 @@ fn drive_to_established(own_params QuicTransportParameters, peer_params QuicTran ee_payload, hs_keys_server)! result2 := c.poll(ee_datagram.bytes, now)! assert result2.events.len == 0 - assert c.handshake.state() == .wait_certificate + assert c.client_handshake().state() == .wait_certificate now += 10 // --- Certificate + CertificateVerify: direct API, not over the wire (see doc comment below) --- server_der := conn_test_pem_to_der(conn_test_cert_pem) cert_framed := conn_test_build_fake_certificate(server_der)! cert_msg, _ := parse_handshake_message(cert_framed)! - c.handshake.process_certificate_or_request(cert_msg, cert_framed) or { + c.client_handshake().process_certificate_or_request(cert_msg, cert_framed) or { // Expected: this repo's test cert is self-signed, so trust // validation fails -- matches tls13_handshake_test.v's own // documented, accepted limitation. The transcript is still @@ -370,31 +370,31 @@ fn drive_to_established(own_params QuicTransportParameters, peer_params QuicTran } real_chain := mbedtls.build_certificate_chain([server_der])! unsafe { - c.handshake.verified_chain = &VerifiedCertificateChain{ + c.client_handshake().verified_chain = &VerifiedCertificateChain{ chain: real_chain } } - c.handshake.certificate_transcript_hash = c.handshake.transcript_hash() - c.handshake.state = .wait_certificate_verify + c.client_handshake().certificate_transcript_hash = c.client_handshake().transcript_hash() + c.client_handshake().state = .wait_certificate_verify signed_content := certificate_verify_signed_content(.server, - c.handshake.certificate_transcript_hash) + c.client_handshake().certificate_transcript_hash) sig := conn_test_sign_certificate_verify(signed_content)! cv_framed := conn_test_build_fake_certificate_verify(sig_scheme_rsa_pss_rsae_sha256, sig)! cv_msg, _ := parse_handshake_message(cv_framed)! - c.handshake.process_certificate_verify(cv_msg, cv_framed)! - assert c.handshake.state() == .wait_finished + c.client_handshake().process_certificate_verify(cv_msg, cv_framed)! + assert c.client_handshake().state() == .wait_finished // --- Deliver Finished as a real, protected Handshake packet --- finished_verify_data := compute_finished_verify_data(server_handshake_secrets.server_secret, - c.handshake.transcript_hash())! + c.client_handshake().transcript_hash())! finished_framed := encode_handshake_message(.finished, finished_verify_data)! finished_payload := encode_crypto_frame(u64(ee_framed.len), finished_framed)! finished_datagram := build_fake_long_header_packet(.handshake, c.scid, server_initial_scid, 1, finished_payload, hs_keys_server)! result3 := c.poll(finished_datagram.bytes, now)! assert result3.events.len == 0 - assert c.handshake.state() == .connected + assert c.client_handshake().state() == .connected now += 10 // --- Fake server: deliver HANDSHAKE_DONE over a real 1-RTT packet --- @@ -432,7 +432,7 @@ fn test_dial_produces_a_valid_padded_initial_datagram() { transport_parameters: QuicTransportParameters{} }, u64(0))! defer { - c.handshake.free() + c.client_handshake().free() } assert dg.bytes.len >= min_initial_datagram_size assert c.state() == .handshaking @@ -468,7 +468,7 @@ fn test_dial_produces_a_valid_padded_initial_datagram() { fn test_full_handshake_reaches_confirmed_over_fake_transport() { mut c, _, _ := drive_to_established(QuicTransportParameters{}, QuicTransportParameters{})! defer { - c.handshake.free() + c.client_handshake().free() } assert c.handshake_completion_is_complete() assert c.app_write_keys != none @@ -497,7 +497,7 @@ fn test_stream_write_read_round_trip_over_fake_transport() { mut c, server_initial_scid, mut now := drive_to_established(generous_transport_params(), generous_transport_params())! defer { - c.handshake.free() + c.client_handshake().free() } stream_id := c.open_stream(true)! @@ -548,7 +548,7 @@ fn test_open_stream_respects_peer_max_streams_and_streams_blocked() { mut c, server_initial_scid, mut now := drive_to_established(own_params, restrictive_peer_params)! defer { - c.handshake.free() + c.client_handshake().free() } c.open_stream(true) or { assert err.msg().contains('STREAM_LIMIT') } @@ -589,7 +589,7 @@ fn test_close_sends_connection_close_and_transitions_to_closing() { mut c, server_initial_scid, now := drive_to_established(generous_transport_params(), generous_transport_params())! defer { - c.handshake.free() + c.client_handshake().free() } c.close(42, 'bye') @@ -633,7 +633,7 @@ fn test_close_before_one_rtt_keys_downgrades_to_transport_connection_close() { transport_parameters: QuicTransportParameters{} }, now)! defer { - c.handshake.free() + c.client_handshake().free() } assert initial_dg.bytes.len >= min_initial_datagram_size @@ -672,7 +672,7 @@ fn test_close_before_one_rtt_keys_downgrades_to_transport_connection_close() { ee_payload, hs_keys_server)! result2 := c.poll(ee_datagram.bytes, now)! assert result2.events.len == 0 - assert c.handshake.state() == .wait_certificate + assert c.client_handshake().state() == .wait_certificate assert c.app_write_keys == none // still pre-1-RTT -- the window this bug lives in now += 10 @@ -708,7 +708,7 @@ fn test_close_before_one_rtt_keys_downgrades_to_transport_connection_close() { fn test_non_ack_eliciting_packet_does_not_elicit_an_ack() { mut c, _, now := drive_to_established(generous_transport_params(), generous_transport_params())! defer { - c.handshake.free() + c.client_handshake().free() } read_keys := c.app_read_keys or { panic('unreachable: established asserts this') } @@ -726,7 +726,7 @@ fn test_non_ack_eliciting_packet_does_not_elicit_an_ack() { // 9002's on_ack_received contract (its own doc comment: "ack_delay_exponent // is the PEER's own ack_delay_exponent transport parameter") -- conn.v's // handle_ack_frame passed the bare frame.v default_ack_delay_exponent -// constant (3) instead of c.handshake.peer_transport_parameters. +// constant (3) instead of c.client_handshake().peer_transport_parameters. // ack_delay_exponent, so any peer advertising a non-default value (legal up // to 20 per RFC 9000 §18.2) had every one of its ACK Delay fields // misinterpreted, corrupting the smoothed_rtt/rttvar the PTO timer is built @@ -742,7 +742,7 @@ fn test_handle_ack_frame_uses_peer_advertised_ack_delay_exponent() { peer_params.ack_delay_exponent = 12 mut c, _, now := drive_to_established(generous_transport_params(), peer_params)! defer { - c.handshake.free() + c.client_handshake().free() } // First RTT sample -- seeds has_sample; RttEstimator.update's @@ -806,7 +806,7 @@ fn test_client_follows_server_initiated_key_update() { mut c, server_initial_scid, now := drive_to_established(generous_transport_params(), generous_transport_params())! defer { - c.handshake.free() + c.client_handshake().free() } read_keys := c.app_read_keys or { panic('unreachable: established asserts this') } @@ -854,7 +854,7 @@ fn test_client_follows_server_initiated_key_update() { fn test_peer_connection_close_enters_draining() { mut c, _, now := drive_to_established(generous_transport_params(), generous_transport_params())! defer { - c.handshake.free() + c.client_handshake().free() } read_keys := c.app_read_keys or { panic('unreachable: established asserts this') } @@ -908,7 +908,7 @@ fn test_closing_deadline_not_extended_by_peer_close_while_already_closing() { mut c, _, now0 := drive_to_established(generous_transport_params(), generous_transport_params())! defer { - c.handshake.free() + c.client_handshake().free() } c.close(1, 'bye') @@ -962,7 +962,7 @@ fn test_idle_timeout_mechanism_fires_after_configured_window() { peer_params.max_idle_timeout = 5000 mut c, _, mut now := drive_to_established(own_params, peer_params)! defer { - c.handshake.free() + c.client_handshake().free() } // Genuinely idle (nothing received or sent since establishment) and @@ -1002,7 +1002,7 @@ fn test_process_one_rtt_packet_resets_idle_timer_on_receive() { mut c, _, mut now := drive_to_established(generous_transport_params(), generous_transport_params())! defer { - c.handshake.free() + c.client_handshake().free() } read_keys := c.app_read_keys or { panic('unreachable: established asserts this') } @@ -1037,7 +1037,7 @@ fn test_process_one_rtt_packet_resets_idle_timer_on_receive() { fn test_handshake_done_rejected_when_role_is_server() { mut c, _, now := drive_to_established(generous_transport_params(), generous_transport_params())! defer { - c.handshake.free() + c.client_handshake().free() } c.role = .server @@ -1066,7 +1066,7 @@ fn test_discarded_initial_keys_reject_further_packets() { mut c, server_initial_scid, mut now := drive_to_established(generous_transport_params(), generous_transport_params())! defer { - c.handshake.free() + c.client_handshake().free() } assert c.initial_keys_discarded @@ -1097,7 +1097,7 @@ fn test_discarded_handshake_keys_reject_further_packets() { mut c, server_initial_scid, mut now := drive_to_established(generous_transport_params(), generous_transport_params())! defer { - c.handshake.free() + c.client_handshake().free() } assert c.handshake_keys_discarded hs_keys_server := c.handshake_keys_server or { @@ -1137,7 +1137,7 @@ fn test_wrong_destination_cid_rejected_on_long_header() { transport_parameters: QuicTransportParameters{} }, u64(0))! defer { - c.handshake.free() + c.client_handshake().free() } assert initial_dg.bytes.len >= min_initial_datagram_size assert c.peer_scid.len == 0 @@ -1155,7 +1155,7 @@ fn test_wrong_destination_cid_rejected_on_long_header() { result := c.poll(forged_datagram.bytes, u64(10))! assert result.events.len == 0 assert c.peer_scid.len == 0 - assert c.handshake.state() == .wait_server_hello + assert c.client_handshake().state() == .wait_server_hello } // test_wrong_destination_cid_rejected_on_short_header is the 1-RTT sibling of @@ -1166,7 +1166,7 @@ fn test_wrong_destination_cid_rejected_on_short_header() { mut c, server_initial_scid, mut now := drive_to_established(generous_transport_params(), generous_transport_params())! defer { - c.handshake.free() + c.client_handshake().free() } read_keys := c.app_read_keys or { panic('unreachable: established asserts this') } @@ -1216,7 +1216,7 @@ fn test_wrong_source_cid_rejected_after_peer_scid_established() { transport_parameters: QuicTransportParameters{} }, u64(0))! defer { - c.handshake.free() + c.client_handshake().free() } assert initial_dg.bytes.len >= min_initial_datagram_size @@ -1289,7 +1289,7 @@ fn test_compute_next_timeout_includes_closing_deadline() { transport_parameters: QuicTransportParameters{} }, u64(0))! defer { - c.handshake.free() + c.client_handshake().free() } c.congestion_control.bytes_in_flight = 0 assert c.idle_timeout_deadline() == none @@ -1315,7 +1315,7 @@ fn test_write_stream_on_auto_created_sibling_stream_is_not_stuck() { mut c, server_initial_scid, mut now := drive_to_established(generous_transport_params(), generous_transport_params())! defer { - c.handshake.free() + c.client_handshake().free() } // Server-initiated bidi stream ids are 1, 5, 9, ... (base 1, step 4). @@ -1376,14 +1376,14 @@ fn test_negotiated_alpn_is_none_before_established_and_selected_value_after() { transport_parameters: QuicTransportParameters{} }, u64(0))! defer { - fresh.handshake.free() + fresh.client_handshake().free() } assert fresh.state() == .handshaking assert fresh.negotiated_alpn() == none mut c, _, _ := drive_to_established(generous_transport_params(), generous_transport_params())! defer { - c.handshake.free() + c.client_handshake().free() } assert c.negotiated_alpn()? == 'h3' } @@ -1402,7 +1402,7 @@ fn test_peer_stream_opened_never_fires_for_a_locally_opened_stream() { mut c, server_initial_scid, mut now := drive_to_established(generous_transport_params(), generous_transport_params())! defer { - c.handshake.free() + c.client_handshake().free() } stream_id := c.open_stream(true)! c.write_stream(stream_id, 'hello from client'.bytes(), true)! @@ -1434,7 +1434,7 @@ fn test_peer_stream_opened_survives_reordering_past_an_auto_created_filler() { mut c, _, mut now := drive_to_established(generous_transport_params(), generous_transport_params())! defer { - c.handshake.free() + c.client_handshake().free() } read_keys := c.app_read_keys or { panic('unreachable: established asserts this') } server_app_keys := read_keys.current_keys @@ -1469,7 +1469,7 @@ fn test_peer_stream_opened_survives_reordering_past_an_auto_created_filler() { fn test_peer_stream_opened_fires_from_a_bare_reset_stream_frame() { mut c, _, now := drive_to_established(generous_transport_params(), generous_transport_params())! defer { - c.handshake.free() + c.client_handshake().free() } read_keys := c.app_read_keys or { panic('unreachable: established asserts this') } server_app_keys := read_keys.current_keys @@ -1492,7 +1492,7 @@ fn test_peer_stream_opened_fires_from_a_bare_reset_stream_frame() { fn test_stream_recv_status_reports_all_three_terminal_states() { mut c, _, now := drive_to_established(generous_transport_params(), generous_transport_params())! defer { - c.handshake.free() + c.client_handshake().free() } assert c.stream_recv_status(1) == none // never seen at all diff --git a/vlib/net/quic/frame.v b/vlib/net/quic/frame.v index d857439023f1ad..a877ad62092c7d 100644 --- a/vlib/net/quic/frame.v +++ b/vlib/net/quic/frame.v @@ -993,3 +993,13 @@ pub fn encode_connection_close_frame(is_application_error bool, error_code u64, out << reason_bytes return out } + +// encode_handshake_done_frame serializes a HANDSHAKE_DONE frame (type +// 0x1e, RFC 9000 §19.20) -- no fields, a bare frame-type varint. Only a +// server ever sends one (see HandshakeDoneFrame's own doc comment on the +// decode side); this encoder itself has no role awareness to enforce +// that, matching this file's established division of labor (role checks +// live on the caller, e.g. QuicConn). +pub fn encode_handshake_done_frame() ![]u8 { + return encode_varint(frame_type_handshake_done) +} diff --git a/vlib/net/quic/h3_conn_test.v b/vlib/net/quic/h3_conn_test.v index 7e01898423a422..14e37b731bc94e 100644 --- a/vlib/net/quic/h3_conn_test.v +++ b/vlib/net/quic/h3_conn_test.v @@ -269,7 +269,7 @@ fn drive_to_established(own_params QuicTransportParameters, peer_params QuicTran sh_payload, c.initial_keys_server)! result1 := c.poll(sh_datagram.bytes, now)! assert result1.events.len == 0 - assert c.handshake.state() == .wait_encrypted_extensions + assert c.client_handshake().state() == .wait_encrypted_extensions now += 10 hs_keys_server := c.handshake_keys_server or { panic('unreachable: just asserted != none') } @@ -282,39 +282,39 @@ fn drive_to_established(own_params QuicTransportParameters, peer_params QuicTran ee_payload, hs_keys_server)! result2 := c.poll(ee_datagram.bytes, now)! assert result2.events.len == 0 - assert c.handshake.state() == .wait_certificate + assert c.client_handshake().state() == .wait_certificate now += 10 server_der := conn_test_pem_to_der(conn_test_cert_pem) cert_framed := conn_test_build_fake_certificate(server_der)! cert_msg, _ := parse_handshake_message(cert_framed)! - c.handshake.process_certificate_or_request(cert_msg, cert_framed) or {} + c.client_handshake().process_certificate_or_request(cert_msg, cert_framed) or {} real_chain := mbedtls.build_certificate_chain([server_der])! unsafe { - c.handshake.verified_chain = &VerifiedCertificateChain{ + c.client_handshake().verified_chain = &VerifiedCertificateChain{ chain: real_chain } } - c.handshake.certificate_transcript_hash = c.handshake.transcript_hash() - c.handshake.state = .wait_certificate_verify + c.client_handshake().certificate_transcript_hash = c.client_handshake().transcript_hash() + c.client_handshake().state = .wait_certificate_verify signed_content := certificate_verify_signed_content(.server, - c.handshake.certificate_transcript_hash) + c.client_handshake().certificate_transcript_hash) sig := conn_test_sign_certificate_verify(signed_content)! cv_framed := conn_test_build_fake_certificate_verify(sig_scheme_rsa_pss_rsae_sha256, sig)! cv_msg, _ := parse_handshake_message(cv_framed)! - c.handshake.process_certificate_verify(cv_msg, cv_framed)! - assert c.handshake.state() == .wait_finished + c.client_handshake().process_certificate_verify(cv_msg, cv_framed)! + assert c.client_handshake().state() == .wait_finished finished_verify_data := compute_finished_verify_data(server_handshake_secrets.server_secret, - c.handshake.transcript_hash())! + c.client_handshake().transcript_hash())! finished_framed := encode_handshake_message(.finished, finished_verify_data)! finished_payload := encode_crypto_frame(u64(ee_framed.len), finished_framed)! finished_datagram := build_fake_long_header_packet(.handshake, c.scid, server_initial_scid, 1, finished_payload, hs_keys_server)! result3 := c.poll(finished_datagram.bytes, now)! assert result3.events.len == 0 - assert c.handshake.state() == .connected + assert c.client_handshake().state() == .connected now += 10 read_keys := c.app_read_keys or { panic('unreachable: just asserted != none') } @@ -385,7 +385,7 @@ fn read_keys(mut c QuicConn) QuicPacketProtectionKeys { fn test_h3_conn_established_opens_own_control_and_qpack_streams() { mut c, mut h, _, now := h3_test_conn()! defer { - c.handshake.free() + c.client_handshake().free() } assert h.established() result := h.poll(none, now)! @@ -395,7 +395,7 @@ fn test_h3_conn_established_opens_own_control_and_qpack_streams() { fn test_h3_conn_peer_control_stream_requires_settings_first() { mut c, mut h, _, now := h3_test_conn()! defer { - c.handshake.free() + c.client_handshake().free() } mut header := encode_h3_control_stream_header()! header << encode_goaway_frame(0)! @@ -409,7 +409,7 @@ fn test_h3_conn_peer_control_stream_requires_settings_first() { fn test_h3_conn_peer_control_stream_settings_then_second_settings_rejected() { mut c, mut h, _, now := h3_test_conn()! defer { - c.handshake.free() + c.client_handshake().free() } mut buf := encode_h3_control_stream_header()! buf << encode_settings_frame([]H3Setting{})! @@ -424,7 +424,7 @@ fn test_h3_conn_peer_control_stream_settings_then_second_settings_rejected() { fn test_h3_conn_peer_control_stream_settings_accepted_and_settings_received_event() { mut c, mut h, _, now := h3_test_conn()! defer { - c.handshake.free() + c.client_handshake().free() } mut buf := encode_h3_control_stream_header()! buf << encode_settings_frame([]H3Setting{})! @@ -437,7 +437,7 @@ fn test_h3_conn_peer_control_stream_settings_accepted_and_settings_received_even fn test_h3_conn_rejects_push_promise_on_control_stream() { mut c, mut h, _, now := h3_test_conn()! defer { - c.handshake.free() + c.client_handshake().free() } mut buf := encode_h3_control_stream_header()! buf << encode_settings_frame([]H3Setting{})! @@ -452,7 +452,7 @@ fn test_h3_conn_rejects_push_promise_on_control_stream() { fn test_h3_conn_ignores_unknown_unidirectional_stream_type() { mut c, mut h, _, now := h3_test_conn()! defer { - c.handshake.free() + c.client_handshake().free() } mut buf := encode_varint(u64(0x40))! buf << [u8(1), 2, 3, 4] @@ -465,7 +465,7 @@ fn test_h3_conn_ignores_unknown_unidirectional_stream_type() { fn test_h3_conn_qpack_glue_loop_end_to_end_with_section_ack() { mut c, mut h, _, now := h3_test_conn()! defer { - c.handshake.free() + c.client_handshake().free() } mut peer_encoder := new_qpack_encoder() set_cap_instr := peer_encoder.set_capacity(4096, 4096)! @@ -517,7 +517,7 @@ fn test_h3_conn_qpack_glue_loop_end_to_end_with_section_ack() { fn test_h3_conn_1xx_interim_response_is_discarded_not_misdelivered_as_final_or_trailers() { mut c, mut h, _, now := h3_test_conn()! defer { - c.handshake.free() + c.client_handshake().free() } mut peer_encoder := new_qpack_encoder() @@ -562,7 +562,7 @@ fn test_h3_conn_1xx_interim_response_is_discarded_not_misdelivered_as_final_or_t fn test_h3_conn_prunes_request_stream_state_once_finalized() { mut c, mut h, _, now := h3_test_conn()! defer { - c.handshake.free() + c.client_handshake().free() } mut peer_encoder := new_qpack_encoder() @@ -593,7 +593,7 @@ fn test_h3_conn_prunes_request_stream_state_once_finalized() { fn test_h3_conn_prunes_request_stream_state_on_failure_too() { mut c, mut h, _, now := h3_test_conn()! defer { - c.handshake.free() + c.client_handshake().free() } stream_id := h.open_request_stream()! h.poll(none, now)! @@ -629,7 +629,7 @@ fn test_h3_conn_prunes_request_stream_state_on_failure_too() { fn test_h3_conn_prunes_dead_request_stream_once_peer_side_fully_terminal() { mut c, mut h, _, now := h3_test_conn()! defer { - c.handshake.free() + c.client_handshake().free() } stream_id := h.open_request_stream()! h.poll(none, now)! @@ -666,7 +666,7 @@ fn test_h3_conn_prunes_dead_request_stream_once_peer_side_fully_terminal() { fn test_h3_conn_blocked_headers_retry_after_delayed_encoder_instruction() { mut c, mut h, _, now := h3_test_conn()! defer { - c.handshake.free() + c.client_handshake().free() } mut peer_encoder := new_qpack_encoder() set_cap_instr := peer_encoder.set_capacity(4096, 4096)! diff --git a/vlib/net/quic/stream.v b/vlib/net/quic/stream.v index bd2047718b4844..c83fd8465e7498 100644 --- a/vlib/net/quic/stream.v +++ b/vlib/net/quic/stream.v @@ -21,10 +21,9 @@ pub enum StreamDirection { // QuicRole distinguishes which side of the connection THIS endpoint is -- // needed because "is this stream mine to have opened" and "am I allowed // to send on this uni stream" depend on who's asking, not just the ID -// itself. v1 only ever runs as .client (server support is Phase 13, out -// of committed scope) -- this enum exists now so stream.v doesn't need -// reshaping when that phase lands, matching QuicConn's own planned -// `role`-field design. +// itself. Both roles are real as of Phase 13d's accept() (accept.v), +// which constructs .server-role connections -- matching QuicConn's own +// `role` field. pub enum QuicRole { client server From 8282341b3f91e5ac950d9e6c333c95545b669eea Mon Sep 17 00:00:00 2001 From: Richard Wheeler Date: Mon, 24 Aug 2026 23:51:21 -0400 Subject: [PATCH 10/10] net.quic: PROGRESS.md -- mark 13d-1 done, split remainder into 13d-2 Documentation-only update following 3a97c5962f. Co-Authored-By: Claude Sonnet 5 --- vlib/net/quic/PROGRESS.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/vlib/net/quic/PROGRESS.md b/vlib/net/quic/PROGRESS.md index be5bc0616c6902..c0fda7c06f1cf5 100644 --- a/vlib/net/quic/PROGRESS.md +++ b/vlib/net/quic/PROGRESS.md @@ -1354,10 +1354,29 @@ stacked-PR convention as Phase 12's 12a-12d. migration, which PROGRESS.md already lists as a separate, explicitly deferrable follow-up below — 13c ships the wire codec and the token primitive it depends on, not the state machine that would consume them. -- [ ] **13d** — UDP listener + connection demux: one socket routing many +- [x] **13d-1** — Server-role handshake wiring: `QuicConn` gained real + `.server`-role support (role-aware directional key selection, a + role-branched handshake dispatch, RFC 9001 §4.1.2's role-asymmetric + handshake-confirmation semantics, server-side HANDSHAKE_DONE sending) + and a new `accept()` constructor (`accept.v`) mirroring `dial()`. + Found + fixed two RFC-conformance bugs via adversarial review before + commit: the RFC 9000 §7.2 bootstrap-DCID exception for a server's + first-received ClientHello, and RFC 9001 §4.9.1's send-vs-receive + Initial-key-discard trigger asymmetry between roles (a naive + client-shaped trigger applied to both roles discarded the server's + Initial keys before the client had sent anything back, stalling the + handshake on ordinary first-round-trip packet loss). Also closed a + missing RFC 9000 §14.1 anti-amplification floor check in `accept()`. + `accept()` deliberately does NOT decide Retry-vs-direct-accept policy + (needs cross-connection-attempt state only 13d-2's listener has) and + does not fragment a large certificate chain's Handshake CRYPTO flight + across multiple packets (both documented scope limits, not blockers). +- [ ] **13d-2** — UDP listener + connection demux: one socket routing many concurrent connections by connection ID (not 4-tuple, since QUIC supports migration) — no analog in the client's transport today; the - new-connection acceptance path (unrecognized DCID → Retry-or-accept). + new-connection acceptance path (unrecognized DCID → Retry-or-accept, + wiring 13b's `AntiAmplificationLimiter`/Retry machinery before calling + 13d-1's `accept()`). - [ ] **13e** — `h3_server.v` wiring, mirroring `h2_server.v`'s established shape (minimal/serial first, concurrency as an explicit follow-up), plus server certificate/key loading.