Skip to content

Commit d29851d

Browse files
author
Richard Wheeler
committed
net.quic: 13b - Retry packet construction, tokens, anti-amplification
Completes Phase 13b (Retry + address validation, RFC 9000 SS8/SS8.1/SS17.2.5): - encode_retry_packet (retry.v): builds a complete Retry packet, reusing compute_retry_integrity_tag directly (already side-agnostic -- no new crypto needed for the tag itself). Round-trips through the already- existing, independently-written client-role verify_retry_integrity_tag/ parse_retry_packet -- proving the exact code that will receive this in production actually accepts it, not just that it looks well-formed. - generate_retry_token/validate_retry_token/validate_retry_token_for_attempt (retry_token.v, new): AEAD-sealed (AES-128-GCM) address-validation tokens. AEAD authentication alone satisfies RFC 9000 SS8.1.4's "difficult to guess" and integrity requirements -- no separate random component needed beyond the nonce GCM itself requires. validate_retry_token_for_attempt adds the two context-dependent checks SS8.1.4 calls for: bound client address must match, and a short expiry window. NEW_TOKEN-frame issuance (SS8.1.3, tokens reusable across future connections) is out of scope -- v1 only issues tokens via Retry. Full single-use replay tracking beyond the expiry window is deferred to 13d (needs a real listening socket to own a consumed-token cache's lifetime); the short window satisfies SS8.1.4's "prevented OR limited" replay requirement in the interim. - AntiAmplificationLimiter (anti_amplification.v, new): RFC 9000 SS8.1's 3x pre-validation send limit, deliberately mirroring flow_control.v's FlowControlWindow shape. Standalone and tested; not yet wired into any datagram-processing loop, since that loop doesn't exist until 13d. Found and flagged (not fixed here, out of scope): while choosing 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 security-relevant values that should use crypto.rand instead (identical API, OS-backed, already used elsewhere in this codebase). Real gap in already-merged Phase 9 code (PR #28129), flagged as a separate follow-up task. Full net.quic suite 57/57, ./vnew missdoc clean, ./vnew fmt -w applied.
1 parent f66281d commit d29851d

7 files changed

Lines changed: 674 additions & 4 deletions

File tree

vlib/net/quic/PROGRESS.md

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1288,10 +1288,39 @@ stacked-PR convention as Phase 12's 12a-12d.
12881288
verification is NOT exercised end-to-end (no EC certificate
12891289
fixture in this repo — the same documented gap as
12901290
`encode_certificate_verify`'s own tests).
1291-
- [ ] **13b** — Retry + address validation: Retry packet + opaque token
1292-
minting/validation (`retry.v` currently only verifies), RFC 9000 §8.1
1293-
anti-amplification 3x accounting (already flagged as a deferred,
1294-
server-only branch in `loss_detection.v`/`coalesce.v`).
1291+
- [x] **13b** — Retry + address validation:
1292+
- [x] `encode_retry_packet` (`retry.v`) — builds a complete Retry packet,
1293+
reusing `compute_retry_integrity_tag` directly (already
1294+
side-agnostic). Round-trips through the already-existing,
1295+
independently-written client-role `verify_retry_integrity_tag`/
1296+
`parse_retry_packet` — the strongest cross-check available: not
1297+
just "well-formed," but "the exact code that will receive this in
1298+
production accepts it."
1299+
- [x] `generate_retry_token`/`validate_retry_token`/
1300+
`validate_retry_token_for_attempt` (`retry_token.v`, new file) —
1301+
AEAD-sealed (AES-128-GCM), authenticated address-validation tokens
1302+
satisfying RFC 9000 §8.1.4's difficult-to-guess and integrity
1303+
requirements via the AEAD tag itself. NEW_TOKEN-frame issuance
1304+
(§8.1.3, tokens reusable across future connections) is explicitly
1305+
out of scope — v1 only issues tokens via Retry. Single-use replay
1306+
tracking beyond a short expiry window is deferred to 13d, once a
1307+
real listening socket exists to own a consumed-token cache's
1308+
lifetime; a short `max_age_ms` window satisfies §8.1.4's "prevented
1309+
OR limited" replay requirement in the interim.
1310+
- [x] `AntiAmplificationLimiter` (`anti_amplification.v`, new file) —
1311+
RFC 9000 §8.1's 3x pre-validation send limit, mirroring
1312+
`flow_control.v`'s `FlowControlWindow` shape deliberately. A
1313+
standalone, tested accounting primitive — not yet wired into any
1314+
connection/datagram-processing loop, since that loop doesn't exist
1315+
until 13d.
1316+
- **Found and flagged, not fixed here (out of scope for this PR)**: while
1317+
picking a CSPRNG for the token nonce, discovered `conn.v`'s `dial()`
1318+
uses V's general-purpose `rand` module (wyrand-backed, NOT
1319+
cryptographically secure) for `original_dcid`/`scid`/`client_random`
1320+
all three are security-relevant values that should use `crypto.rand`
1321+
instead (same API, OS-backed, already used elsewhere in this codebase).
1322+
This is a real gap in already-merged code (Phase 9, PR #28129), not
1323+
Phase 13 work — flagged as a separate follow-up task, not fixed inline.
12951324
- [ ] **13c** — Connection ID lifecycle: `NEW_CONNECTION_ID`/
12961325
`RETIRE_CONNECTION_ID` frames (currently fall through `frame.v`'s
12971326
generic "not yet implemented" branch), stateless reset token

vlib/net/quic/anti_amplification.v

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
module quic
2+
3+
// AntiAmplificationLimiter enforces RFC 9000 §8's server-side send limit
4+
// before a client's address is validated: "after receiving packets from an
5+
// address that is not yet validated, an endpoint MUST limit the amount of
6+
// data it sends to the unvalidated address to three times the amount of
7+
// data received from that address." Deliberately dumb: this type only
8+
// counts bytes and answers "how many more may I send right now" -- it has
9+
// no opinion on WHEN address validation actually completes (RFC 9000 §8.1
10+
// lists three independent ways: a Handshake-protected packet was received
11+
// from the peer, the peer used a server-chosen connection ID with at least
12+
// 64 bits of entropy, or a Retry/NEW_TOKEN token validated -- deciding
13+
// which applies, and calling mark_validated() at that moment, is a future
14+
// caller's job (13d's connection-acceptance path), not this primitive's).
15+
//
16+
// Mirrors flow_control.v's FlowControlWindow/ReceiveWindow shape
17+
// deliberately -- same "dumb accounting primitive, caller decides policy"
18+
// role, just for a different RFC-mandated limit.
19+
pub struct AntiAmplificationLimiter {
20+
mut:
21+
received u64
22+
sent u64
23+
validated bool
24+
}
25+
26+
// note_received records `n` more bytes as received from this (still
27+
// possibly unvalidated) address. RFC 9000 §8.1: "servers MUST count all of
28+
// the payload bytes received in datagrams that are uniquely attributed to a
29+
// single connection. This includes datagrams that contain packets that are
30+
// successfully processed and datagrams that contain packets that are all
31+
// discarded" -- the caller must count full UDP datagram payload bytes for
32+
// every datagram attributed to this connection attempt, including ones
33+
// this endpoint ultimately drops, not just the bytes of packets it
34+
// successfully processes; this type has no visibility into that
35+
// distinction and trusts the caller's count entirely.
36+
pub fn (mut l AntiAmplificationLimiter) note_received(n u64) {
37+
l.received += n
38+
}
39+
40+
// mark_validated permanently lifts the send limit -- RFC 9000 §8.1 imposes
41+
// it only "prior to validating the client address"; once validated, this
42+
// endpoint is constrained solely by its congestion controller, a
43+
// completely separate mechanism (loss_detection.v/congestion_control.v)
44+
// this type has no relationship to. There is no corresponding "un-validate"
45+
// -- address validation, once achieved, does not lapse.
46+
pub fn (mut l AntiAmplificationLimiter) mark_validated() {
47+
l.validated = true
48+
}
49+
50+
// is_validated reports whether mark_validated has been called.
51+
pub fn (l &AntiAmplificationLimiter) is_validated() bool {
52+
return l.validated
53+
}
54+
55+
// available_to_send reports how many more bytes this endpoint may send
56+
// right now without exceeding RFC 9000 §8.1's 3x limit -- max_u64 once
57+
// validated, meaning this limit no longer applies at all, not merely that
58+
// it has become large.
59+
pub fn (l &AntiAmplificationLimiter) available_to_send() u64 {
60+
if l.validated {
61+
return max_u64
62+
}
63+
limit := l.received * 3
64+
if l.sent >= limit {
65+
return 0
66+
}
67+
return limit - l.sent
68+
}
69+
70+
// note_sent records `n` more bytes as sent to this address, failing if that
71+
// would exceed the current limit -- callers must check available_to_send()
72+
// (or catch this error) BEFORE actually sending, never discover the
73+
// violation only after the fact, the same convention
74+
// FlowControlWindow.consume() already establishes for the analogous
75+
// send-side check elsewhere in this module.
76+
pub fn (mut l AntiAmplificationLimiter) note_sent(n u64) ! {
77+
if !l.validated && l.sent + n > l.received * 3 {
78+
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})')
79+
}
80+
l.sent += n
81+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
module quic
2+
3+
fn test_anti_amplification_starts_at_zero() {
4+
lim := AntiAmplificationLimiter{}
5+
assert lim.available_to_send() == 0
6+
assert lim.is_validated() == false
7+
}
8+
9+
fn test_anti_amplification_allows_up_to_3x_received() {
10+
mut lim := AntiAmplificationLimiter{}
11+
lim.note_received(100)
12+
assert lim.available_to_send() == 300
13+
lim.note_sent(300)!
14+
assert lim.available_to_send() == 0
15+
}
16+
17+
fn test_anti_amplification_rejects_over_limit_send() {
18+
mut lim := AntiAmplificationLimiter{}
19+
lim.note_received(100)
20+
lim.note_sent(301) or {
21+
assert err.msg().contains('anti-amplification limit exceeded')
22+
return
23+
}
24+
assert false, 'expected sending 301 bytes on a 300-byte budget to fail'
25+
}
26+
27+
fn test_anti_amplification_accumulates_across_multiple_receives() {
28+
mut lim := AntiAmplificationLimiter{}
29+
lim.note_received(50)
30+
lim.note_received(50)
31+
assert lim.available_to_send() == 300
32+
}
33+
34+
fn test_anti_amplification_tracks_remaining_budget_across_sends() {
35+
mut lim := AntiAmplificationLimiter{}
36+
lim.note_received(100)
37+
lim.note_sent(120)!
38+
assert lim.available_to_send() == 180
39+
lim.note_sent(180)!
40+
assert lim.available_to_send() == 0
41+
}
42+
43+
fn test_anti_amplification_mark_validated_lifts_the_limit() {
44+
mut lim := AntiAmplificationLimiter{}
45+
lim.note_received(10)
46+
lim.note_sent(30)!
47+
assert lim.available_to_send() == 0
48+
49+
lim.mark_validated()
50+
assert lim.is_validated()
51+
assert lim.available_to_send() == max_u64
52+
// A send far exceeding any pre-validation budget must now succeed.
53+
lim.note_sent(1_000_000_000)!
54+
}
55+
56+
fn test_anti_amplification_receiving_more_raises_the_budget_even_after_a_send() {
57+
mut lim := AntiAmplificationLimiter{}
58+
lim.note_received(100)
59+
lim.note_sent(300)!
60+
assert lim.available_to_send() == 0
61+
62+
lim.note_received(50)
63+
// New limit is 3*150=450, already sent 300 -> 150 more available.
64+
assert lim.available_to_send() == 150
65+
}

vlib/net/quic/retry.v

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,84 @@ pub fn parse_retry_packet(buf []u8, original_dcid []u8, original_scid []u8) !Qui
107107
}
108108
}
109109

