Skip to content

Commit 3a97c59

Browse files
Richard Wheelerclaude
andcommitted
net.quic: v3: 13d-1 -- wire a server-role accept() handshake path into QuicConn
Adds server-role support to QuicConn (previously client-only) plus a new accept() constructor mirroring dial(): role-aware directional key selection, role-branched handshake dispatch (dispatch_server_handshake_message), role-asymmetric handshake-confirmation semantics (RFC 9001 §4.1.2), and server-side HANDSHAKE_DONE sending -- all reusing the existing client-role packet-building/CRYPTO-reassembly/drain machinery. Two RFC-conformance bugs found and fixed via an adversarial multi-agent review before commit, both independently re-verified: - RFC 9000 §7.2: a client's Initial-space packets address their DCID field to its own original_dcid until it has processed a reply, so the DCID match in process_initial_or_handshake now accepts a server-role connection's bootstrap ClientHello even though the server's real scid didn't exist yet when the client sent it. - RFC 9001 §4.9.1: Initial-key discard is send-triggered for a client but receive-triggered for a server -- applying the client's trigger to both roles made accept()'s single poll() call discard the server's Initial keys before the client had sent anything back, silently dropping any ClientHello retransmission and blocking the server's own PTO retransmission of its first flight. Split into a client-only send trigger (build_handshake_packet) and a new server receive trigger (process_initial_or_handshake, right after a Handshake-space packet from the client decrypts). Also fixes a missing RFC 9000 §14.1 anti-amplification check: accept() now rejects any datagram under the 1200-byte floor before doing any work, closing a reflection-amplification gap. accept() deliberately does not decide Retry-vs-direct-accept policy (deferred to 13d-2's UDP listener, which has the cross-connection-attempt state that decision needs) and does not fragment a large certificate chain's Handshake-space CRYPTO flight across multiple packets (documented scope limits, not blockers for this repo's own small test certificate). Tests: new accept_test.v drives a real dial()/accept() pair through a full handshake (including real Certificate/CertificateVerify chain verification against a freshly generated EC cert -- 13a's own PROGRESS.md noted gap) and bidirectional stream exchange, plus regression assertions for both RFC-conformance fixes above and a rejection test for the anti-amplification floor. Full suite: 58/58 passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent bc5f108 commit 3a97c59

7 files changed

Lines changed: 828 additions & 109 deletions

File tree

vlib/net/quic/accept.v

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
module quic
2+
3+
import crypto.ecdsa
4+
import crypto.rand
5+
6+
// accept.v: the server-role counterpart to dial() (conn.v). Split into its
7+
// own file rather than added to conn.v directly for one purely mechanical
8+
// reason: this file needs crypto.rand (a real CSPRNG) for the server's own
9+
// connection ID and ServerHello random, and conn.v already imports the
10+
// plain `rand` module (non-cryptographic, used by dial() -- a known,
11+
// separately-tracked gap, not repeated here) under the same bare name --
12+
// V requires disambiguating two same-named imports, and a fresh file needing
13+
// only the secure one is simpler than an alias in an already-large file.
14+
//
15+
// Everything else server-role lives in conn.v itself (the struct fields,
16+
// dispatch_handshake_message's role branch, the own/peer_*_keys and
17+
// is_handshake_confirmed role-aware helpers, drain_outgoing's HANDSHAKE_DONE
18+
// send) -- accept() is deliberately thin: it does just enough to construct
19+
// a QuicConn with the right identity (original_dcid/dcid/scid/peer_scid)
20+
// and Initial-space keys to make the FIRST incoming datagram decryptable,
21+
// then hands that exact datagram to poll() -- the same, already-tested
22+
// entry point every SUBSEQUENT datagram goes through. Everything else
23+
// (decrypting, reassembling the ClientHello's CRYPTO frame(s), running
24+
// Tls13ServerHandshake.respond_to_client_hello, deriving Handshake/
25+
// Application keys, queuing the response flight, and draining it into
26+
// actual outgoing datagrams) happens exactly where it already happens for
27+
// every OTHER handshake message, via dispatch_handshake_message's own
28+
// server branch -- there is no separate, parallel decrypt/dispatch path
29+
// here to keep in sync with process_initial_or_handshake's.
30+
31+
// AcceptParams is everything accept() needs beyond what it decides for
32+
// itself (this server's own connection ID, ServerHello random) -- the
33+
// server-role mirror of DialParams.
34+
pub struct AcceptParams {
35+
pub:
36+
// This server's own OFFERED transport parameters. accept() overrides
37+
// initial_source_connection_id and original_destination_connection_id
38+
// with values it derives itself (RFC 9000 §7.3) regardless of what the
39+
// caller set there, the same override dial() already applies to its own
40+
// initial_source_connection_id.
41+
transport_parameters QuicTransportParameters
42+
// Application protocols this server supports, most preferred first
43+
// (RFC 7301 §3.2 -- the server picks, in ITS OWN preference order, from
44+
// among what the client also offered). Named to match DialParams'
45+
// alpn_protocols field, even though ServerHandshakeParams' own
46+
// equivalent field is named supported_alpn_protocols.
47+
alpn_protocols []string
48+
// This server's own certificate chain, leaf-first, and the long-lived
49+
// private key matching the leaf's public key -- see
50+
// ServerHandshakeParams' own doc comments (tls13_server_handshake.v)
51+
// for what accept() does NOT own here (loading these from a PEM file is
52+
// a future caller's job, same scope note that struct already states).
53+
certificate_chain []CertificateEntry
54+
signing_key ecdsa.PrivateKey
55+
}
56+
57+
// accept constructs a new server-role QuicConn from `raw_datagram` -- the
58+
// first UDP datagram of a new connection attempt, expected to contain
59+
// exactly one Initial packet carrying the client's ClientHello (this
60+
// codebase's own dial() never sends anything else in its first flight;
61+
// see the loop below for what happens if that assumption doesn't hold).
62+
// Returns the new connection AND the PollResult from processing that same
63+
// datagram through it -- typically the response flight (ServerHello under
64+
// Initial protection, EncryptedExtensions..this server's own Finished
65+
// under Handshake protection) queued in PollResult.outgoing, ready for the
66+
// caller to actually send.
67+
//
68+
// Deliberately does NOT decide whether to accept directly or send a Retry
69+
// first (RFC 9000 §8.1's address-validation policy) -- that decision needs
70+
// state (has this source address been seen before? is the anti-
71+
// amplification budget already exhausted?) that only a caller tracking
72+
// MANY connection attempts across MANY source addresses can have; a single
73+
// accept() call has no such context. 13b's encode_retry_packet/
74+
// generate_retry_token/AntiAmplificationLimiter already exist for a caller
75+
// to make and act on that decision BEFORE ever calling accept() -- wiring
76+
// them together is 13d-2's job (the UDP listener), not this constructor's.
77+
//
78+
// KNOWN SCOPE LIMIT, not fixed here: the server's Handshake-space CRYPTO
79+
// flight (EncryptedExtensions+Certificate+CertificateVerify+Finished,
80+
// dispatch_handshake_message's server bootstrap branch queues it as one
81+
// blob in pending_handshake_crypto) is flushed by drain_outgoing's
82+
// pre-existing logic as a SINGLE CRYPTO frame in a SINGLE Handshake packet
83+
// -- correct for this repo's own small test certificate, but not
84+
// fragmented across multiple packets if a real-world certificate chain
85+
// doesn't fit one packet's payload. dial()'s own ClientHello flush has the
86+
// identical shape but was never previously exercised with anything large
87+
// enough to expose it, since a ClientHello (no certificate) is always
88+
// small. Splitting a CRYPTO stream write across multiple packets is a
89+
// real, separate piece of work, not attempted here.
90+
pub fn accept(raw_datagram []u8, params AcceptParams, now u64) !(&QuicConn, PollResult) {
91+
// RFC 9000 §14.1: "A server MUST discard an Initial packet that is
92+
// carried in a UDP datagram with a payload that is smaller than the
93+
// smallest allowed maximum datagram size of 1200 bytes" -- an
94+
// anti-amplification measure: without this, a spoofed-source, undersized
95+
// trigger datagram gets a full ServerHello+EncryptedExtensions+
96+
// Certificate+CertificateVerify+Finished response flight, routinely
97+
// several times larger than the trigger, aimed at whatever address the
98+
// attacker claimed. coalesce.v's split_coalesced_datagram deliberately
99+
// does NOT enforce this itself (see its own doc comment) -- it's a
100+
// stateless, role-agnostic splitter also used for datagrams this
101+
// endpoint SENDS, where a legitimate reply smaller than 1200 bytes
102+
// (e.g. ACK-only) is allowed; that comment explicitly defers the real,
103+
// role-aware check to "a later phase with that visibility," which is
104+
// this one: the single call site that always knows a datagram reaching
105+
// it is being RECEIVED, by a SERVER, before address validation. Found
106+
// by 13d-1's adversarial review (v-quality lens).
107+
if raw_datagram.len < min_initial_datagram_size {
108+
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')
109+
}
110+
packets := split_coalesced_datagram(raw_datagram)!
111+
mut initial_header := ?QuicLongHeader(none)
112+
for p in packets {
113+
if p.form != .long {
114+
continue
115+
}
116+
h, _ := parse_long_header(p.bytes) or { continue }
117+
if h.typ == .initial {
118+
initial_header = h
119+
break
120+
}
121+
}
122+
header := initial_header or {
123+
return error('quic: accept: raw_datagram contains no Initial packet')
124+
}
125+
126+
// header.dcid is the client's freshly-chosen original_dcid -- RFC 9001
127+
// §5.2 derives Initial secrets from it, identically on both sides (the
128+
// same derive_initial_secrets dial() itself calls for the client half
129+
// of this exact derivation).
130+
initial_secrets := derive_initial_secrets(header.dcid)!
131+
initial_keys_client := derive_packet_protection_keys(initial_secrets.client)!
132+
initial_keys_server := derive_packet_protection_keys(initial_secrets.server)!
133+
134+
scid := rand.bytes(local_cid_len) or {
135+
return error('quic: accept: failed to generate this server\'s own connection ID: ${err.msg()}')
136+
}
137+
server_hello_random := rand.bytes(32) or {
138+
return error('quic: accept: failed to generate ServerHello random: ${err.msg()}')
139+
}
140+
141+
mut own_params := params.transport_parameters
142+
own_params.initial_source_connection_id = scid.clone()
143+
// RFC 9000 §7.3: a server MUST send original_destination_connection_id,
144+
// echoing the DCID the client's own Initial packet used -- the value
145+
// ServerHandshakeParams' own doc comment names as this caller's
146+
// responsibility to set (build_encrypted_extensions itself validates
147+
// it's present, but does not know what value to fill in).
148+
own_params.original_destination_connection_id = header.dcid.clone()
149+
150+
own_max_idle_timeout_ms := own_params.max_idle_timeout or { u64(0) }
151+
152+
mut c := &QuicConn{
153+
role: .server
154+
state: .handshaking
155+
original_dcid: header.dcid.clone()
156+
dcid: header.scid.clone()
157+
scid: scid
158+
peer_scid: header.scid.clone()
159+
token: []u8{}
160+
server_handshake: none
161+
server_accept_params: ServerHandshakeParams{
162+
transport_parameters: own_params
163+
supported_alpn_protocols: params.alpn_protocols
164+
certificate_chain: params.certificate_chain
165+
signing_key: params.signing_key
166+
server_hello_random: server_hello_random
167+
}
168+
handshake_completion: new_handshake_completion_state()
169+
pn_spaces: new_packet_number_spaces()
170+
initial_keys_client: initial_keys_client
171+
initial_keys_server: initial_keys_server
172+
initial_crypto: new_crypto_stream_reassembler()
173+
handshake_crypto: new_crypto_stream_reassembler()
174+
loss_detection: new_quic_loss_detection_timer()
175+
congestion_control: new_newreno_congestion_control()
176+
own_max_idle_timeout_ms: own_max_idle_timeout_ms
177+
stateless_reset: new_stateless_reset_tracker()
178+
connection_start: now
179+
own_transport_parameters: own_params
180+
streams: new_quic_stream_set(.server)
181+
conn_send_window: new_flow_control_window(0)
182+
conn_recv_window: new_receive_window(own_params.initial_max_data or { u64(0) })
183+
local_max_streams_bidi: own_params.initial_max_streams_bidi or { u64(0) }
184+
local_max_streams_uni: own_params.initial_max_streams_uni or { u64(0) }
185+
}
186+
187+
result := c.poll(raw_datagram, now)!
188+
return c, result
189+
}

0 commit comments

Comments
 (0)