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:
Claude
2026-05-07 23:35:35 +00:00
parent be8f2e08d3
commit afe3aaf020
5 changed files with 409 additions and 2 deletions
@@ -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
}
}