Skip to content

Commit 9ecb42e

Browse files
author
Richard Wheeler
committed
net.quic: 13a - server-side TLS 1.3 state machine (completes 13a)
Tls13ServerHandshake (tls13_server_handshake.v), the server-role mirror of the existing Tls13ClientHandshake. respond_to_client_hello parses and fully validates an incoming ClientHello (cipher suite, TLS 1.3 offered, secp256r1 key_share, ecdsa_secp256r1_sha256 in signature_algorithms, ALPN common protocol, and RFC 9000 SS7.3's transport-parameter role restrictions) BEFORE allocating anything, does real ECDH against the client's offered key_share, then builds the entire response flight -- ServerHello through this server's own Finished -- in one call, since nothing arrives from the peer in between. process_finished verifies the client's Finished and confirms the handshake. Needed ClientHello parsing, which didn't exist at all before this commit: added parse_client_hello, decode_alpn_offer, parse_key_share_extension_client, parse_signature_algorithms_extension_client, and parse_supported_versions_from_client to tls13_client_hello.v. Each client-vs- server wire-shape asymmetry already established for ServerHello (list-wrapped vs. bare key_share, versioned list vs. bare selected-version) recurs here in reverse, documented the same way. Verified with a real integration test, not just round-tripped against this module's own code: a genuine Tls13ClientHandshake and Tls13ServerHandshake, each independently written against the RFC text, run against each other with fresh ECDHE keys on both sides (not fixed RFC 8448 vectors). The client's own real process_server_hello/process_encrypted_extensions/verify_finished all independently accept this server's real output, and this server's process_finished accepts a real client Finished built the same way -- confirming the ECDH, key schedule, and both Finished computations genuinely agree between two separately-implemented roles. Full net.quic suite 55/55, ./vnew missdoc clean, ./vnew fmt -w applied. Not exercised: Certificate/CertificateVerify chain verification end-to-end (no EC certificate fixture in this repo -- same documented gap as encode_certificate_verify's own tests); HelloRetryRequest generation is not wired into this state machine (a key_share group mismatch is a hard failure) -- the same deliberate-defer scope choice Tls13ClientHandshake. process_server_hello already made for its own first-HRR gap. This completes 13a's stated scope in PROGRESS.md. Next: 13b (Retry + address validation).
1 parent 47254d7 commit 9ecb42e

6 files changed

Lines changed: 1144 additions & 14 deletions

File tree

vlib/net/quic/PROGRESS.md

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,7 +1240,7 @@ QUIC layer is already role-parameterized (`role QuicRole` on `QuicConn`;
12401240
explicitly) — none of that foundation needs rework. Same one-sub-phase-per-
12411241
stacked-PR convention as Phase 12's 12a-12d.
12421242

