Skip to content

Commit 47254d7

Browse files
author
Richard Wheeler
committed
net.quic: 13a - Certificate, CertificateVerify signing, Finished, HRR
Completes the message-construction half of 13a's TLS 1.3 server handshake: - encode_certificate (tls13_certificate.v): server Certificate message, reusing the existing CertificateEntry/ParsedCertificate types. certificate_ request_context is always empty per RFC 8446 SS4.4.2 ("in the case of server authentication, this field SHALL be zero length"). - encode_certificate_verify (tls13_certificate.v): signs certificate_verify_signed_content(.server, ...) via crypto.ecdsa.PrivateKey. sign() -- a pre-existing V primitive, untouched by net.quic work so far. Only sig_scheme_ecdsa_secp256r1_sha256 is wired up; RSA-PSS signing is explicitly rejected with a clear error rather than silently mis-signing -- it needs a mbedtls_pk_sign_ext V wrapper that doesn't exist yet (only the verify side, verify_rsa_pss_signature, does). - build_finished (tls13_messages.v): thin wrapper around the already side-agnostic compute_finished_verify_data. Verified against the real RFC 8448 SS3 vector, not just round-tripped against this module's own parser. - build_hello_retry_request (tls13_server_hello.v): shares ServerHello's wire type, distinguished by the fixed magic random. key_share carries a bare NamedGroup (RFC 8446 SS4.2.8's KeyShareHelloRetryRequest) -- a third, distinct wire shape from both the client's and the real-ServerHello key_share encodings. Every function round-trips through its already-existing, independently- written parse counterpart -- a real cross-check, not tautological, since build and parse were each derived from the RFC text separately. Full net.quic suite 54/54, ./vnew missdoc clean, ./vnew fmt -w applied. Certificate/CertificateVerify signature framing is verified; the produced ECDSA signature's cryptographic validity is not cross-verified against an independent verifier in this repo (no PublicKey.verify() in crypto.ecdsa, no EC certificate fixture) -- the same documented gap Phase 2c's own x509_standalone_signature_test.v already states for the identical reason. Still to come within 13a: the server-side state machine wiring these five functions into an actual handshake driver (see PROGRESS.md).
1 parent 7b45b39 commit 47254d7

7 files changed

Lines changed: 401 additions & 6 deletions

File tree

vlib/net/quic/PROGRESS.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,11 +1240,28 @@ 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: ServerHello +
1244-
EncryptedExtensions construction, Certificate presentation,
1245-
CertificateVerify **signing** (only verify exists today), server-side
1246-
Finished, HelloRetryRequest generation (cookie extension — the client
1247-
only ever rejects a second HRR, never sends one).
1243+
- [ ] **13a** (in progress) — TLS 1.3 server handshake:
1244+
- [x] Message construction, all five pieces (`tls13_server_hello.v`,
1245+
`tls13_certificate.v`, `tls13_messages.v`): `build_server_hello`,
1246+
`build_encrypted_extensions`, `encode_certificate`,
1247+
`encode_certificate_verify` (ECDSA P-256 signing only —
1248+
`sig_scheme_ecdsa_secp256r1_sha256`; RSA-PSS signing needs a
1249+
`mbedtls_pk_sign_ext` V wrapper that doesn't exist yet, only the
1250+
verify side does), `build_finished` (verified against the real RFC
1251+
8448 §3 vector, not just round-tripped against this module's own
1252+
parser), `build_hello_retry_request`. Every function round-trips
1253+
through its ALREADY-EXISTING, independently-written parse
1254+
counterpart — a real cross-check, not tautological. Caught one
1255+
real bug this way before commit: a server's `key_share` is a bare
1256+
`KeyShareEntry` (RFC 8446 §4.2.8), not the client's list-wrapped
1257+
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.
12481265
- [ ] **13b** — Retry + address validation: Retry packet + opaque token
12491266
minting/validation (`retry.v` currently only verifies), RFC 9000 §8.1
12501267
anti-amplification 3x accounting (already flagged as a deferred,

vlib/net/quic/tls13_certificate.v

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
module quic
22

3+
import crypto.ecdsa
4+
35
// CertificateEntry is one X.509 certificate plus its per-certificate
46
// extensions (RFC 8446 §4.4.2). v1 only speaks the X509 CertificateType —
57
// RawPublicKey (RFC 7250) is never negotiated (v1's EncryptedExtensions
@@ -113,6 +115,66 @@ pub fn parse_certificate(body []u8) !ParsedCertificate {
113115
}
114116
}
115117

118+
// encode_certificate constructs a complete Certificate handshake message
119+
// (RFC 8446 §4.4.2), framed via encode_handshake_message. `certificate_list`
120+
// is this server's own certificate chain, leaf-first (RFC 8446 §4.4.2's own
121+
// implicit ordering -- the peer's chain-validation walk, mirrored by this
122+
// codebase's own verify_certificate_chain, always treats the first entry as
123+
// the leaf). certificate_request_context is always encoded as empty: RFC
124+
// 8446 §4.4.2 states it is only non-empty "if this message is in response
125+
// to a CertificateRequest" -- "Otherwise (in the case of server
126+
// authentication), this field SHALL be zero length" -- and v1 is
127+
// server-authentication-only (client-cert auth is out of scope), so this
128+
// function never takes a caller-supplied context, the same scope
129+
// restriction parse_certificate's own doc comment already states for the
130+
// parse side.
131+
pub fn encode_certificate(certificate_list []CertificateEntry) ![]u8 {
132+
// RFC 8446 §4.4.2.4 (quoted in parse_certificate's own doc comment):
133+
// "the server MUST always provide a non-empty certificate_list" --
134+
// enforced here on the encode side too, not just checked on the way
135+
// back in when a peer's Certificate is parsed.
136+
if certificate_list.len == 0 {
137+
return error('quic: Certificate certificate_list must not be empty (server certificate_list MUST always be non-empty, RFC 8446 §4.4.2.4)')
138+
}
139+
140+
mut body := []u8{}
141+
body << u8(0) // certificate_request_context: always empty, see doc comment above
142+
143+
mut list := []u8{}
144+
for entry in certificate_list {
145+
if entry.cert_data.len == 0 || entry.cert_data.len > 0xff_ffff {
146+
return error('quic: CertificateEntry cert_data length ${entry.cert_data.len} out of range (opaque cert_data<1..2^24-1>)')
147+
}
148+
list << u8(entry.cert_data.len >> 16)
149+
list << u8(entry.cert_data.len >> 8)
150+
list << u8(entry.cert_data.len)
151+
list << entry.cert_data
152+
// parse_certificate's own doc comment establishes that this
153+
// client's ClientHello offers neither status_request nor
154+
// signed_certificate_timestamp, so the only RFC 8446 §4.2-legal
155+
// CertificateEntry extensions for THIS codebase's peer are illegal
156+
// to send here (RFC 8446 §4.4.2: "Extensions in the Certificate
157+
// message from the server MUST correspond to ones from the
158+
// ClientHello message") -- enforced here too, not just on the
159+
// parse side, so a caller can never accidentally construct a
160+
// message a compliant peer would reject.
161+
if entry.extensions.len != 0 {
162+
return error('quic: CertificateEntry.extensions must be empty -- this server never negotiates status_request or signed_certificate_timestamp (RFC 8446 §4.4.2)')
163+
}
164+
list << u8(0) // extensions length: always 0, see above
165+
list << u8(0)
166+
}
167+
if list.len > 0xff_ffff {
168+
return error('quic: Certificate certificate_list too large: ${list.len} bytes')
169+
}
170+
body << u8(list.len >> 16)
171+
body << u8(list.len >> 8)
172+
body << u8(list.len)
173+
body << list
174+
175+
return encode_handshake_message(.certificate, body)!
176+
}
177+
116178
pub struct ParsedCertificateVerify {
117179
pub:
118180
algorithm u16
@@ -201,3 +263,56 @@ pub fn certificate_verify_signed_content(role CertificateVerifyRole, transcript_
201263
out << transcript_hash
202264
return out
203265
}
266+
267+
// encode_certificate_verify constructs a complete CertificateVerify
268+
// handshake message (RFC 8446 §4.4.3) by SIGNING
269+
// certificate_verify_signed_content(.server, transcript_hash) with
270+
// `signing_key`, then framing the result via encode_handshake_message. v1
271+
// is server-authentication-only (client CertificateVerify is never sent),
272+
// so this function always signs the `.server` context -- see
273+
// certificate_verify_signed_content's own doc comment for why the `.client`
274+
// variant exists at all without a real caller.
275+
//
276+
// Only sig_scheme_ecdsa_secp256r1_sha256 is wired up so far: rejected with
277+
// a clear "not implemented yet" error for any other algorithm rather than
278+
// silently producing a signature under the wrong scheme -- RSA-PSS signing
279+
// needs a still-missing mbedtls_pk_sign_ext V wrapper (only the verify side,
280+
// verify_rsa_pss_signature in net.mbedtls, exists today), tracked as
281+
// follow-up work within 13a, not built here.
282+
//
283+
// `signing_key` MUST be a P-256 (prime256v1) key -- the only curve this
284+
// codebase's own key generation/loading ever produces (Phase 1's scope
285+
// decision, `crypto.ecdsa`'s CurveOptions defaults to prime256v1 and no v1
286+
// caller ever overrides it). crypto.ecdsa exposes no curve accessor to
287+
// verify this defensively at the V level; behavior for a caller-supplied
288+
// non-P-256 key is undefined by construction, not validated here -- the
289+
// same trust boundary this function's own signing_key parameter implies
290+
// for any local, non-peer-supplied cryptographic material.
291+
pub fn encode_certificate_verify(algorithm u16, signing_key ecdsa.PrivateKey, transcript_hash []u8) ![]u8 {
292+
if algorithm != sig_scheme_ecdsa_secp256r1_sha256 {
293+
return error('quic: CertificateVerify signing for algorithm 0x${algorithm:04x} is not implemented yet (only ecdsa_secp256r1_sha256 is wired up)')
294+
}
295+
296+
content := certificate_verify_signed_content(.server, transcript_hash)
297+
// PrivateKey.sign's default hash_config (.with_recommended_hash) picks
298+
// SHA-256 for a 256-bit (P-256) key -- see default_digest in
299+
// vlib/crypto/ecdsa/ecdsa.v, keyed off the key's own bit size, matching
300+
// exactly what sig_scheme_ecdsa_secp256r1_sha256 requires. The
301+
// resulting signature is OpenSSL's standard ASN.1 DER ECDSA-Sig-Value
302+
// encoding, the same format net.mbedtls's verify_ecdsa_signature (used
303+
// on the client-side verify path, tls13_certificate_chain.c.v) already
304+
// parses -- no reformatting needed between the two libraries.
305+
signature := signing_key.sign(content, hash_config: .with_recommended_hash)!
306+
307+
mut body := []u8{}
308+
body << u8(algorithm >> 8)
309+
body << u8(algorithm)
310+
if signature.len > 0xffff {
311+
return error('quic: CertificateVerify signature too large: ${signature.len} bytes')
312+
}
313+
body << u8(signature.len >> 8)
314+
body << u8(signature.len)
315+
body << signature
316+
317+
return encode_handshake_message(.certificate_verify, body)!
318+
}

vlib/net/quic/tls13_certificate_test.v

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
module quic
33

44
import encoding.hex
5+
import crypto.ecdsa
56

67
// RFC 8446 §4.4.3's own worked example: transcript hash = 32 bytes of
78
// 0x01, server context -> this exact 130-byte signed content. Extracted
@@ -220,3 +221,97 @@ fn test_parse_certificate_verify_rejects_truncated_header() {
220221
}
221222
assert false, 'expected an error for a header shorter than 4 bytes'
222223
}
224+
225+
// encode_certificate / encode_certificate_verify (Phase 13a, server-role
226+
// construction). Round-tripped through this same file's own
227+
// parse_certificate/parse_certificate_verify, the same real cross-check
228+
// discipline as tls13_server_hello_test.v's build_server_hello tests.
229+
230+
fn test_encode_certificate_round_trips_through_parse_certificate() {
231+
entries := [
232+
CertificateEntry{
233+
cert_data: []u8{len: 300, init: 0x30}
234+
},
235+
CertificateEntry{
236+
cert_data: []u8{len: 150, init: 0x31}
237+
},
238+
]
239+
msg := encode_certificate(entries)!
240+
parsed_msg, consumed := parse_handshake_message(msg)!
241+
assert consumed == msg.len
242+
assert parsed_msg.typ == .certificate
243+
result := parse_certificate(parsed_msg.body)!
244+
assert result.certificate_request_context.len == 0
245+
assert result.certificate_list.len == 2
246+
assert result.certificate_list[0].cert_data == entries[0].cert_data
247+
assert result.certificate_list[1].cert_data == entries[1].cert_data
248+
}
249+
250+
fn test_encode_certificate_rejects_empty_list() {
251+
encode_certificate([]CertificateEntry{}) or {
252+
assert err.msg().contains('must not be empty')
253+
return
254+
}
255+
assert false, 'expected an error for an empty certificate_list'
256+
}
257+
258+
fn test_encode_certificate_rejects_entry_with_extensions() {
259+
entries := [
260+
CertificateEntry{
261+
cert_data: []u8{len: 10, init: 0x30}
262+
extensions: [TlsExtension{
263+
typ: 0x1234
264+
data: []u8{}
265+
}]
266+
},
267+
]
268+
encode_certificate(entries) or {
269+
assert err.msg().contains('extensions must be empty')
270+
return
271+
}
272+
assert false, 'expected an error for a CertificateEntry carrying extensions'
273+
}
274+
275+
// test_encode_certificate_verify_round_trips_through_parse_certificate_verify
276+
// verifies the WIRE FRAMING (algorithm field, signature length prefix) is
277+
// correct and that the signature this function produces is plausible ECDSA
278+
// DER output -- non-empty, and different for different transcript hashes
279+
// (a constant/garbage signature would fail this). It does NOT
280+
// cryptographically verify the signature against the public key: this
281+
// codebase has no PublicKey.verify() exposed by crypto.ecdsa and no EC
282+
// certificate fixture to build an mbedtls_pk_context from, the SAME
283+
// documented gap Phase 2c's own x509_standalone_signature_test.v already
284+
// states ("No EC private key exists anywhere in this repo, so the ECDSA
285+
// path is tested only via rejecting an incompatible key") -- not silently
286+
// skipped, stated here for the same reason.
287+
fn test_encode_certificate_verify_round_trips_through_parse_certificate_verify() {
288+
_, priv_key := ecdsa.generate_key()!
289+
transcript_hash := []u8{len: 32, init: 0x01}
290+
291+
msg := encode_certificate_verify(sig_scheme_ecdsa_secp256r1_sha256, priv_key, transcript_hash)!
292+
parsed_msg, consumed := parse_handshake_message(msg)!
293+
assert consumed == msg.len
294+
assert parsed_msg.typ == .certificate_verify
295+
296+
result := parse_certificate_verify(parsed_msg.body)!
297+
assert result.algorithm == sig_scheme_ecdsa_secp256r1_sha256
298+
assert result.signature.len > 0
299+
300+
other_transcript_hash := []u8{len: 32, init: 0x02}
301+
other_msg := encode_certificate_verify(sig_scheme_ecdsa_secp256r1_sha256, priv_key,
302+
other_transcript_hash)!
303+
_, other_consumed := parse_handshake_message(other_msg)!
304+
other_parsed, _ := parse_handshake_message(other_msg)!
305+
other_result := parse_certificate_verify(other_parsed.body)!
306+
assert other_consumed == other_msg.len
307+
assert other_result.signature != result.signature
308+
}
309+
310+
fn test_encode_certificate_verify_rejects_unimplemented_algorithm() {
311+
_, priv_key := ecdsa.generate_key()!
312+
encode_certificate_verify(sig_scheme_rsa_pss_rsae_sha256, priv_key, []u8{len: 32}) or {
313+
assert err.msg().contains('not implemented yet')
314+
return
315+
}
316+
assert false, 'expected an error for an unimplemented signing algorithm'
317+
}

vlib/net/quic/tls13_messages.v

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,20 @@ pub fn compute_finished_verify_data(base_secret []u8, transcript_hash []u8) ![]u
117117
return hmac.new(finished_key, transcript_hash, sha256.sum256, sha256.block_size)
118118
}
119119

