fix(quic): preserve Initial PN namespace across Retry (RFC 9001 §5.7)
aioquic's retry test result, surfaced via qlog: Check of downloaded files succeeded. Client reset the packet number. Check failed for PN 0 Our applyRetry called LevelState.resetForVersionNegotiation, which creates a fresh PacketNumberSpaceState() — resetting PN to 0. The qlog confirmed: PN=0 sent at t=388 (pre-Retry ClientHello), then PN=0 again at t=1468 (post-Retry retried ClientHello). Same PN reused across the boundary. RFC 9001 §5.7 + RFC 9000 §17.2.5: the Initial PN namespace CONTINUES across the Retry boundary. The new Initial keys are derived from the new DCID, but PN doesn't reset. Reusing a PN under different keys makes the runner's pcap-decryption check fail (it's also a security concern in the general case, hence the strict spec rule). Fix: new LevelState.resetForRetry that's identical to resetForVersionNegotiation EXCEPT it preserves pnSpace. applyRetry calls resetForRetry. Two regression tests updated to assert the post-Retry Initial uses PN=1 (continues from PN=0 of the pre-Retry attempt) rather than PN=0 (the buggy reset behavior). For Version Negotiation the original semantics still apply (RFC 9000 §6.2: client treats VN as if the original Initial was never sent; PN reset to 0 is correct). This should bring the retry testcase from ✕(S) to ✓(S) against servers that exercise the Retry path. The handshake / transfer already succeeded over the Retry per the qlog (the server's check "Check of downloaded files succeeded." passed); only the PN-reuse flag was failing the test. https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
This commit is contained in:
@@ -142,8 +142,14 @@ class RetryHandlingTest {
|
||||
assertContentEquals(retryToken, client.retryToken)
|
||||
assertTrue(client.retryConsumed)
|
||||
|
||||
// Initial PN space reset — next allocation is 0 again.
|
||||
assertEquals(0L, client.initial.pnSpace.nextPacketNumber)
|
||||
// RFC 9001 §5.7 + RFC 9000 §17.2.5: the Initial PN namespace
|
||||
// CONTINUES across the Retry boundary. The first ClientHello at
|
||||
// PN=0 already consumed PN=0 (it was sent on the wire even though
|
||||
// the server discarded it in favor of replying with Retry). The
|
||||
// post-Retry Initial uses PN=1, not PN=0. The runner's retry
|
||||
// testcase explicitly checks this — a client that resets PN to 0
|
||||
// gets "Client reset the packet number. Check failed for PN 0".
|
||||
assertEquals(1L, client.initial.pnSpace.nextPacketNumber)
|
||||
assertEquals(-1L, client.initial.pnSpace.largestReceived)
|
||||
|
||||
// Next drain produces the retried Initial: token in header, ClientHello
|
||||
@@ -179,7 +185,10 @@ class RetryHandlingTest {
|
||||
largestReceivedInSpace = -1L,
|
||||
)
|
||||
assertNotNull(parsed, "retried Initial must decrypt under keys derived from new DCID")
|
||||
assertEquals(0L, parsed.packet.packetNumber, "retried Initial PN must be 0 (RFC 9000 §17.2.5.2)")
|
||||
// RFC 9001 §5.7: Initial PN namespace continues across Retry. The
|
||||
// pre-Retry Initial in this test already consumed PN=0, so the
|
||||
// retried Initial uses PN=1.
|
||||
assertEquals(1L, parsed.packet.packetNumber, "retried Initial PN continues from pre-Retry counter (RFC 9001 §5.7)")
|
||||
// Decoded payload starts with at least one CRYPTO frame (frame type 0x06).
|
||||
val frames =
|
||||
com.vitorpamplona.quic.frame
|
||||
|
||||
Reference in New Issue
Block a user