feat(quic): RFC 9000 §8.2 server-initiated path validation (PATH_CHALLENGE / PATH_RESPONSE)
Soak target #4 — minimum viable path validation. Lands the spec-required peer-initiated case so a server probing the path (e.g. after a NAT rebind, or post-CID rotation) sees a matching PATH_RESPONSE and doesn't declare the path dead. Pre-fix the parser decoded PATH_CHALLENGE / PATH_RESPONSE bytes but threw the result away — a peer's challenge went silently into the void. After ~3 RTT of no response, a strict peer would mark the path dead and tear the connection down (visible to audio-rooms users as a sudden cut on a phone that briefly switched cells). Implementation: - Add PathChallengeFrame / PathResponseFrame data classes; wire decode and encode (was decode-and-discard previously). - Add `pendingPathResponses` queue on QuicConnection (bounded at MAX_PENDING_PATH_RESPONSES = 64 to defend against challenge flood; excess silently dropped — peer retries on PTO). - Parser handler queues a response on inbound PATH_CHALLENGE. - Writer drains the queue in buildApplicationPacket. RFC 9000 §13.3 doesn't list PATH_RESPONSE as ack-eliciting-and- retransmittable; if a response is lost, the peer's next PATH_CHALLENGE re-queues it and we respond again. Out of scope for this landing (multi-day each, parked unless production evidence requires): - Client-initiated migration: requires UdpSocket replacement, new-CID acquisition tracking, validating new path BEFORE moving traffic to it. - Anti-amplification on unvalidated paths (RFC 9000 §8.1). Tests (PathValidationTest, 6 cases): - PATH_CHALLENGE / PATH_RESPONSE codec round-trip + 8-byte length validation. - End-to-end: peer PATH_CHALLENGE → client PATH_RESPONSE with byte-equal payload. - Multi-challenge fan-in: 3 challenges → 3 distinct responses (in any order; matched by content). - Flood cap: 256 challenges → ≤ MAX_PENDING_PATH_RESPONSES responses, connection stays CONNECTED. https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec
This commit is contained in:
@@ -480,6 +480,31 @@ class QuicConnection(
|
||||
internal val pendingNewConnectionId:
|
||||
MutableMap<Long, com.vitorpamplona.quic.connection.recovery.RecoveryToken.NewConnectionId> = HashMap()
|
||||
|
||||
/**
|
||||
* RFC 9000 §8.2.2: queue of inbound PATH_CHALLENGE payloads we
|
||||
* still owe a PATH_RESPONSE for. Each entry is the EXACT 8 bytes
|
||||
* the peer challenged with — the response MUST echo them
|
||||
* unchanged. The writer drains this queue on the next
|
||||
* application-level packet build, emitting one PATH_RESPONSE
|
||||
* per entry until empty.
|
||||
*
|
||||
* Bounded at [MAX_PENDING_PATH_RESPONSES] to defend against an
|
||||
* attacker spamming PATH_CHALLENGE frames to exhaust memory.
|
||||
* Excess challenges are dropped; the protocol allows it (a peer
|
||||
* that doesn't get a response just retries).
|
||||
*
|
||||
* RFC 9000 §8.2.1 nuance: the response MUST go out on the
|
||||
* incoming-packet's path. We model a single path today (one
|
||||
* UDP socket per connection — see [com.vitorpamplona.quic.transport.UdpSocket]'s
|
||||
* "no migration" kdoc), so "path the challenge arrived on" is
|
||||
* trivially "the only path." When client-initiated migration
|
||||
* lands, this queue grows to remember which path each entry
|
||||
* belongs to.
|
||||
*
|
||||
* Caller must hold [streamsLock] for any read/write.
|
||||
*/
|
||||
internal val pendingPathResponses: ArrayDeque<ByteArray> = ArrayDeque()
|
||||
|
||||
/**
|
||||
* RFC 9002 RTT estimator + loss-detection algorithm. Single
|
||||
* shared instance per connection (RTT is per-path; we model a
|
||||
@@ -1856,6 +1881,29 @@ class QuicConnection(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a PATH_RESPONSE for the given [challengeData]. Called by
|
||||
* the parser when a PATH_CHALLENGE arrives. Idempotent on
|
||||
* duplicate challenges (peer retransmit) — the writer drains
|
||||
* each entry exactly once, so an over-eager peer that sends
|
||||
* many PATH_CHALLENGEs gets many PATH_RESPONSEs (RFC 9000 §8.2
|
||||
* permits this; the spec just requires at-least-one).
|
||||
*
|
||||
* The queue is bounded at [MAX_PENDING_PATH_RESPONSES]; excess
|
||||
* entries are silently dropped (the protocol allows the peer
|
||||
* to time out and retry).
|
||||
*
|
||||
* Caller must hold [streamsLock].
|
||||
*/
|
||||
internal fun queuePathResponseLocked(challengeData: ByteArray) {
|
||||
if (pendingPathResponses.size >= MAX_PENDING_PATH_RESPONSES) return
|
||||
// Defensive copy: the parser hands us a slice of the inbound
|
||||
// packet's plaintext payload, which the parser may free /
|
||||
// reuse after we return. Copying preserves the bytes for the
|
||||
// writer to encode later.
|
||||
pendingPathResponses.addLast(challengeData.copyOf())
|
||||
}
|
||||
|
||||
/**
|
||||
* True if [streamId] has been retired *and* is still inside the
|
||||
* ring's eviction window. Used by [QuicConnectionParser] to drop
|
||||
@@ -2072,6 +2120,19 @@ class QuicConnection(
|
||||
* the per-stream object size we're saving by retiring.
|
||||
*/
|
||||
const val RETIRED_STREAM_ID_RING_SIZE: Int = 4_096
|
||||
|
||||
/**
|
||||
* Bound on the [pendingPathResponses] queue. RFC 9000 §8.2
|
||||
* doesn't cap PATH_CHALLENGE rate, so a malicious peer could
|
||||
* spam them to exhaust our memory. 64 entries × 8 bytes = 512 B
|
||||
* worst case — trivial to absorb but tight enough that an
|
||||
* attacker can't pin 100 MB by flooding.
|
||||
*
|
||||
* Excess challenges are dropped; the spec allows it (a peer
|
||||
* that doesn't see a response will retransmit on the next
|
||||
* PTO if path validation matters to them).
|
||||
*/
|
||||
const val MAX_PENDING_PATH_RESPONSES: Int = 64
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ import com.vitorpamplona.quic.frame.MaxStreamDataFrame
|
||||
import com.vitorpamplona.quic.frame.MaxStreamsFrame
|
||||
import com.vitorpamplona.quic.frame.NewConnectionIdFrame
|
||||
import com.vitorpamplona.quic.frame.NewTokenFrame
|
||||
import com.vitorpamplona.quic.frame.PathChallengeFrame
|
||||
import com.vitorpamplona.quic.frame.PathResponseFrame
|
||||
import com.vitorpamplona.quic.frame.PingFrame
|
||||
import com.vitorpamplona.quic.frame.ResetStreamFrame
|
||||
import com.vitorpamplona.quic.frame.StopSendingFrame
|
||||
@@ -710,6 +712,39 @@ private fun dispatchFrames(
|
||||
ackEliciting = true
|
||||
}
|
||||
|
||||
is PathChallengeFrame -> {
|
||||
// RFC 9000 §8.2.2 — peer is validating that a path is
|
||||
// alive. We MUST echo the SAME 8-byte payload in a
|
||||
// PATH_RESPONSE on the path the challenge arrived on.
|
||||
// The writer drains [pendingPathResponses] on the next
|
||||
// application-level packet build.
|
||||
//
|
||||
// Common practical trigger: server-side path
|
||||
// validation after our connection-id rotation, OR
|
||||
// post-NAT-rebind probing. Without responding the
|
||||
// server may declare the path dead within a few RTTs
|
||||
// and tear the connection down — visible to users as
|
||||
// a sudden audio cut on a phone that briefly
|
||||
// switched cells.
|
||||
//
|
||||
// RFC 9000 §13.2.1: PATH_CHALLENGE is ack-eliciting.
|
||||
// The PATH_RESPONSE we queue here is itself
|
||||
// ack-eliciting; the regular ACK path covers both.
|
||||
ackEliciting = true
|
||||
conn.queuePathResponseLocked(frame.data)
|
||||
}
|
||||
|
||||
is PathResponseFrame -> {
|
||||
// RFC 9000 §13.2.1: PATH_RESPONSE is ack-eliciting.
|
||||
// We don't yet issue PATH_CHALLENGE ourselves (that's
|
||||
// the client-initiated migration path, out of scope
|
||||
// for the first-pass landing here), so any PATH_RESPONSE
|
||||
// we receive is necessarily for a challenge we never
|
||||
// sent — drop it after marking ack-eliciting so the
|
||||
// outbound ACK still goes out.
|
||||
ackEliciting = true
|
||||
}
|
||||
|
||||
is ConnectionCloseFrame -> {
|
||||
// Audit-4 #13: any frames following CONNECTION_CLOSE in the
|
||||
// same payload MUST NOT be dispatched — they could create
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.vitorpamplona.quic.frame.MaxDataFrame
|
||||
import com.vitorpamplona.quic.frame.MaxStreamDataFrame
|
||||
import com.vitorpamplona.quic.frame.MaxStreamsFrame
|
||||
import com.vitorpamplona.quic.frame.NewConnectionIdFrame
|
||||
import com.vitorpamplona.quic.frame.PathResponseFrame
|
||||
import com.vitorpamplona.quic.frame.PingFrame
|
||||
import com.vitorpamplona.quic.frame.ResetStreamFrame
|
||||
import com.vitorpamplona.quic.frame.StopSendingFrame
|
||||
@@ -949,6 +950,18 @@ private fun appendFlowControlUpdates(
|
||||
}
|
||||
}
|
||||
|
||||
// PATH_RESPONSE — drain every pending challenge response in one
|
||||
// pass. RFC 9000 §13.3 is silent on retransmission for
|
||||
// PATH_RESPONSE: it's NOT in the ack-eliciting-and-retransmittable
|
||||
// class, so we don't track tokens for these. If the response
|
||||
// packet is lost, the peer's next PATH_CHALLENGE retry queues a
|
||||
// fresh entry here and we respond again. The peer is responsible
|
||||
// for retrying its challenge until it sees a matching response.
|
||||
while (conn.pendingPathResponses.isNotEmpty()) {
|
||||
val data = conn.pendingPathResponses.removeFirst()
|
||||
frames += PathResponseFrame(data)
|
||||
}
|
||||
|
||||
// NEW_CONNECTION_ID retransmits. No application path emits these
|
||||
// initially today (connection-ID rotation isn't wired); the map
|
||||
// is populated only by the loss dispatcher, so this branch only
|
||||
|
||||
@@ -313,6 +313,54 @@ class NewConnectionIdFrame(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9000 §19.17 — PATH_CHALLENGE frame, used for path validation
|
||||
* (§8.2). The 8-byte [data] payload is opaque random bytes the
|
||||
* sender uses to bind a PATH_RESPONSE back to a specific
|
||||
* challenge. The receiver MUST echo the SAME 8 bytes in a
|
||||
* [PathResponseFrame] on the path the challenge arrived on.
|
||||
*
|
||||
* Path validation lets either endpoint confirm the peer can still
|
||||
* receive on a 4-tuple: the most common practical use is the
|
||||
* server probing the client after a NAT rebind / connection
|
||||
* migration. Without responding, the server may declare the path
|
||||
* dead and tear the connection down — visible to users as a
|
||||
* sudden audio cut on a phone that briefly switched cells.
|
||||
*/
|
||||
class PathChallengeFrame(
|
||||
val data: ByteArray,
|
||||
) : Frame() {
|
||||
init {
|
||||
require(data.size == 8) { "PATH_CHALLENGE data must be exactly 8 bytes per RFC 9000 §19.17" }
|
||||
}
|
||||
|
||||
override fun encode(out: QuicWriter) {
|
||||
out.writeByte(FrameType.PATH_CHALLENGE.toInt())
|
||||
out.writeBytes(data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9000 §19.18 — PATH_RESPONSE frame, the reply to a
|
||||
* [PathChallengeFrame]. Carries the EXACT same 8-byte payload back.
|
||||
* The challenger uses byte-equality to match a response to its
|
||||
* outstanding challenge — a peer that echoes random bytes would
|
||||
* pass validation, so callers that issue PATH_CHALLENGE MUST use
|
||||
* a cryptographically-random payload.
|
||||
*/
|
||||
class PathResponseFrame(
|
||||
val data: ByteArray,
|
||||
) : Frame() {
|
||||
init {
|
||||
require(data.size == 8) { "PATH_RESPONSE data must be exactly 8 bytes per RFC 9000 §19.18" }
|
||||
}
|
||||
|
||||
override fun encode(out: QuicWriter) {
|
||||
out.writeByte(FrameType.PATH_RESPONSE.toInt())
|
||||
out.writeBytes(data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a stream of frames from [data]. Padding bytes (0x00) are silently
|
||||
* absorbed. Unknown frame types raise [QuicCodecException] (per RFC 9000 §19
|
||||
@@ -445,11 +493,11 @@ fun decodeFrames(data: ByteArray): List<Frame> {
|
||||
}
|
||||
|
||||
type == FrameType.PATH_CHALLENGE -> {
|
||||
r.readBytes(8)
|
||||
out += PathChallengeFrame(r.readBytes(8))
|
||||
}
|
||||
|
||||
type == FrameType.PATH_RESPONSE -> {
|
||||
r.readBytes(8)
|
||||
out += PathResponseFrame(r.readBytes(8))
|
||||
}
|
||||
|
||||
type == FrameType.CONNECTION_CLOSE_TRANSPORT -> {
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quic.connection
|
||||
|
||||
import com.vitorpamplona.quic.frame.PathChallengeFrame
|
||||
import com.vitorpamplona.quic.frame.PathResponseFrame
|
||||
import com.vitorpamplona.quic.frame.decodeFrames
|
||||
import com.vitorpamplona.quic.frame.encodeFrames
|
||||
import com.vitorpamplona.quic.tls.InProcessTlsServer
|
||||
import com.vitorpamplona.quic.tls.PermissiveCertificateValidator
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* RFC 9000 §8.2 path validation — minimum-viable peer-initiated case.
|
||||
*
|
||||
* Soak target #4 from the audio-rooms hardening pass: support
|
||||
* the spec-required PATH_CHALLENGE / PATH_RESPONSE round-trip.
|
||||
*
|
||||
* Scope of this landing:
|
||||
* - Frame codec for PATH_CHALLENGE (0x1A) and PATH_RESPONSE
|
||||
* (0x1B) — was previously decoded-and-discarded.
|
||||
* - Server-initiated path validation: peer sends
|
||||
* PATH_CHALLENGE; client MUST echo the SAME 8-byte payload
|
||||
* in a PATH_RESPONSE on the next outbound packet.
|
||||
*
|
||||
* Out of scope (explicit follow-on):
|
||||
* - Client-initiated migration: requires UdpSocket replacement,
|
||||
* new-CID acquisition tracking, and validating the new path
|
||||
* BEFORE moving traffic to it.
|
||||
* - Anti-amplification on unvalidated paths (RFC 9000 §8.1).
|
||||
*
|
||||
* Why this matters even without client-initiated migration: any
|
||||
* compliant peer (server or middlebox) MAY probe the path at any
|
||||
* time — most commonly after a NAT rebind or our connection-id
|
||||
* rotation. A peer that doesn't see a PATH_RESPONSE within a few
|
||||
* RTTs may declare the path dead and tear the connection down,
|
||||
* visible to users as a sudden audio cut on a phone that briefly
|
||||
* switched cells.
|
||||
*/
|
||||
class PathValidationTest {
|
||||
@Test
|
||||
fun pathChallengeFrameRoundTripsThroughCodec() {
|
||||
val payload = byteArrayOf(0x01, 0x23, 0x45, 0x67, -0x77, -0x55, -0x33, -0x11)
|
||||
val encoded = encodeFrames(listOf(PathChallengeFrame(payload)))
|
||||
val decoded = decodeFrames(encoded)
|
||||
assertEquals(1, decoded.size)
|
||||
val frame = decoded.first() as PathChallengeFrame
|
||||
assertContentEquals(
|
||||
payload,
|
||||
frame.data,
|
||||
"PATH_CHALLENGE codec must round-trip the 8-byte payload byte-for-byte",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pathResponseFrameRoundTripsThroughCodec() {
|
||||
val payload = byteArrayOf(-0x80, 0x7F, 0x00, -0x01, 0x55, -0x56, 0x42, -0x43)
|
||||
val encoded = encodeFrames(listOf(PathResponseFrame(payload)))
|
||||
val decoded = decodeFrames(encoded)
|
||||
assertEquals(1, decoded.size)
|
||||
val frame = decoded.first() as PathResponseFrame
|
||||
assertContentEquals(payload, frame.data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pathChallengeFrameRejectsNonEightByteData() {
|
||||
try {
|
||||
PathChallengeFrame(ByteArray(7))
|
||||
error("PATH_CHALLENGE constructor must reject < 8 bytes")
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// expected
|
||||
}
|
||||
try {
|
||||
PathChallengeFrame(ByteArray(9))
|
||||
error("PATH_CHALLENGE constructor must reject > 8 bytes")
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun inboundPathChallengeQueuesMatchingPathResponse() =
|
||||
runBlocking {
|
||||
val (client, pipe) = newConnectedClient()
|
||||
val challengeData = byteArrayOf(0xCA.toByte(), 0xFE.toByte(), 0xBA.toByte(), 0xBE.toByte(), 0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte())
|
||||
|
||||
// Server sends a PATH_CHALLENGE in a 1-RTT packet. Pre-fix
|
||||
// the client would silently absorb it — the connection
|
||||
// would stay CONNECTED but the peer would never see a
|
||||
// PATH_RESPONSE, eventually declaring the path dead.
|
||||
val packet = pipe.buildServerApplicationDatagram(listOf(PathChallengeFrame(challengeData)))!!
|
||||
feedDatagram(client, packet, nowMillis = 0L)
|
||||
|
||||
// Drain the next outbound packet and verify it carries a
|
||||
// PATH_RESPONSE with the EXACT same 8 bytes.
|
||||
val outbound = drainOutbound(client, nowMillis = 0L)
|
||||
assertTrue(outbound != null, "client must emit an outbound packet (ACK + PATH_RESPONSE)")
|
||||
val frames = pipe.decryptClientApplicationFrames(outbound)
|
||||
assertTrue(frames != null, "outbound packet must decrypt with the live application keys")
|
||||
val response = frames.firstOrNull { it is PathResponseFrame } as? PathResponseFrame
|
||||
assertTrue(response != null, "outbound packet must contain a PATH_RESPONSE — got ${frames.map { it::class.simpleName }}")
|
||||
assertContentEquals(
|
||||
challengeData,
|
||||
response.data,
|
||||
"PATH_RESPONSE MUST echo the PATH_CHALLENGE payload exactly (byte equality is " +
|
||||
"the discriminator the peer uses to match a response to its outstanding challenge)",
|
||||
)
|
||||
assertEquals(QuicConnection.Status.CONNECTED, client.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleQueuedPathChallengesDrainAsMultipleResponses() =
|
||||
runBlocking {
|
||||
// RFC 9000 §8.2: a peer MAY send several PATH_CHALLENGEs
|
||||
// (e.g. validation retries on packet loss). Each one
|
||||
// requires its own PATH_RESPONSE — a single response
|
||||
// doesn't subsume earlier ones because the payload bytes
|
||||
// are independent random values.
|
||||
val (client, pipe) = newConnectedClient()
|
||||
val challenges =
|
||||
listOf(
|
||||
ByteArray(8) { 0x10.toByte() },
|
||||
ByteArray(8) { 0x20.toByte() },
|
||||
ByteArray(8) { 0x30.toByte() },
|
||||
)
|
||||
for (data in challenges) {
|
||||
val packet = pipe.buildServerApplicationDatagram(listOf(PathChallengeFrame(data)))!!
|
||||
feedDatagram(client, packet, nowMillis = 0L)
|
||||
}
|
||||
|
||||
// Drain all outbound packets and collect every PATH_RESPONSE
|
||||
// we see. The writer can fold all three into one packet
|
||||
// (each is just 9 wire bytes) but might also split — either
|
||||
// shape is spec-correct.
|
||||
val responses = mutableListOf<PathResponseFrame>()
|
||||
while (true) {
|
||||
val out = drainOutbound(client, nowMillis = 0L) ?: break
|
||||
val frames = pipe.decryptClientApplicationFrames(out) ?: continue
|
||||
for (f in frames) {
|
||||
if (f is PathResponseFrame) responses += f
|
||||
}
|
||||
}
|
||||
assertEquals(
|
||||
challenges.size,
|
||||
responses.size,
|
||||
"client must emit exactly one PATH_RESPONSE per inbound PATH_CHALLENGE",
|
||||
)
|
||||
// The responses can arrive in any order; match by content.
|
||||
val responseSet = responses.map { it.data.toList() }.toSet()
|
||||
val challengeSet = challenges.map { it.toList() }.toSet()
|
||||
assertEquals(
|
||||
challengeSet,
|
||||
responseSet,
|
||||
"every challenge payload must appear in some response",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pathResponseQueueIsBoundedAgainstChallengeFlood() =
|
||||
runBlocking {
|
||||
// Defence-in-depth: an attacker spamming PATH_CHALLENGE
|
||||
// shouldn't pin arbitrary memory in our pendingPathResponses
|
||||
// queue. Cap is MAX_PENDING_PATH_RESPONSES (64); excess
|
||||
// challenges are silently dropped — the protocol allows
|
||||
// it (peer would retransmit on PTO if a response actually
|
||||
// mattered).
|
||||
val (client, pipe) = newConnectedClient()
|
||||
val flood = QuicConnection.MAX_PENDING_PATH_RESPONSES * 4
|
||||
for (i in 0 until flood) {
|
||||
val data = ByteArray(8) { ((i shr 8) and 0xFF).toByte() }
|
||||
data[7] = (i and 0xFF).toByte()
|
||||
val packet = pipe.buildServerApplicationDatagram(listOf(PathChallengeFrame(data)))!!
|
||||
feedDatagram(client, packet, nowMillis = 0L)
|
||||
}
|
||||
|
||||
val responses = mutableListOf<PathResponseFrame>()
|
||||
while (true) {
|
||||
val out = drainOutbound(client, nowMillis = 0L) ?: break
|
||||
val frames = pipe.decryptClientApplicationFrames(out) ?: continue
|
||||
for (f in frames) {
|
||||
if (f is PathResponseFrame) responses += f
|
||||
}
|
||||
}
|
||||
assertTrue(
|
||||
responses.size <= QuicConnection.MAX_PENDING_PATH_RESPONSES,
|
||||
"response count ${responses.size} must not exceed cap " +
|
||||
"${QuicConnection.MAX_PENDING_PATH_RESPONSES}",
|
||||
)
|
||||
// And: connection survives the flood.
|
||||
assertEquals(QuicConnection.Status.CONNECTED, client.status)
|
||||
}
|
||||
|
||||
private fun newConnectedClient(): Pair<QuicConnection, InMemoryQuicPipe> =
|
||||
runBlocking {
|
||||
val client =
|
||||
QuicConnection(
|
||||
serverName = "path.test",
|
||||
config = QuicConnectionConfig(),
|
||||
tlsCertificateValidator = PermissiveCertificateValidator(),
|
||||
)
|
||||
val serverScid = ConnectionId.random(8)
|
||||
val tlsServer =
|
||||
InProcessTlsServer(
|
||||
transportParameters =
|
||||
TransportParameters(
|
||||
initialMaxData = 1L * 1024 * 1024,
|
||||
initialMaxStreamDataBidiLocal = 64L * 1024,
|
||||
initialMaxStreamDataBidiRemote = 64L * 1024,
|
||||
initialMaxStreamDataUni = 64L * 1024,
|
||||
initialMaxStreamsBidi = 16,
|
||||
initialMaxStreamsUni = 16,
|
||||
initialSourceConnectionId = serverScid.bytes,
|
||||
originalDestinationConnectionId = client.destinationConnectionId.bytes,
|
||||
).encode(),
|
||||
)
|
||||
val pipe =
|
||||
InMemoryQuicPipe(
|
||||
client = client,
|
||||
initialDcid = client.destinationConnectionId.bytes,
|
||||
serverScid = serverScid,
|
||||
tlsServer = tlsServer,
|
||||
)
|
||||
client.start()
|
||||
pipe.drive(maxRounds = 16)
|
||||
assertEquals(QuicConnection.Status.CONNECTED, client.status)
|
||||
client to pipe
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user