120+
// build_finished constructs a complete Finished handshake message (RFC 8446
121+
// §4.4.4) from `base_secret`/`transcript_hash`, framed via
122+
// encode_handshake_message. Side-agnostic like compute_finished_verify_data
123+
// itself, which this function wraps directly -- the caller picks which
124+
// traffic secret to sign with (client_handshake_traffic_secret for the
125+
// client's own Finished, server_handshake_traffic_secret for the server's)
126+
// and which transcript_hash checkpoint applies; see
127+
// compute_finished_verify_data's own doc comment for the exact checkpoint
128+
// each side uses.
129+
pub fn build_finished(base_secret []u8, transcript_hash []u8) ![]u8 {
130+
verify_data := compute_finished_verify_data(base_secret, transcript_hash)!
131+
return encode_handshake_message(.finished, verify_data)!
132+
}
133+
120134
// verify_finished checks a peer-supplied Finished message's verify_data
121135
// against the expected value computed from our own key schedule and
122136
// transcript state, using a constant-time comparison

vlib/net/quic/tls13_messages_test.v

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,19 @@ fn test_verify_finished_rejects_stale_transcript_hash() {
174174
peer_verify_data := hex.decode(rfc8448_server_verify_data)!
175175
assert verify_finished(base_secret, stale_transcript_hash, peer_verify_data)! == false
176176
}
177+
178+
// build_finished (Phase 13a, server-role construction) -- tested against
179+
// the SAME real RFC 8448 §3 vector the functions above already use, not
180+
// just round-tripped against this module's own code, since this function
181+
// is a thin wrapper around already-vector-verified
182+
// compute_finished_verify_data.
183+
fn test_build_finished_matches_rfc8448_vector() {
184+
base_secret := hex.decode(rfc8448_server_hs_traffic_finished)!
185+
transcript_hash := hex.decode(rfc8448_transcript_hash_ch_thru_certverify)!
186+
msg := build_finished(base_secret, transcript_hash)!
187+
188+
parsed_msg, consumed := parse_handshake_message(msg)!
189+
assert consumed == msg.len
190+
assert parsed_msg.typ == .finished
191+
assert parsed_msg.body == hex.decode(rfc8448_server_verify_data)!
192+
}

0 commit comments

Comments
 (0)