110+
// RetryPacketParams is everything encode_retry_packet needs.
111+
pub struct RetryPacketParams {
112+
pub:
113+
// The Source Connection ID from the client's Initial packet that
114+
// provoked this Retry -- becomes this Retry packet's OWN Destination
115+
// Connection ID (an echo, not a new value; see parse_retry_packet's
116+
// doc comment for the same field semantics on the parse side).
117+
client_scid []u8
118+
// This server's newly chosen connection ID for the retried connection
119+
// attempt -- becomes this Retry packet's Source Connection ID. RFC
120+
// 9000 §17.2.5.1: "This value MUST NOT be equal to the Destination
121+
// Connection ID field of the packet sent by the client" -- checked
122+
// below (against `original_dcid`, not `client_scid`; a Retry echoing
123+
// the client's SCID back as its own new DCID is normal, expected
124+
// behavior, not the degenerate case this MUST forbids).
125+
server_scid []u8
126+
// The Destination Connection ID from the client's Initial packet --
127+
// required for BOTH compute_retry_integrity_tag's own AAD (RFC 9001
128+
// §5.8) and as the address-validation token's own claim (see
129+
// generate_retry_token, retry_token.v).
130+
original_dcid []u8
131+
// The AEAD key this server uses for its own address-validation
132+
// tokens -- see retry_token.v's own key-length/rotation doc comments.
133+
token_key []u8
134+
// A caller-serialized identifier for the client's current source
135+
// address (IP + port), bound into the token so a later Initial
136+
// presenting it can be checked against the ADDRESS IT ARRIVES FROM --
137+
// opaque to this function and retry_token.v alike; the caller decides
138+
// the exact byte representation as long as it's used consistently
139+
// between issuance and validation.
140+
client_addr []u8
141+
// A caller-supplied monotonic timestamp (matching this module's
142+
// existing time.sys_mono_now()-sourced convention, e.g.
143+
// idle_timeout.v's `now u64` parameters) recording when this token was
144+
// issued, for the short-expiry check RFC 9000 §8.1.4 recommends
145+
// ("SHOULD ensure that tokens sent in Retry packets are only accepted
146+
// for a short time").
147+
issued_at_ms u64
148+
}
149+
150+
// encode_retry_packet constructs a complete Retry packet (RFC 9000
151+
// §17.2.5), including a fresh address-validation token (generate_retry_token,
152+
// retry_token.v) and the Retry Integrity Tag (compute_retry_integrity_tag,
153+
// this file -- already side-agnostic, reused directly rather than
154+
// duplicated). The header's Unused 4 bits (RFC 9000 §17.2.5, Figure 18) are
155+
// set to zero: "The value in the Unused field is set to an arbitrary value
156+
// by the server; a client MUST ignore these bits" -- zero is as arbitrary
157+
// as any other value and keeps the packet deterministic for testing.
158+
pub fn encode_retry_packet(p RetryPacketParams) ![]u8 {
159+
// RFC 9000 §17.2.5.1: "This value MUST NOT be equal to the Destination
160+
// Connection ID field of the packet sent by the client" -- a Retry
161+
// violating this would be silently discarded by any compliant client
162+
// (parse_retry_packet's own anti-loop check, this file), so refusing
163+
// to construct one here catches a caller's CID-generation bug before
164+
// it produces a packet that could never actually complete a handshake.
165+
if p.server_scid == p.original_dcid {
166+
return error("quic: Retry server_scid must not equal the client's original Initial dcid (RFC 9000 §17.2.5.1)")
167+
}
168+
169+
token := generate_retry_token(p.token_key, RetryTokenClaims{
170+
client_addr: p.client_addr
171+
original_dcid: p.original_dcid
172+
issued_at_ms: p.issued_at_ms
173+
})!
174+
175+
header := QuicLongHeader{
176+
typ: .retry
177+
version: quic_v1
178+
dcid: p.client_scid
179+
scid: p.server_scid
180+
}
181+
mut packet := encode_long_header(header, 0, 0)!
182+
packet << token
183+
tag := compute_retry_integrity_tag(p.original_dcid, packet)!
184+
packet << tag
185+
return packet
186+
}
187+
110188
// compute_retry_integrity_tag computes the expected 16-byte Retry
111189
// Integrity Tag (RFC 9001 §5.8) given the ORIGINAL destination connection
112190
// ID the client used in the Initial packet that provoked this Retry

