Skip to content

Commit bc5f108

Browse files
author
Richard Wheeler
committed
net.quic: 13c - connection ID lifecycle (NEW_CONNECTION_ID/RETIRE_CONNECTION_ID)
Adds the RFC 9000 §19.15/§19.16 wire codec for NEW_CONNECTION_ID/ RETIRE_CONNECTION_ID frames (frame.v), previously falling through parse_frame's generic "not yet implemented" branch, plus generate_stateless_reset_token (stateless_reset.v) implementing RFC 9000 §10.3.2's recommended HMAC-SHA-256(static_key, connection_id) derivation, cross-checked against the existing StatelessResetTracker.is_stateless_reset. Also updates QuicConn.dispatch_one_rtt_frame (conn.v) to explicitly acknowledge the two new frame types now flow through it instead of silently landing in the generic informational-hint else-arm, documenting why they're accepted-but-not-yet-acted-upon pending the (deliberately deferred) active-connection-ID-set state machine. Driving that active set (issuing more CIDs, active_connection_id_limit accounting) remains out of scope, same as before this commit.
1 parent d29851d commit bc5f108

6 files changed

Lines changed: 393 additions & 13 deletions

File tree

vlib/net/quic/PROGRESS.md

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1321,11 +1321,39 @@ stacked-PR convention as Phase 12's 12a-12d.
13211321
instead (same API, OS-backed, already used elsewhere in this codebase).
13221322
This is a real gap in already-merged code (Phase 9, PR #28129), not
13231323
Phase 13 work — flagged as a separate follow-up task, not fixed inline.
1324-
- [ ] **13c** — Connection ID lifecycle: `NEW_CONNECTION_ID`/
1325-
`RETIRE_CONNECTION_ID` frames (currently fall through `frame.v`'s
1326-
generic "not yet implemented" branch), stateless reset token
1327-
generation per issued CID (`StatelessResetTracker` currently only
1328-
checks incoming tokens).
1324+
- [x] **13c** — Connection ID lifecycle:
1325+
- [x] `NewConnectionIdFrame`/`RetireConnectionIdFrame` wire codec
1326+
(`frame.v`) — `encode_new_connection_id_frame`/
1327+
`parse_new_connection_id_frame` and their RETIRE_CONNECTION_ID
1328+
counterparts, types 0x18/0x19, previously falling through
1329+
`parse_frame`'s generic "not yet implemented" branch. Enforces the
1330+
two frame-local RFC 9000 §19.15 requirements (`retire_prior_to`
1331+
`sequence_number`; connection ID length in 1-20 bytes), on both the
1332+
encode and decode sides so a caller can't construct a frame this
1333+
module's own parser would then reject. Every OTHER §19.15/§19.16
1334+
requirement (zero-length-DCID prohibition, duplicate/conflicting
1335+
sequence numbers, a RETIRE_CONNECTION_ID referencing the current
1336+
packet's own DCID) needs connection state `parse_frame` doesn't
1337+
have — deferred to the caller, the same division already
1338+
established for `HandshakeDoneFrame`'s role check.
1339+
- [x] `generate_stateless_reset_token` (`stateless_reset.v`) — RFC 9000
1340+
§10.3.2's recommended construction, `HMAC-SHA-256(static_key,
1341+
connection_id)` truncated to 16 bytes: a server-instance-local
1342+
secret plus the connection ID deterministically reproduces the
1343+
SAME token, so an endpoint that has lost all per-connection state
1344+
(the entire premise of a stateless reset) can still recompute it.
1345+
Cross-checked against `StatelessResetTracker.is_stateless_reset`
1346+
(already-existing, independently-written matching logic) — proving
1347+
a token this function generates is actually recognized by the
1348+
exact code that would validate it in production.
1349+
- **Deliberately still out of scope** (per `stateless_reset.v`'s own
1350+
long-standing note, unchanged by 13c): driving an ACTIVE SET of usable
1351+
connection IDs — issuing more as the peer retires them,
1352+
`active_connection_id_limit` accounting, `CONNECTION_ID_LIMIT_ERROR`
1353+
enforcement. That full lifecycle exists to support connection
1354+
migration, which PROGRESS.md already lists as a separate, explicitly
1355+
deferrable follow-up below — 13c ships the wire codec and the token
1356+
primitive it depends on, not the state machine that would consume them.
13291357
- [ ] **13d** — UDP listener + connection demux: one socket routing many
13301358
concurrent connections by connection ID (not 4-tuple, since QUIC
13311359
supports migration) — no analog in the client's transport today; the

vlib/net/quic/conn.v

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,6 +1029,24 @@ fn (mut c QuicConn) dispatch_one_rtt_frame(frame QuicFrame, now u64, mut result
10291029
}
10301030
}
10311031
}
1032+
NewConnectionIdFrame, RetireConnectionIdFrame {
1033+
// Legal here (RFC 9000 §12.4 Table 3 marks both 1-RTT-only,
1034+
// consistent with dispatch_pre_confirm_frame's own rejection
1035+
// of them in the Initial/Handshake spaces), accepted, but not
1036+
// yet acted upon: driving an ACTIVE SET of usable connection
1037+
// IDs (issuing more as the peer retires them,
1038+
// active_connection_id_limit accounting, the §19.15/§19.16
1039+
// requirements that need that same state -- e.g. §19.16's
1040+
// "sequence number greater than any previously sent" check)
1041+
// is a deliberately deferred state machine, not yet built --
1042+
// see stateless_reset.v's doc comment and PROGRESS.md's Phase
1043+
// 13c scope note. The wire codec these frames now decode
1044+
// through (frame.v) exists so that state machine has
1045+
// something to consume once it lands; until then, a peer
1046+
// sending either is simply ignored, the same as this
1047+
// function's own CryptoFrame arm ignores post-handshake
1048+
// CRYPTO for an unimplemented feature.
1049+
}
10321050
else {
10331051
// DATA_BLOCKED/STREAM_DATA_BLOCKED/STREAMS_BLOCKED: purely
10341052
// informational hints (RFC 9000 §19.12-§19.14 impose no MUST

vlib/net/quic/frame.v

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,37 @@ pub:
211211
// analogous note).
212212
pub struct HandshakeDoneFrame {}
213213

214+
// NewConnectionIdFrame represents a NEW_CONNECTION_ID frame (type 0x18, RFC
215+
// 9000 §19.15): the sender offering an additional connection ID (and its
216+
// associated stateless-reset token, see generate_stateless_reset_token in
217+
// stateless_reset.v) for the peer to use. `retire_prior_to` is validated
218+
// against `sequence_number` (§19.15's "MUST be less than or equal to"
219+
// requirement) by both parse_frame and encode_new_connection_id_frame;
220+
// every other requirement in that section (the zero-length-DCID
221+
// prohibition, duplicate-sequence-number handling) needs connection state
222+
// parse_frame doesn't have, so -- same division as HandshakeDoneFrame's
223+
// role check above -- it is deferred to the caller.
224+
pub struct NewConnectionIdFrame {
225+
pub:
226+
sequence_number u64
227+
retire_prior_to u64
228+
connection_id []u8
229+
stateless_reset_token []u8 // always exactly 16 bytes (RFC 9000 §19.15)
230+
}
231+
232+
// RetireConnectionIdFrame represents a RETIRE_CONNECTION_ID frame (type
233+
// 0x19, RFC 9000 §19.16): the sender will no longer use the connection ID
234+
// with this sequence number. Every normative check in §19.16 (sequence
235+
// number not greater than any previously sent, not equal to the DCID of
236+
// the packet carrying this frame, not sent by an endpoint using a
237+
// zero-length CID) needs connection state parse_frame doesn't have, so --
238+
// same division as HandshakeDoneFrame's role check above -- it is deferred
239+
// to the caller.
240+
pub struct RetireConnectionIdFrame {
241+
pub:
242+
sequence_number u64
243+
}
244+
214245
pub type QuicFrame = AckFrame
215246
| ConnectionCloseFrame
216247
| CryptoFrame
@@ -219,9 +250,11 @@ pub type QuicFrame = AckFrame
219250
| MaxDataFrame
220251
| MaxStreamDataFrame
221252
| MaxStreamsFrame
253+
| NewConnectionIdFrame
222254
| PaddingFrame
223255
| PingFrame
224256
| ResetStreamFrame
257+
| RetireConnectionIdFrame
225258
| StopSendingFrame
226259
| StreamDataBlockedFrame
227260
| StreamFrame
@@ -243,6 +276,8 @@ const frame_type_data_blocked = u64(0x14)
243276
const frame_type_stream_data_blocked = u64(0x15)
244277
const frame_type_streams_blocked_bidi = u64(0x16)
245278
const frame_type_streams_blocked_uni = u64(0x17)
279+
const frame_type_new_connection_id = u64(0x18)
280+
const frame_type_retire_connection_id = u64(0x19)
246281
const frame_type_connection_close_transport = u64(0x1c)
247282
const frame_type_connection_close_application = u64(0x1d)
248283
const frame_type_handshake_done = u64(0x1e)
@@ -314,6 +349,14 @@ pub fn parse_frame(buf []u8) !(QuicFrame, int) {
314349
return parse_streams_blocked_frame(buf, typ_len, typ == frame_type_streams_blocked_uni)
315350
}
316351

352+
if typ == frame_type_new_connection_id {
353+
return parse_new_connection_id_frame(buf, typ_len)
354+
}
355+
356+
if typ == frame_type_retire_connection_id {
357+
return parse_retire_connection_id_frame(buf, typ_len)
358+
}
359+
317360
if typ == frame_type_connection_close_transport
318361
|| typ == frame_type_connection_close_application {
319362
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
598641
}), start + n1
599642
}
600643

644+
fn parse_new_connection_id_frame(buf []u8, start int) !(QuicFrame, int) {
645+
mut offset := start
646+
sequence_number, n1 := decode_varint(buf[offset..])!
647+
offset += n1
648+
retire_prior_to, n2 := decode_varint(buf[offset..])!
649+
offset += n2
650+
651+
// RFC 9000 §19.15: "The value in the Retire Prior To field MUST be
652+
// less than or equal to the value in the Sequence Number field.
653+
// Receiving a value in the Retire Prior To field that is greater than
654+
// that in the Sequence Number field MUST be treated as a connection
655+
// error of type FRAME_ENCODING_ERROR."
656+
if retire_prior_to > sequence_number {
657+
return error('quic: NEW_CONNECTION_ID frame: retire_prior_to ${retire_prior_to} exceeds sequence_number ${sequence_number} (RFC 9000 §19.15)')
658+
}
659+
660+
if offset >= buf.len {
661+
return error('quic: NEW_CONNECTION_ID frame: missing Length field')
662+
}
663+
length := int(buf[offset])
664+
offset += 1
665+
// RFC 9000 §19.15: "Values less than 1 and greater than 20 are invalid
666+
// and MUST be treated as a connection error of type
667+
// FRAME_ENCODING_ERROR." 20 is quic_v1_max_cid_len (header.v).
668+
if length < 1 || length > quic_v1_max_cid_len {
669+
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)')
670+
}
671+
if offset + length > buf.len {
672+
return error('quic: NEW_CONNECTION_ID frame: declares a ${length}-byte connection ID exceeding the remaining buffer')
673+
}
674+
connection_id := buf[offset..offset + length].clone()
675+
offset += length
676+
677+
if offset + 16 > buf.len {
678+
return error('quic: NEW_CONNECTION_ID frame: missing 16-byte stateless reset token')
679+
}
680+
stateless_reset_token := buf[offset..offset + 16].clone()
681+
offset += 16
682+
683+
return QuicFrame(NewConnectionIdFrame{
684+
sequence_number: sequence_number
685+
retire_prior_to: retire_prior_to
686+
connection_id: connection_id
687+
stateless_reset_token: stateless_reset_token
688+
}), offset
689+
}
690+
691+
fn parse_retire_connection_id_frame(buf []u8, start int) !(QuicFrame, int) {
692+
sequence_number, n1 := decode_varint(buf[start..])!
693+
return QuicFrame(RetireConnectionIdFrame{
694+
sequence_number: sequence_number
695+
}), start + n1
696+
}
697+
601698
fn parse_connection_close_frame(buf []u8, start int, is_application_error bool) !(QuicFrame, int) {
602699
mut offset := start
603700
error_code, n1 := decode_varint(buf[offset..])!
@@ -840,6 +937,38 @@ pub fn encode_streams_blocked_frame(direction StreamDirection, maximum_streams u
840937
return out
841938
}
842939

940+
// encode_new_connection_id_frame serializes a NEW_CONNECTION_ID frame.
941+
// Mirrors parse_new_connection_id_frame's exact validation (RFC 9000
942+
// §19.15) so a caller cannot construct a frame this same module's own
943+
// parser would then reject.
944+
pub fn encode_new_connection_id_frame(sequence_number u64, retire_prior_to u64, connection_id []u8, stateless_reset_token []u8) ![]u8 {
945+
if retire_prior_to > sequence_number {
946+
return error('quic: encode_new_connection_id_frame: retire_prior_to ${retire_prior_to} exceeds sequence_number ${sequence_number} (RFC 9000 §19.15)')
947+
}
948+
if connection_id.len < 1 || connection_id.len > quic_v1_max_cid_len {
949+
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)')
950+
}
951+
if stateless_reset_token.len != 16 {
952+
return error('quic: encode_new_connection_id_frame: stateless reset token must be exactly 16 bytes, got ${stateless_reset_token.len}')
953+
}
954+
mut out := encode_varint(frame_type_new_connection_id)!
955+
out << encode_varint(sequence_number)!
956+
out << encode_varint(retire_prior_to)!
957+
out << u8(connection_id.len)
958+
out << connection_id
959+
out << stateless_reset_token
960+
return out
961+
}
962+
963+
// encode_retire_connection_id_frame serializes a RETIRE_CONNECTION_ID
964+
// frame. See RetireConnectionIdFrame's doc comment for which §19.16
965+
// requirements are the caller's responsibility rather than this function's.
966+
pub fn encode_retire_connection_id_frame(sequence_number u64) ![]u8 {
967+
mut out := encode_varint(frame_type_retire_connection_id)!
968+
out << encode_varint(sequence_number)!
969+
return out
970+
}
971+
843972
// encode_connection_close_frame serializes a CONNECTION_CLOSE frame.
844973
// `frame_type` is ignored (the Frame Type field is OMITTED from the wire
845974
// entirely, not encoded as a zero value) when `is_application_error` is

vlib/net/quic/frame_test.v

Lines changed: 132 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -259,14 +259,14 @@ fn test_scaled_ack_delay_micros_saturates_at_exponent_ge_64() {
259259
}
260260

261261
fn test_parse_frame_rejects_unimplemented_frame_type() {
262-
// 0x18 (NEW_CONNECTION_ID) is a real, valid QUIC frame type this module
263-
// simply doesn't implement yet -- connection ID rotation/migration is
264-
// explicitly out of v1 scope (see stateless_reset.v's own doc comment)
265-
// -- must be a clear "not implemented" error, not a wire-format error
266-
// or a panic. (0x08 STREAM and 0x1e HANDSHAKE_DONE, both used here
267-
// before their respective phases implemented them, would no longer
268-
// demonstrate this.)
269-
parse_frame([u8(0x18)]) or {
262+
// 0x1a (PATH_CHALLENGE) is a real, valid QUIC frame type this module
263+
// simply doesn't implement yet -- connection migration is explicitly a
264+
// separate, deferrable follow-up (see PROGRESS.md's Phase 13 notes) --
265+
// must be a clear "not implemented" error, not a wire-format error or
266+
// a panic. (0x08 STREAM, 0x18 NEW_CONNECTION_ID, and 0x1e
267+
// HANDSHAKE_DONE, all used here before their respective phases
268+
// implemented them, would no longer demonstrate this.)
269+
parse_frame([u8(0x1a)]) or {
270270
assert err.msg().contains('not yet implemented')
271271
return
272272
}
@@ -736,3 +736,127 @@ fn test_streams_blocked_frame_round_trip_both_directions() {
736736
}
737737
}
738738
}
739+
740+
fn test_new_connection_id_frame_round_trip() {
741+
cid := [u8(1), 2, 3, 4, 5, 6, 7, 8]
742+
token := []u8{len: 16, init: 0xab}
743+
encoded := encode_new_connection_id_frame(3, 1, cid, token)!
744+
assert encoded[0] == 0x18
745+
frame, n := parse_frame(encoded)!
746+
assert n == encoded.len
747+
match frame {
748+
NewConnectionIdFrame {
749+
assert frame.sequence_number == 3
750+
assert frame.retire_prior_to == 1
751+
assert frame.connection_id == cid
752+
assert frame.stateless_reset_token == token
753+
}
754+
else {
755+
assert false, 'expected a NewConnectionIdFrame'
756+
}
757+
}
758+
}
759+
760+
fn test_new_connection_id_frame_round_trip_at_max_cid_length() {
761+
cid := []u8{len: quic_v1_max_cid_len, init: 0x42}
762+
token := []u8{len: 16, init: 0}
763+
encoded := encode_new_connection_id_frame(0, 0, cid, token)!
764+
frame, _ := parse_frame(encoded)!
765+
match frame {
766+
NewConnectionIdFrame {
767+
assert frame.connection_id == cid
768+
}
769+
else {
770+
assert false, 'expected a NewConnectionIdFrame'
771+
}
772+
}
773+
}
774+
775+
fn test_encode_new_connection_id_frame_rejects_retire_prior_to_above_sequence_number() {
776+
encode_new_connection_id_frame(1, 2, [u8(1)], []u8{len: 16}) or {
777+
assert err.msg().contains('retire_prior_to')
778+
return
779+
}
780+
assert false, 'expected retire_prior_to > sequence_number to be rejected'
781+
}
782+
783+
fn test_parse_new_connection_id_frame_rejects_retire_prior_to_above_sequence_number() {
784+
mut buf := encode_varint(frame_type_new_connection_id)!
785+
buf << encode_varint(u64(1))! // sequence_number
786+
buf << encode_varint(u64(2))! // retire_prior_to > sequence_number
787+
buf << u8(1)
788+
buf << [u8(0xff)]
789+
buf << []u8{len: 16}
790+
parse_frame(buf) or {
791+
assert err.msg().contains('retire_prior_to')
792+
return
793+
}
794+
assert false, 'expected retire_prior_to > sequence_number to be rejected'
795+
}
796+
797+
fn test_encode_new_connection_id_frame_rejects_zero_length_connection_id() {
798+
encode_new_connection_id_frame(0, 0, []u8{}, []u8{len: 16}) or {
799+
assert err.msg().contains('connection ID length')
800+
return
801+
}
802+
assert false, 'expected a zero-length connection ID to be rejected'
803+
}
804+
805+
fn test_encode_new_connection_id_frame_rejects_connection_id_above_20_bytes() {
806+
encode_new_connection_id_frame(0, 0, []u8{len: quic_v1_max_cid_len + 1}, []u8{len: 16}) or {
807+
assert err.msg().contains('connection ID length')
808+
return
809+
}
810+
assert false, 'expected a connection ID longer than 20 bytes to be rejected'
811+
}
812+
813+
fn test_parse_new_connection_id_frame_rejects_length_above_20_bytes() {
814+
mut buf := encode_varint(frame_type_new_connection_id)!
815+
buf << encode_varint(u64(0))!
816+
buf << encode_varint(u64(0))!
817+
buf << u8(21) // declares an invalid, over-20-byte connection ID length
818+
buf << []u8{len: 21}
819+
buf << []u8{len: 16}
820+
parse_frame(buf) or {
821+
assert err.msg().contains('connection ID length')
822+
return
823+
}
824+
assert false, 'expected a declared length above 20 bytes to be rejected'
825+
}
826+
827+
fn test_encode_new_connection_id_frame_rejects_wrong_token_length() {
828+
encode_new_connection_id_frame(0, 0, [u8(1)], []u8{len: 15}) or {
829+
assert err.msg().contains('16 bytes')
830+
return
831+
}
832+
assert false, 'expected a non-16-byte stateless reset token to be rejected'
833+
}
834+
835+
fn test_parse_new_connection_id_frame_rejects_truncated_token() {
836+
mut buf := encode_varint(frame_type_new_connection_id)!
837+
buf << encode_varint(u64(0))!
838+
buf << encode_varint(u64(0))!
839+
buf << u8(1)
840+
buf << [u8(0xff)]
841+
buf << []u8{len: 10} // short of the required 16-byte token
842+
parse_frame(buf) or {
843+
assert err.msg().contains('stateless reset token')
844+
return
845+
}
846+
assert false, 'expected a truncated stateless reset token to be rejected'
847+
}
848+
849+
fn test_retire_connection_id_frame_round_trip() {
850+
encoded := encode_retire_connection_id_frame(7)!
851+
assert encoded[0] == 0x19
852+
frame, n := parse_frame(encoded)!
853+
assert n == encoded.len
854+
match frame {
855+
RetireConnectionIdFrame {
856+
assert frame.sequence_number == 7
857+
}
858+
else {
859+
assert false, 'expected a RetireConnectionIdFrame'
860+
}
861+
}
862+
}

0 commit comments

Comments
 (0)