1243-
- [ ] **13a** (in progress) — TLS 1.3 server handshake:
1243+
- [x] **13a** — TLS 1.3 server handshake:
12441244
- [x] Message construction, all five pieces (`tls13_server_hello.v`,
12451245
`tls13_certificate.v`, `tls13_messages.v`): `build_server_hello`,
12461246
`build_encrypted_extensions`, `encode_certificate`,
@@ -1255,13 +1255,39 @@ stacked-PR convention as Phase 12's 12a-12d.
12551255
real bug this way before commit: a server's `key_share` is a bare
12561256
`KeyShareEntry` (RFC 8446 §4.2.8), not the client's list-wrapped
12571257
shape.
1258-
- [ ] Server-side state machine (mirroring `Tls13ClientHandshake` in
1259-
`tls13_handshake.v`) — orchestrating the above into an actual
1260-
handshake driver: deciding when to send Certificate vs. reuse
1261-
cached state, whether to send an HRR, deriving/tracking the
1262-
transcript hash across all these messages, discarding keys at the
1263-
right checkpoints. NOT started — none of the five functions above
1264-
are wired into anything yet.
1258+
- [x] Server-side state machine (`tls13_server_handshake.v`):
1259+
`Tls13ServerHandshake.respond_to_client_hello` — parses+validates a
1260+
ClientHello (cipher suite, TLS 1.3, secp256r1 key_share,
1261+
ecdsa_secp256r1_sha256 in signature_algorithms, ALPN common
1262+
protocol, RFC 9000 §7.3 transport-parameter role restrictions —
1263+
added `parse_client_hello`/`decode_alpn_offer`/
1264+
`parse_key_share_extension_client`/
1265+
`parse_signature_algorithms_extension_client`/
1266+
`parse_supported_versions_from_client` to `tls13_client_hello.v`
1267+
for this, none of which existed before), does real ECDH against
1268+
the client's offered key_share, then builds the ENTIRE response
1269+
flight (ServerHello through this server's own Finished) in one
1270+
call — RFC 8446 §7.1/Figure 3 lets application traffic secrets
1271+
derive right after the server's own Finished, no dependency on the
1272+
client's Finished arriving. `process_finished` verifies the
1273+
client's Finished and confirms the handshake. Simpler state
1274+
machine than the client's (2 states, not 6): a server never waits
1275+
on a peer message between ClientHello and its own Finished.
1276+
HelloRetryRequest is NOT wired in (group mismatch is a hard
1277+
failure) — the SAME deliberate-defer scope choice
1278+
`Tls13ClientHandshake.process_server_hello` already made for its
1279+
own first-HRR gap; `build_hello_retry_request` exists and is
1280+
unit-tested at the message layer but not yet driven by this state
1281+
machine. Verified via a REAL client-vs-server integration test
1282+
(`tls13_server_handshake_test.v`): fresh ECDHE keys on both
1283+
sides (not fixed RFC vectors), the client's own real
1284+
`process_server_hello`/`process_encrypted_extensions`/
1285+
`verify_finished` all independently accept this server's real
1286+
output, and this server's `process_finished` accepts a real client
1287+
Finished built the same way. Certificate/CertificateVerify chain
1288+
verification is NOT exercised end-to-end (no EC certificate
1289+
fixture in this repo — the same documented gap as
1290+
`encode_certificate_verify`'s own tests).
12651291
- [ ] **13b** — Retry + address validation: Retry packet + opaque token
12661292
minting/validation (`retry.v` currently only verifies), RFC 9000 §8.1
12671293
anti-amplification 3x accounting (already flagged as a deferred,

vlib/net/quic/tls13_client_hello.v

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,149 @@ fn decode_alpn_response(data []u8) !string {
289289
return list[1..].bytestr()
290290
}
291291

292+
// decode_alpn_offer parses a CLIENT's ALPN extension_data (RFC 7301 §3.1),
293+
// returning every protocol name offered, most preferred first, in wire
294+
// order. This is the multi-entry counterpart to decode_alpn_response
295+
// (this file) -- a server reads a client's full offer list with this
296+
// function, a client reads a server's single-entry selection with that
297+
// one; the two share a wire TYPE but not a wire SHAPE, the same class of
298+
// asymmetry tls13_server_hello.v documents for supported_versions/
299+
// key_share. RFC 7301 §3.1's own bounds are enforced: the list must not be
300+
// empty, and no entry may be empty (opaque ProtocolName<1..2^8-1>).
301+
pub fn decode_alpn_offer(data []u8) ![]string {
302+
if data.len < 2 {
303+
return error('quic: ALPN extension_data too short: need at least 2 bytes, have ${data.len}')
304+
}
305+
list_len := int((u32(data[0]) << 8) | u32(data[1]))
306+
if 2 + list_len != data.len {
307+
return error('quic: ALPN list_length ${list_len} does not match extension_data length ${data.len - 2}')
308+
}
309+
list := data[2..]
310+
if list.len == 0 {
311+
return error('quic: ALPN ProtocolNameList must not be empty')
312+
}
313+
mut protocols := []string{}
314+
mut cursor := 0
315+
for cursor < list.len {
316+
name_len := int(list[cursor])
317+
cursor += 1
318+
if name_len == 0 {
319+
return error('quic: ALPN protocol name must not be empty')
320+
}
321+
if cursor + name_len > list.len {
322+
return error('quic: ALPN protocol name declares ${name_len} bytes exceeding the remaining list')
323+
}
324+
protocols << list[cursor..cursor + name_len].bytestr()
325+
cursor += name_len
326+
}
327+
return protocols
328+
}
329+
330+
// parse_supported_versions_from_client parses the CLIENT-side
331+
// supported_versions payload (RFC 8446 §4.2.1): a 1-byte length prefix
332+
// followed by that many bytes of 2-byte version codepoints -- the list
333+
// shape a client OFFERS, not the server's bare 2-byte selected_version
334+
// (see parse_supported_versions_from_server, tls13_server_hello.v, for
335+
// that shape -- the same asymmetry as key_share below).
336+
fn parse_supported_versions_from_client(data []u8) ![]u16 {
337+
if data.len < 1 {
338+
return error('quic: supported_versions (client) truncated: need at least 1 byte, have ${data.len}')
339+
}
340+
list_len := int(data[0])
341+
if 1 + list_len != data.len {
342+
return error('quic: supported_versions (client) list length ${list_len} does not match remaining data ${data.len - 1}')
343+
}
344+
if list_len == 0 || list_len % 2 != 0 {
345+
return error('quic: supported_versions (client) list length ${list_len} must be a non-zero, even number of bytes')
346+
}
347+
mut versions := []u16{}
348+
mut cursor := 1
349+
for cursor < data.len {
350+
versions << u16((u32(data[cursor]) << 8) | u32(data[cursor + 1]))
351+
cursor += 2
352+
}
353+
return versions
354+
}
355+
356+
// ClientKeyShareEntry is one offered (group, key_exchange) pair from a
357+
// client's key_share extension.
358+
pub struct ClientKeyShareEntry {
359+
pub:
360+
group u16
361+
key_exchange []u8
362+
}
363+
364+
// parse_key_share_extension_client parses the CLIENT-side key_share payload
365+
// (RFC 8446 §4.2.8's KeyShareClientHello): a 2-byte client_shares length
366+
// prefix, then zero or more KeyShareEntry values (group(2) +
367+
// key_exchange_len(2) + key_exchange) -- the list shape a client OFFERS,
368+
// not a server's single bare KeyShareEntry (see
369+
// encode_key_share_extension_server, tls13_server_hello.v, for that
370+
// shape). This codebase's own build_client_hello only ever sends exactly
371+
// one entry, but the wire format itself permits any number, so this parses
372+
// the full list rather than assuming one.
373+
pub fn parse_key_share_extension_client(data []u8) ![]ClientKeyShareEntry {
374+
if data.len < 2 {
375+
return error('quic: key_share (client) truncated: need at least 2 bytes, have ${data.len}')
376+
}
377+
list_len := int((u32(data[0]) << 8) | u32(data[1]))
378+
if 2 + list_len != data.len {
379+
return error('quic: key_share (client) client_shares length ${list_len} does not match remaining data ${data.len - 2}')
380+
}
381+
mut entries := []ClientKeyShareEntry{}
382+
mut cursor := 2
383+
end := 2 + list_len
384+
for cursor < end {
385+
if end - cursor < 4 {
386+
return error('quic: key_share (client) truncated KeyShareEntry header')
387+
}
388+
group := u16((u32(data[cursor]) << 8) | u32(data[cursor + 1]))
389+
ke_len := int((u32(data[cursor + 2]) << 8) | u32(data[cursor + 3]))
390+
cursor += 4
391+
if cursor + ke_len > end {
392+
return error('quic: key_share (client) KeyShareEntry declares ${ke_len}-byte key_exchange exceeding client_shares')
393+
}
394+
if ke_len == 0 {
395+
return error('quic: key_share (client) KeyShareEntry key_exchange must not be empty (opaque key_exchange<1..2^16-1>)')
396+
}
397+
entries << ClientKeyShareEntry{
398+
group: group
399+
key_exchange: data[cursor..cursor + ke_len].clone()
400+
}
401+
cursor += ke_len
402+
}
403+
return entries
404+
}
405+
406+
// parse_signature_algorithms_extension_client parses a client's
407+
// signature_algorithms payload (RFC 8446 §4.2.3's
408+
// `SignatureScheme supported_signature_algorithms<2..2^16-2>`): a 2-byte
409+
// length prefix followed by that many bytes of 2-byte SignatureScheme
410+
// codepoints -- byte-identical framing to supported_groups's NamedGroupList
411+
// (parse_encrypted_extensions, tls13_server_hello.v, validates that inner
412+
// shape the same way), but kept as its own named function rather than a
413+
// shared generic helper, matching this module's established
414+
// one-function-per-RFC-field convention.
415+
fn parse_signature_algorithms_extension_client(data []u8) ![]u16 {
416+
if data.len < 2 {
417+
return error('quic: signature_algorithms (client) truncated: need at least 2 bytes, have ${data.len}')
418+
}
419+
list_len := int((u32(data[0]) << 8) | u32(data[1]))
420+
if 2 + list_len != data.len {
421+
return error('quic: signature_algorithms (client) list length ${list_len} does not match remaining data ${data.len - 2}')
422+
}
423+
if list_len == 0 || list_len % 2 != 0 {
424+
return error('quic: signature_algorithms (client) list length ${list_len} must be a non-zero, even number of bytes')
425+
}
426+
mut schemes := []u16{}
427+
mut cursor := 2
428+
for cursor < data.len {
429+
schemes << u16((u32(data[cursor]) << 8) | u32(data[cursor + 1]))
430+
cursor += 2
431+
}
432+
return schemes
433+
}
434+
292435
// ClientHelloParams is everything build_client_hello needs beyond what's
293436
// fixed by v1's scope decisions (single cipher suite, single named group,
294437
// a fixed signature_algorithms list).
@@ -398,3 +541,102 @@ pub fn build_client_hello(p ClientHelloParams) ![]u8 {
398541

399542
return encode_handshake_message(.client_hello, body)!
400543
}
544+
545+
// ParsedClientHello is the structural parse of a ClientHello (RFC 8446
546+
// §4.1.2), scoped like ParsedServerHello (tls13_server_hello.v): a few
547+
// RFC-mandated, caller-state-independent fields pulled out directly, plus
548+
// the raw extension list for a caller to interpret with the same
549+
// find_extension/decode_* helpers process_encrypted_extensions already
550+
// uses on the client side. cipher_suites is the FULL offered list (unlike
551+
// ParsedServerHello's single cipher_suite) -- a server must find its own
552+
// suite among possibly many, not just record what a peer already chose.
553+
pub struct ParsedClientHello {
554+
pub:
555+
random []u8
556+
cipher_suites []u16
557+
extensions []TlsExtension
558+
}
559+
560+
// parse_client_hello parses a ClientHello handshake message BODY. Validates
561+
// only what has no caller-dependent state: legacy_version, the
562+
// cipher_suites/legacy_compression_methods vector shapes, and
563+
// legacy_session_id -- RFC 9001 §8.4: "A server SHOULD treat the receipt of
564+
// a TLS ClientHello with a non-empty legacy_session_id field as a
565+
// connection error of type PROTOCOL_VIOLATION" (this file's own
566+
// build_client_hello doc comment already states the identical requirement
567+
// from the sending side; the QUIC-native PROTOCOL_VIOLATION code, not a TLS
568+
// alert, is why this uses error_with_code with quic_error_protocol_violation
569+
// directly rather than a TLS alert mapping). Extension-level semantic
570+
// validation (ALPN offered-list, key_share group, quic_transport_parameters
571+
// cross-checks, and rejecting the four server-only transport parameters a
572+
// client must never send) is the caller's job -- the same division of
573+
// labor process_encrypted_extensions already uses for EncryptedExtensions.
574+
pub fn parse_client_hello(body []u8) !ParsedClientHello {
575+
if body.len < 2 + 32 + 1 {
576+
return error('quic: truncated ClientHello: need at least 35 bytes for the fixed prefix, have ${body.len}')
577+
}
578+
if body[0] != 0x03 || body[1] != 0x03 {
579+
return error('quic: ClientHello legacy_version must be 0x0303, got 0x${body[0]:02x}${body[1]:02x}')
580+
}
581+
random := body[2..34].clone()
582+
mut cursor := 34
583+
session_id_len := int(body[cursor])
584+
cursor += 1
585+
if session_id_len != 0 {
586+
return error_with_code('quic: ClientHello legacy_session_id must be empty (RFC 9001 §8.4)',
587+
int(quic_error_protocol_violation))
588+
}
589+
590+
if body.len < cursor + 2 {
591+
return error('quic: truncated ClientHello after legacy_session_id')
592+
}
593+
suites_len := int((u32(body[cursor]) << 8) | u32(body[cursor + 1]))
594+
cursor += 2
595+
if suites_len == 0 || suites_len % 2 != 0 {
596+
return error('quic: ClientHello cipher_suites length ${suites_len} must be a non-zero, even number of bytes')
597+
}
598+
if body.len < cursor + suites_len {
599+
return error('quic: truncated ClientHello cipher_suites: declares ${suites_len} bytes, only ${body.len - cursor} remain')
600+
}
601+
mut cipher_suites := []u16{}
602+
mut suite_cursor := cursor
603+
for suite_cursor < cursor + suites_len {
604+
cipher_suites << u16((u32(body[suite_cursor]) << 8) | u32(body[suite_cursor + 1]))
605+
suite_cursor += 2
606+
}
607+
cursor += suites_len
608+
609+
if body.len < cursor + 1 {
610+
return error('quic: truncated ClientHello: missing legacy_compression_methods')
611+
}
612+
compression_len := int(body[cursor])
613+
cursor += 1
614+
if body.len < cursor + compression_len {
615+
return error('quic: truncated ClientHello legacy_compression_methods')
616+
}
617+
// RFC 8446 §4.1.2: "For every TLS 1.3 ClientHello, this vector MUST
618+
// contain exactly one byte, set to zero" -- the offering side's mirror
619+
// of parse_server_hello's fixed single-byte legacy_compression_method
620+
// check (a server only ever selects, never offers, one, so that side
621+
// has no vector wrapper at all).
622+
if compression_len != 1 || body[cursor] != 0 {
623+
return error('quic: ClientHello legacy_compression_methods must be exactly [0], got ${compression_len} bytes')
624+
}
625+
cursor += compression_len
626+
627+
if body.len < cursor + 2 {
628+
return error('quic: truncated ClientHello: missing extensions length')
629+
}
630+
extensions_len := int((u32(body[cursor]) << 8) | u32(body[cursor + 1]))
631+
cursor += 2
632+
if cursor + extensions_len != body.len {
633+
return error('quic: ClientHello extensions length ${extensions_len} does not match remaining body ${body.len - cursor}')
634+
}
635+
extensions := parse_extension_list(body[cursor..])!
636+
637+
return ParsedClientHello{
638+
random: random
639+
cipher_suites: cipher_suites
640+
extensions: extensions
641+
}
642+
}

0 commit comments

Comments
 (0)