vlib/net/quic/retry_test.v

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,58 @@ fn test_verify_retry_integrity_tag_discards_when_already_processed_other_packet(
193193
ok := verify_retry_integrity_tag(original_dcid, packet, true)!
194194
assert ok == false
195195
}
196+
197+
// encode_retry_packet (Phase 13b, server-role construction). Round-tripped
198+
// through this same file's own verify_retry_integrity_tag/
199+
// parse_retry_packet -- the client-role functions, written and tested in an
200+
// earlier phase, completely independently of this server-role code. This
201+
// is the strongest possible cross-check available in this module: it isn't
202+
// merely "does this look like a well-formed packet," it's "does the exact
203+
// client code that will actually receive this in production accept it."
204+
205+
fn test_encode_retry_packet_round_trips_through_client_verification() {
206+
client_scid := [u8(0x55), 0x66, 0x77, 0x88]
207+
original_dcid := [u8(0xaa), 0xbb, 0xcc, 0xdd]
208+
server_scid := [u8(9), 10, 11, 12]
209+
key := []u8{len: retry_token_key_len, init: 0x42}
210+
client_addr := [u8(192), 168, 1, 1, 0x1f, 0x90]
211+
212+
packet := encode_retry_packet(
213+
client_scid: client_scid
214+
server_scid: server_scid
215+
original_dcid: original_dcid
216+
token_key: key
217+
client_addr: client_addr
218+
issued_at_ms: 500
219+
)!
220+
221+
ok := verify_retry_integrity_tag(original_dcid, packet, false)!
222+
assert ok
223+
parsed := parse_retry_packet(packet, original_dcid, client_scid)!
224+
assert parsed.dcid == client_scid
225+
assert parsed.scid == server_scid
226+
assert parsed.retry_token.len > 0
227+
228+
// The retried Initial's token is exactly parsed.retry_token -- validate
229+
// it the way the server's own future connection-acceptance path would.
230+
claims := validate_retry_token_for_attempt(key, parsed.retry_token, client_addr, 500, 30000)!
231+
assert claims.original_dcid == original_dcid
232+
assert claims.client_addr == client_addr
233+
assert claims.issued_at_ms == 500
234+
}
235+
236+
fn test_encode_retry_packet_rejects_server_scid_equal_to_original_dcid() {
237+
original_dcid := [u8(0xaa), 0xbb, 0xcc, 0xdd]
238+
encode_retry_packet(
239+
client_scid: [u8(1), 2, 3, 4]
240+
server_scid: original_dcid // degenerate: RFC 9000 §17.2.5.1 forbids this
241+
original_dcid: original_dcid
242+
token_key: []u8{len: retry_token_key_len}
243+
client_addr: [u8(1)]
244+
issued_at_ms: 0
245+
) or {
246+
assert err.msg().contains('must not equal')
247+
return
248+
}
249+
assert false, 'expected an error when server_scid equals original_dcid'
250+
}

0 commit comments

Comments
 (0)