diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/LevelState.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/LevelState.kt index e02df4498..58064fcab 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/LevelState.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/LevelState.kt @@ -153,4 +153,45 @@ class LevelState { this.sendProtection = sendProtection this.receiveProtection = receiveProtection } + + /** + * Reset the Initial-level state for a successful Retry. + * + * Differs from [resetForVersionNegotiation] in that the packet number + * namespace is **preserved**: per RFC 9001 §5.7 + RFC 9000 §17.2.5, + * the Initial PN space spans the original AND the post-Retry Initials. + * The client MUST NOT reuse a packet number across the boundary — + * the next Initial sent after Retry uses PN = (last-used) + 1. + * + * The runner's retry test specifically checks for this: a client that + * resets the PN gets "Client reset the packet number. Check failed + * for PN 0". + * + * Everything else resets to mirror VN semantics: + * - cryptoSend cleared so the caller can re-enqueue the cached + * ClientHello on top of fresh empty buffer state. + * - sentPackets cleared because the pre-Retry Initial's loss-recovery + * state references PNs the server will never ACK (the server + * discarded that packet when it sent the Retry). + * - keys re-derived from the new DCID and reinstalled. + * - keysDiscarded latch reset (the pre-Retry keys are gone, but the + * level itself is still alive with the new keys). + */ + internal fun resetForRetry( + sendProtection: PacketProtection, + receiveProtection: PacketProtection, + ) { + // pnSpace is INTENTIONALLY preserved — see kdoc above. + ackTracker = + com.vitorpamplona.quic.recovery + .AckTracker() + cryptoSend = SendBuffer() + cryptoReceive = ReceiveBuffer() + sentPackets.clear() + largestAckedPn = null + largestAckedSentTimeMs = null + keysDiscarded = false + this.sendProtection = sendProtection + this.receiveProtection = receiveProtection + } } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt index 2056c7779..77dc85ca8 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -579,7 +579,11 @@ class QuicConnection( destinationConnectionId = retryPacket.scid val proto = InitialSecrets.derive(destinationConnectionId.bytes) val hp = AesEcbHeaderProtection(PlatformAesOneBlock) - initial.resetForVersionNegotiation( + // Use resetForRetry, NOT resetForVersionNegotiation: RFC 9001 §5.7 + // requires the Initial PN namespace to continue across the Retry + // boundary. Resetting PN to 0 caused the runner to flag + // "Client reset the packet number. Check failed for PN 0". + initial.resetForRetry( sendProtection = PacketProtection(bestAes128GcmAead(proto.clientKey), proto.clientKey, proto.clientIv, hp, proto.clientHp), receiveProtection = diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/RetryHandlingTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/RetryHandlingTest.kt index a0bd832c8..d5e79d790 100644 --- a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/RetryHandlingTest.kt +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/RetryHandlingTest.kt @@ -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