diff --git a/vlib/net/quic/PROGRESS.md b/vlib/net/quic/PROGRESS.md index a27335bdd14a16..c0fda7c06f1cf5 100644 --- a/vlib/net/quic/PROGRESS.md +++ b/vlib/net/quic/PROGRESS.md @@ -1231,8 +1231,164 @@ 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. + +- [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`, + `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. + - [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). +- [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. +- [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. +- [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, + 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. +- [ ] *(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) 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/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/conn.v b/vlib/net/quic/conn.v index d7ce5f3395880a..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)') @@ -1029,6 +1179,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 @@ -1211,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 } @@ -1286,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) } @@ -1305,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 @@ -1325,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{ @@ -1399,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 { @@ -1412,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)! @@ -1582,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)) @@ -1594,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') } @@ -1622,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 @@ -1687,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 } @@ -1805,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) } } @@ -1823,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)) } @@ -1842,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 4443dad7b40f66..a877ad62092c7d 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 @@ -864,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/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/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/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' +} 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) +} 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 diff --git a/vlib/net/quic/tls13_certificate.v b/vlib/net/quic/tls13_certificate.v index e1990ed1e6bb71..67be1edca3fd13 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,64 @@ 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 (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{} + 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..fef73b041a2d9d 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,133 @@ 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 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() { + 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)! + 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 + + 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)! + _, 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 + + 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() { + _, 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_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_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_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' +} diff --git a/vlib/net/quic/tls13_server_hello.v b/vlib/net/quic/tls13_server_hello.v index aae92a1586c687..43eb4baf9bf97d 100644 --- a/vlib/net/quic/tls13_server_hello.v +++ b/vlib/net/quic/tls13_server_hello.v @@ -302,6 +302,263 @@ 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)! +} + +// 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: + // 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..f450cf1966c0d8 100644 --- a/vlib/net/quic/tls13_server_hello_test.v +++ b/vlib/net/quic/tls13_server_hello_test.v @@ -485,3 +485,192 @@ 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' +} + +// 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' +}