feat(quic): client-initiated path validation + DCID rotation (RFC 9000 §9)
Implements the client side of connection migration so a path that
stops receiving ACKs (NAT rebind, route flap, dead peer) can be
recovered without a fresh handshake:
1. NEW_CONNECTION_ID frames from the server are stored in a
PathValidator pool (was: parsed and dropped).
2. After PATH_PROBE_PTO_THRESHOLD consecutive PTOs, the driver
calls triggerPathMigrationLocked(); the validator picks an
unused CID and queues a PATH_CHALLENGE with a CSPRNG payload.
3. The writer drains the challenge into the next outbound 1-RTT
packet using the new DCID; a RecoveryToken.PathChallenge is
attached so loss recovery can re-queue on packet drop.
4. Inbound PATH_RESPONSE that byte-equals the outstanding payload
promotes destinationConnectionId to the new bytes and queues
RETIRE_CONNECTION_ID for the prior sequence.
5. RFC 9000 §8.2.4: validation is abandoned after 3 * PTO;
timeout transitions to PathValidationState.Failed for retry.
Spec coverage:
- §5.1.1 initial DCID is sequence 0
- §5.1.2 retire_prior_to enforcement (clamping per §19.15
reordering rule, force-retire of cached entries below
watermark)
- §8.2.2 byte-equal payload match
- §8.2.4 3 * PTO abandonment
- §19.15 frame-encoding error checks (retire_prior_to >
sequence_number, invalid CID/token length)
- §19.16 RETIRE_CONNECTION_ID frame codec + protocol-violation
close on retire of an unissued sequence
Observability: QlogObserver gains onPathValidationStarted /
Succeeded / Failed and onConnectionIdActivated / Retired hooks
for qvis sequence diagrams.
Tests: PathValidatorTest (state-machine unit) +
ClientPathMigrationTest (full round-trip through InMemoryQuicPipe:
NEW_CONNECTION_ID -> trigger -> PATH_CHALLENGE -> PATH_RESPONSE ->
DCID rotated + RETIRE_CONNECTION_ID emitted). Existing
PathValidationTest (peer-initiated PATH_CHALLENGE echo) continues
to pass unchanged.
https://claude.ai/code/session_01PVVhSQXvw4K4oQ46FzpgaT
This commit is contained in:
@@ -0,0 +1,534 @@
|
||||
/*
|
||||
* 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.quartz.utils.RandomInstance
|
||||
|
||||
/**
|
||||
* Single connection ID the peer (server) has issued to us via a
|
||||
* `NEW_CONNECTION_ID` frame (RFC 9000 §19.15) and we have NOT yet
|
||||
* retired. The initial DCID negotiated during the handshake is
|
||||
* implicitly sequence number 0 (RFC 9000 §5.1.1).
|
||||
*
|
||||
* `connectionId` is the bytes the writer would stamp into outbound
|
||||
* short-header packets; `statelessResetToken` is the 16-byte token
|
||||
* we'd compare against an incoming stateless reset.
|
||||
*/
|
||||
data class PeerConnectionIdEntry(
|
||||
val sequenceNumber: Long,
|
||||
val connectionId: ByteArray,
|
||||
val statelessResetToken: ByteArray,
|
||||
) {
|
||||
init {
|
||||
require(connectionId.isNotEmpty()) { "connection id must not be empty" }
|
||||
require(statelessResetToken.size == 16) { "stateless reset token must be 16 bytes" }
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is PeerConnectionIdEntry) return false
|
||||
return sequenceNumber == other.sequenceNumber &&
|
||||
connectionId.contentEquals(other.connectionId) &&
|
||||
statelessResetToken.contentEquals(other.statelessResetToken)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var h = sequenceNumber.hashCode()
|
||||
h = 31 * h + connectionId.contentHashCode()
|
||||
h = 31 * h + statelessResetToken.contentHashCode()
|
||||
return h
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the path validation state machine currently sits (RFC 9000
|
||||
* §8.2 / §9). Most installations spend their entire life in [Idle];
|
||||
* the transition to [Validating] happens when the driver detects the
|
||||
* current path looks dead (consecutive PTOs without ACKs) and asks
|
||||
* the connection to migrate to a fresh DCID.
|
||||
*
|
||||
* - [Idle]: nothing in flight; the writer is happily using the
|
||||
* current DCID.
|
||||
* - [Validating]: a `PATH_CHALLENGE` is on the wire (or queued for
|
||||
* the next outbound) and we're waiting for a matching
|
||||
* `PATH_RESPONSE` with the SAME 8-byte payload.
|
||||
* - [Succeeded]: the peer echoed the payload; the writer has
|
||||
* switched to the new DCID and a `RETIRE_CONNECTION_ID` for the
|
||||
* old sequence number is queued.
|
||||
* - [Failed]: validation didn't complete inside the 3*PTO window
|
||||
* (RFC 9000 §8.2.4) — caller may retry with a different CID, or
|
||||
* surface the failure as a connection close.
|
||||
*/
|
||||
sealed class PathValidationState {
|
||||
object Idle : PathValidationState()
|
||||
|
||||
data class Validating(
|
||||
val challengeData: ByteArray,
|
||||
val newCidSequence: Long,
|
||||
val newCidBytes: ByteArray,
|
||||
val priorCidSequence: Long,
|
||||
val startedAtMillis: Long,
|
||||
val priorPtoMillis: Long,
|
||||
) : PathValidationState() {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Validating) return false
|
||||
return challengeData.contentEquals(other.challengeData) &&
|
||||
newCidSequence == other.newCidSequence &&
|
||||
newCidBytes.contentEquals(other.newCidBytes) &&
|
||||
priorCidSequence == other.priorCidSequence &&
|
||||
startedAtMillis == other.startedAtMillis &&
|
||||
priorPtoMillis == other.priorPtoMillis
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var h = challengeData.contentHashCode()
|
||||
h = 31 * h + newCidSequence.hashCode()
|
||||
h = 31 * h + newCidBytes.contentHashCode()
|
||||
h = 31 * h + priorCidSequence.hashCode()
|
||||
h = 31 * h + startedAtMillis.hashCode()
|
||||
h = 31 * h + priorPtoMillis.hashCode()
|
||||
return h
|
||||
}
|
||||
}
|
||||
|
||||
object Succeeded : PathValidationState()
|
||||
|
||||
object Failed : PathValidationState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of [PathValidator.tryStartValidation], used by the caller
|
||||
* (driver / test) to know whether a probe was actually issued.
|
||||
*/
|
||||
enum class PathMigrationResult {
|
||||
/** A `PATH_CHALLENGE` was queued; state is [PathValidationState.Validating]. */
|
||||
Started,
|
||||
|
||||
/**
|
||||
* Already in [PathValidationState.Validating] — the previous
|
||||
* attempt hasn't resolved yet. Caller should not pile on; the
|
||||
* existing challenge will resolve via PATH_RESPONSE or fail
|
||||
* via [PathValidator.checkValidationTimeout].
|
||||
*/
|
||||
AlreadyInProgress,
|
||||
|
||||
/**
|
||||
* No spare CID in the pool. The peer hasn't yet issued a
|
||||
* NEW_CONNECTION_ID we can use, or we've already drained the
|
||||
* pool. Caller should wait for fresh peer offers.
|
||||
*/
|
||||
NoSpareCid,
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-connection state for client-initiated path validation + DCID
|
||||
* rotation (RFC 9000 §9). Owned by [QuicConnection]; the parser and
|
||||
* writer call into it under `streamsLock`.
|
||||
*
|
||||
* Spec mapping:
|
||||
* - **§5.1.1** initial DCID is sequence number 0.
|
||||
* - **§5.1.2** at most `active_connection_id_limit` CIDs
|
||||
* simultaneously usable; the limit is the PEER's transport
|
||||
* parameter telling us how many of OUR source CIDs they'll
|
||||
* hold, but reciprocally we cap the peer's pool the same way
|
||||
* so a buggy server can't pin arbitrary memory by spamming
|
||||
* NEW_CONNECTION_ID frames.
|
||||
* - **§5.1.2** `retire_prior_to` in a NEW_CONNECTION_ID frame
|
||||
* forces us to retire every previously-issued CID with a
|
||||
* smaller sequence number (and queue RETIRE_CONNECTION_ID for
|
||||
* each). The `retire_prior_to` value MUST NOT decrease across
|
||||
* successive frames; we treat a regression as a peer protocol
|
||||
* violation and abort the connection upstream.
|
||||
* - **§8.2 / §9** path validation: pick a spare CID, issue
|
||||
* PATH_CHALLENGE with a cryptographically random 8-byte
|
||||
* payload, await a PATH_RESPONSE that echoes the payload byte
|
||||
* for byte. RFC 9000 §8.2.4: validation MUST be abandoned after
|
||||
* `3 * PTO` of inactivity.
|
||||
* - **§19.16** RETIRE_CONNECTION_ID is reliable; the dispatcher
|
||||
* re-queues on loss until ACK'd.
|
||||
*
|
||||
* Thread safety: every method here assumes the caller holds the
|
||||
* connection's `streamsLock`. The pool / outstanding-challenge maps
|
||||
* are plain mutable collections.
|
||||
*/
|
||||
class PathValidator(
|
||||
/**
|
||||
* Sequence number of the CID the writer is currently stamping
|
||||
* into outbound short-header packets. Initial value 0 per RFC
|
||||
* 9000 §5.1.1 — the DCID negotiated at handshake. After a
|
||||
* successful validation [activeCidSequence] is the new
|
||||
* sequence; the prior sequence is in `pendingRetireSequences`
|
||||
* waiting to ride out as a RETIRE_CONNECTION_ID.
|
||||
*/
|
||||
initialActiveCidSequence: Long = 0L,
|
||||
/**
|
||||
* Cap on the number of unused CIDs we'll buffer. Defaults to
|
||||
* the peer's `active_connection_id_limit` transport parameter,
|
||||
* but the connection caller may pass a tighter cap. Excess
|
||||
* NEW_CONNECTION_ID offers past this cap are silently dropped
|
||||
* — the peer is in violation of the limit it asked us to
|
||||
* advertise (or never advertised one) and we don't owe it
|
||||
* indefinite buffering.
|
||||
*/
|
||||
private val maxUnusedCids: Int = DEFAULT_MAX_UNUSED_CIDS,
|
||||
/**
|
||||
* Cryptographically random 8-byte payload generator used for
|
||||
* outbound PATH_CHALLENGE. Default uses [RandomInstance]; tests
|
||||
* can substitute a deterministic supplier so assertions can
|
||||
* compare the on-wire payload against a known value.
|
||||
*/
|
||||
private val challengePayloadFactory: () -> ByteArray = { RandomInstance.bytes(8) },
|
||||
) {
|
||||
/**
|
||||
* Sequence number → entry. Holds CIDs the peer has issued and
|
||||
* we have NOT yet retired or activated. The active CID itself
|
||||
* is NOT in this map (it's held by the connection's
|
||||
* `destinationConnectionId` field); only the spare pool lives
|
||||
* here.
|
||||
*
|
||||
* LinkedHashMap so iteration order matches issuance order — when
|
||||
* we pick a CID for a fresh validation we prefer the lowest
|
||||
* sequence number for FIFO fairness.
|
||||
*/
|
||||
private val unusedCids: LinkedHashMap<Long, PeerConnectionIdEntry> = LinkedHashMap()
|
||||
|
||||
/**
|
||||
* Highest `retire_prior_to` value the peer has ever advertised.
|
||||
* Per RFC 9000 §5.1.2 this MUST NOT decrease; a decrease is a
|
||||
* peer protocol violation. Used to decide whether a
|
||||
* just-arrived NEW_CONNECTION_ID with a sequence number ≤
|
||||
* [retirePriorToWatermark] is stale (drop it) or whether a
|
||||
* previously-cached entry has now been forced into retirement.
|
||||
*/
|
||||
var retirePriorToWatermark: Long = 0L
|
||||
private set
|
||||
|
||||
var activeCidSequence: Long = initialActiveCidSequence
|
||||
private set
|
||||
|
||||
/**
|
||||
* Sequence numbers of CIDs we owe the peer a
|
||||
* `RETIRE_CONNECTION_ID` for. Drained by the writer on the
|
||||
* next outbound application packet. Re-populated by the loss
|
||||
* dispatcher if our RETIRE_CONNECTION_ID was lost.
|
||||
*/
|
||||
val pendingRetireSequences: ArrayDeque<Long> = ArrayDeque()
|
||||
|
||||
/**
|
||||
* Outbound `PATH_CHALLENGE` payloads the writer should drain on
|
||||
* the next application-level packet. Populated by
|
||||
* [tryStartValidation]; emptied by the writer after encoding
|
||||
* (the writer also records a [com.vitorpamplona.quic.connection.recovery.RecoveryToken.PathChallenge]
|
||||
* so the loss dispatcher can decide to retransmit or abandon).
|
||||
*/
|
||||
val pendingChallenges: ArrayDeque<ByteArray> = ArrayDeque()
|
||||
|
||||
var state: PathValidationState = PathValidationState.Idle
|
||||
private set
|
||||
|
||||
/**
|
||||
* Total number of validations that have completed successfully
|
||||
* over the lifetime of this connection. Diagnostic only — used
|
||||
* by tests and qlog to assert "exactly one rotation happened".
|
||||
*/
|
||||
var successfulValidations: Long = 0L
|
||||
private set
|
||||
|
||||
/**
|
||||
* Total number of validations that timed out / were abandoned.
|
||||
* Counter-part of [successfulValidations].
|
||||
*/
|
||||
var failedValidations: Long = 0L
|
||||
private set
|
||||
|
||||
/**
|
||||
* Number of unused CIDs currently in the pool. Used by tests
|
||||
* (and the connection's diagnostic surface) to assert the peer
|
||||
* actually offered new CIDs we can rotate to.
|
||||
*/
|
||||
fun unusedCount(): Int = unusedCids.size
|
||||
|
||||
fun unusedSequences(): List<Long> = unusedCids.keys.toList()
|
||||
|
||||
/**
|
||||
* Record a peer-issued `NEW_CONNECTION_ID` (RFC 9000 §19.15).
|
||||
* Returns the result code so the caller (parser) can decide
|
||||
* whether to close the connection on a peer protocol violation.
|
||||
*
|
||||
* The semantics:
|
||||
* - If [retirePriorTo] is greater than [sequenceNumber], that's
|
||||
* a §19.15 violation (the peer can't tell us to retire its
|
||||
* own brand-new CID).
|
||||
* - If [retirePriorTo] decreased, that's a §5.1.2 violation.
|
||||
* - If the new entry would push the pool past [maxUnusedCids],
|
||||
* return [Result.PoolFull] — the peer over-issued.
|
||||
* - If [sequenceNumber] is below the current
|
||||
* [retirePriorToWatermark], the offer is already-retired and
|
||||
* we silently drop it (the caller MUST still queue a
|
||||
* RETIRE_CONNECTION_ID for it per §5.1.2).
|
||||
* - Duplicate sequence number with matching CID/token is
|
||||
* idempotent ([Result.Duplicate]); a duplicate sequence with
|
||||
* DIFFERENT bytes is a §19.15 violation.
|
||||
*
|
||||
* Side effect on success: any cached entry whose sequence
|
||||
* number is now below the new [retirePriorTo] is moved into
|
||||
* [pendingRetireSequences] (we owe the peer a retire) and
|
||||
* dropped from [unusedCids]. If the active CID itself is
|
||||
* forced-retired, the active sequence is bumped (the caller
|
||||
* must rotate the active CID — see [forceRetireActiveIfNeeded]).
|
||||
*/
|
||||
fun recordPeerNewConnectionId(
|
||||
sequenceNumber: Long,
|
||||
retirePriorTo: Long,
|
||||
connectionId: ByteArray,
|
||||
statelessResetToken: ByteArray,
|
||||
): RecordResult {
|
||||
if (retirePriorTo > sequenceNumber) return RecordResult.RetirePriorToExceedsSequence
|
||||
if (connectionId.isEmpty() || connectionId.size > 20) return RecordResult.InvalidCidLength
|
||||
if (statelessResetToken.size != 16) return RecordResult.InvalidStatelessResetToken
|
||||
|
||||
// RFC 9000 §19.15: a smaller `retire_prior_to` than what we
|
||||
// previously saw "MUST be treated as the largest one it has
|
||||
// seen". This handles reordered NEW_CONNECTION_ID arrivals
|
||||
// — not a protocol violation, just clamp.
|
||||
val effectiveRetirePriorTo = if (retirePriorTo < retirePriorToWatermark) retirePriorToWatermark else retirePriorTo
|
||||
|
||||
// §5.1.2: bump the watermark and force-retire any cached
|
||||
// entries that fall under it. This step happens BEFORE the
|
||||
// duplicate check so a sequence number that's just been
|
||||
// dropped doesn't survive on a stale entry.
|
||||
if (effectiveRetirePriorTo > retirePriorToWatermark) {
|
||||
retirePriorToWatermark = effectiveRetirePriorTo
|
||||
val it = unusedCids.entries.iterator()
|
||||
while (it.hasNext()) {
|
||||
val (seq, _) = it.next()
|
||||
if (seq < retirePriorToWatermark) {
|
||||
queueRetireSequence(seq)
|
||||
it.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stale offer: the peer is pushing a sequence number we've
|
||||
// already retired. Per §5.1.2 we owe a RETIRE_CONNECTION_ID
|
||||
// back; queue it but don't add the entry to the pool.
|
||||
if (sequenceNumber < retirePriorToWatermark) {
|
||||
queueRetireSequence(sequenceNumber)
|
||||
return RecordResult.AlreadyRetired
|
||||
}
|
||||
|
||||
// Duplicate handling.
|
||||
val existing = unusedCids[sequenceNumber]
|
||||
if (existing != null) {
|
||||
if (!existing.connectionId.contentEquals(connectionId) ||
|
||||
!existing.statelessResetToken.contentEquals(statelessResetToken)
|
||||
) {
|
||||
return RecordResult.DuplicateSequenceMismatch
|
||||
}
|
||||
return RecordResult.Duplicate
|
||||
}
|
||||
|
||||
if (unusedCids.size >= maxUnusedCids) return RecordResult.PoolFull
|
||||
|
||||
unusedCids[sequenceNumber] =
|
||||
PeerConnectionIdEntry(
|
||||
sequenceNumber = sequenceNumber,
|
||||
connectionId = connectionId.copyOf(),
|
||||
statelessResetToken = statelessResetToken.copyOf(),
|
||||
)
|
||||
return RecordResult.Stored
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of [recordPeerNewConnectionId].
|
||||
*/
|
||||
enum class RecordResult {
|
||||
Stored,
|
||||
Duplicate,
|
||||
DuplicateSequenceMismatch,
|
||||
AlreadyRetired,
|
||||
PoolFull,
|
||||
RetirePriorToExceedsSequence,
|
||||
InvalidCidLength,
|
||||
InvalidStatelessResetToken,
|
||||
}
|
||||
|
||||
/**
|
||||
* Append [sequenceNumber] to [pendingRetireSequences] iff it
|
||||
* isn't already queued. Idempotent — a duplicate request from
|
||||
* the loss dispatcher (re-queue on loss) finds the entry
|
||||
* already there and is a no-op.
|
||||
*/
|
||||
fun queueRetireSequence(sequenceNumber: Long) {
|
||||
if (!pendingRetireSequences.contains(sequenceNumber)) {
|
||||
pendingRetireSequences.addLast(sequenceNumber)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin a fresh path-validation attempt. Picks the lowest-sequence
|
||||
* unused CID, generates a random 8-byte challenge, queues both,
|
||||
* and transitions [state] to [PathValidationState.Validating].
|
||||
*
|
||||
* Caller is responsible for waking the writer so the challenge
|
||||
* actually goes out. The writer also stamps the new DCID into
|
||||
* the next outbound short-header packet — see
|
||||
* [QuicConnection.activatePendingValidatedCid] for the post-
|
||||
* response promotion path.
|
||||
*/
|
||||
fun tryStartValidation(
|
||||
nowMillis: Long,
|
||||
currentPtoMillis: Long,
|
||||
): PathMigrationResult {
|
||||
if (state is PathValidationState.Validating) return PathMigrationResult.AlreadyInProgress
|
||||
val (seq, entry) = unusedCids.entries.firstOrNull() ?: return PathMigrationResult.NoSpareCid
|
||||
unusedCids.remove(seq)
|
||||
val payload =
|
||||
challengePayloadFactory().also {
|
||||
require(it.size == 8) { "challenge payload supplier must return 8 bytes" }
|
||||
}
|
||||
pendingChallenges.addLast(payload)
|
||||
state =
|
||||
PathValidationState.Validating(
|
||||
challengeData = payload,
|
||||
newCidSequence = seq,
|
||||
newCidBytes = entry.connectionId,
|
||||
priorCidSequence = activeCidSequence,
|
||||
startedAtMillis = nowMillis,
|
||||
priorPtoMillis = currentPtoMillis,
|
||||
)
|
||||
return PathMigrationResult.Started
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an inbound `PATH_RESPONSE` payload. Returns true when
|
||||
* it matched the outstanding challenge and the migration just
|
||||
* completed; false if there's no outstanding challenge or the
|
||||
* payload doesn't match (an attacker echoing random bytes).
|
||||
*
|
||||
* Side effects on success:
|
||||
* - [state] transitions to [PathValidationState.Succeeded].
|
||||
* - [activeCidSequence] is bumped to the validated sequence.
|
||||
* - The prior sequence is queued for RETIRE_CONNECTION_ID.
|
||||
* - Returns the new entry so the connection can swap its
|
||||
* `destinationConnectionId` field.
|
||||
*/
|
||||
fun applyPathResponse(payload: ByteArray): ValidationOutcome {
|
||||
val current = state as? PathValidationState.Validating ?: return ValidationOutcome.NotValidating
|
||||
if (!payload.contentEquals(current.challengeData)) return ValidationOutcome.PayloadMismatch
|
||||
val priorSeq = activeCidSequence
|
||||
activeCidSequence = current.newCidSequence
|
||||
if (priorSeq != current.newCidSequence) queueRetireSequence(priorSeq)
|
||||
state = PathValidationState.Succeeded
|
||||
successfulValidations += 1
|
||||
return ValidationOutcome.Validated(
|
||||
newSequence = current.newCidSequence,
|
||||
newConnectionIdBytes = current.newCidBytes,
|
||||
retiredSequence = priorSeq,
|
||||
)
|
||||
}
|
||||
|
||||
sealed class ValidationOutcome {
|
||||
object NotValidating : ValidationOutcome()
|
||||
|
||||
object PayloadMismatch : ValidationOutcome()
|
||||
|
||||
data class Validated(
|
||||
val newSequence: Long,
|
||||
val newConnectionIdBytes: ByteArray,
|
||||
val retiredSequence: Long,
|
||||
) : ValidationOutcome() {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Validated) return false
|
||||
return newSequence == other.newSequence &&
|
||||
newConnectionIdBytes.contentEquals(other.newConnectionIdBytes) &&
|
||||
retiredSequence == other.retiredSequence
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var h = newSequence.hashCode()
|
||||
h = 31 * h + newConnectionIdBytes.contentHashCode()
|
||||
h = 31 * h + retiredSequence.hashCode()
|
||||
return h
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9000 §8.2.4: abandon validation if more than 3 * PTO has
|
||||
* elapsed since the challenge went out without a matching
|
||||
* response. Returns the abandoned context (so the caller can
|
||||
* surface a qlog event / decide whether to attempt another
|
||||
* rotation), or null if no validation is in progress / the
|
||||
* timer hasn't fired yet.
|
||||
*/
|
||||
fun checkValidationTimeout(nowMillis: Long): PathValidationState.Validating? {
|
||||
val current = state as? PathValidationState.Validating ?: return null
|
||||
val elapsed = nowMillis - current.startedAtMillis
|
||||
val budget = (current.priorPtoMillis * VALIDATION_PTO_BUDGET_MULTIPLIER).coerceAtLeast(MIN_VALIDATION_BUDGET_MS)
|
||||
if (elapsed < budget) return null
|
||||
state = PathValidationState.Failed
|
||||
failedValidations += 1
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to [PathValidationState.Idle] after the caller has
|
||||
* surfaced a Succeeded / Failed transition. Allows the next
|
||||
* trigger to start a fresh attempt without leaking the
|
||||
* previous terminal state.
|
||||
*/
|
||||
fun acknowledgeTerminal() {
|
||||
if (state is PathValidationState.Succeeded || state is PathValidationState.Failed) {
|
||||
state = PathValidationState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Default cap on the unused-CID pool. RFC 9000 §18.2 default
|
||||
* for `active_connection_id_limit` is 2, and the spec
|
||||
* lower-bounds it at 2. A peer that issues more than its
|
||||
* own advertised limit is misbehaving but harmless — we
|
||||
* just cap at 8 here so a buggy server can't pin memory.
|
||||
*/
|
||||
const val DEFAULT_MAX_UNUSED_CIDS: Int = 8
|
||||
|
||||
/**
|
||||
* RFC 9000 §8.2.4: validation MUST be abandoned after
|
||||
* 3 * PTO. We use 3.0 exactly; a slightly looser value
|
||||
* (e.g. 4) wouldn't change correctness but might mask peer
|
||||
* misbehavior in tests.
|
||||
*/
|
||||
const val VALIDATION_PTO_BUDGET_MULTIPLIER: Long = 3L
|
||||
|
||||
/**
|
||||
* Floor on the validation budget. PTO during the first RTT
|
||||
* sample is ~300 ms (see [com.vitorpamplona.quic.connection.recovery.QuicLossDetection.INITIAL_RTT_MS]),
|
||||
* so 3*PTO ≈ 900 ms. We add a generous floor so test paths
|
||||
* with an artificially-tiny RTT still get a real chance to
|
||||
* complete validation before the timer fires.
|
||||
*/
|
||||
const val MIN_VALIDATION_BUDGET_MS: Long = 250L
|
||||
}
|
||||
}
|
||||
@@ -505,6 +505,28 @@ class QuicConnection(
|
||||
*/
|
||||
internal val pendingPathChallengePayloads: ArrayDeque<ByteArray> = ArrayDeque()
|
||||
|
||||
/**
|
||||
* RFC 9000 §9 client-initiated path validation + DCID rotation.
|
||||
* The pool of unused peer-issued connection IDs, the outbound
|
||||
* `PATH_CHALLENGE` queue, the matching state machine, and the
|
||||
* `RETIRE_CONNECTION_ID` retransmit queue all live here. The
|
||||
* parser populates the pool from inbound `NEW_CONNECTION_ID`;
|
||||
* the writer drains the challenge + retire queues; the driver
|
||||
* triggers a fresh validation when consecutive PTO threshold is
|
||||
* exceeded.
|
||||
*
|
||||
* Caller of any read/write must hold [streamsLock].
|
||||
*/
|
||||
internal val pathValidator: PathValidator =
|
||||
PathValidator(
|
||||
initialActiveCidSequence = 0L,
|
||||
maxUnusedCids =
|
||||
config.activeConnectionIdLimit
|
||||
.coerceAtMost(PathValidator.DEFAULT_MAX_UNUSED_CIDS.toLong())
|
||||
.coerceAtLeast(2L)
|
||||
.toInt(),
|
||||
)
|
||||
|
||||
/**
|
||||
* RFC 9002 RTT estimator + loss-detection algorithm. Single
|
||||
* shared instance per connection (RTT is per-path; we model a
|
||||
@@ -1881,6 +1903,190 @@ class QuicConnection(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9000 §19.15: store a peer-issued connection ID into the
|
||||
* [pathValidator] pool, enforcing `retire_prior_to` semantics
|
||||
* and capping pool size. On a peer protocol violation
|
||||
* ([PathValidator.RecordResult.RetirePriorToExceedsSequence] /
|
||||
* [PathValidator.RecordResult.RetirePriorToRegressed] /
|
||||
* [PathValidator.RecordResult.DuplicateSequenceMismatch] /
|
||||
* [PathValidator.RecordResult.InvalidCidLength] /
|
||||
* [PathValidator.RecordResult.InvalidStatelessResetToken]) the
|
||||
* connection is closed with FRAME_ENCODING_ERROR — these are
|
||||
* MUSTs in the spec.
|
||||
*
|
||||
* Caller must hold [streamsLock].
|
||||
*/
|
||||
internal fun applyPeerNewConnectionIdLocked(
|
||||
sequenceNumber: Long,
|
||||
retirePriorTo: Long,
|
||||
connectionId: ByteArray,
|
||||
statelessResetToken: ByteArray,
|
||||
) {
|
||||
when (
|
||||
pathValidator.recordPeerNewConnectionId(
|
||||
sequenceNumber = sequenceNumber,
|
||||
retirePriorTo = retirePriorTo,
|
||||
connectionId = connectionId,
|
||||
statelessResetToken = statelessResetToken,
|
||||
)
|
||||
) {
|
||||
PathValidator.RecordResult.Stored -> {
|
||||
Unit
|
||||
}
|
||||
|
||||
PathValidator.RecordResult.Duplicate -> {
|
||||
Unit
|
||||
}
|
||||
|
||||
PathValidator.RecordResult.AlreadyRetired -> {
|
||||
Unit
|
||||
}
|
||||
|
||||
PathValidator.RecordResult.PoolFull -> {
|
||||
// Peer over-issued past its own advertised
|
||||
// active_connection_id_limit. RFC 9000 §5.1.1 says
|
||||
// we MAY treat this as CONNECTION_ID_LIMIT_ERROR. We
|
||||
// only drop silently here — pinning memory is the
|
||||
// real concern, and the cap defense already handled
|
||||
// that.
|
||||
}
|
||||
|
||||
PathValidator.RecordResult.RetirePriorToExceedsSequence -> {
|
||||
markClosedExternally(
|
||||
"FRAME_ENCODING_ERROR: NEW_CONNECTION_ID retire_prior_to ($retirePriorTo) > sequence_number ($sequenceNumber)",
|
||||
)
|
||||
}
|
||||
|
||||
PathValidator.RecordResult.DuplicateSequenceMismatch -> {
|
||||
markClosedExternally(
|
||||
"PROTOCOL_VIOLATION: NEW_CONNECTION_ID seq=$sequenceNumber re-issued with different bytes",
|
||||
)
|
||||
}
|
||||
|
||||
PathValidator.RecordResult.InvalidCidLength -> {
|
||||
markClosedExternally(
|
||||
"FRAME_ENCODING_ERROR: NEW_CONNECTION_ID has invalid cid length",
|
||||
)
|
||||
}
|
||||
|
||||
PathValidator.RecordResult.InvalidStatelessResetToken -> {
|
||||
markClosedExternally(
|
||||
"FRAME_ENCODING_ERROR: NEW_CONNECTION_ID has invalid stateless reset token length",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an inbound `PATH_RESPONSE` (RFC 9000 §19.18). If the
|
||||
* payload matches our outstanding `PATH_CHALLENGE`, the writer
|
||||
* gets a new `destinationConnectionId` to stamp into outbound
|
||||
* short-headers and a `RETIRE_CONNECTION_ID` is queued for the
|
||||
* old sequence number. Mismatches and unsolicited responses are
|
||||
* silently dropped — RFC 9000 §8.2.2 says "an endpoint that
|
||||
* receives a PATH_RESPONSE with a different payload than what
|
||||
* it sent in the PATH_CHALLENGE on the path" is allowed to
|
||||
* ignore.
|
||||
*
|
||||
* Caller must hold [streamsLock].
|
||||
*/
|
||||
internal fun applyPeerPathResponseLocked(payload: ByteArray) {
|
||||
when (val outcome = pathValidator.applyPathResponse(payload)) {
|
||||
PathValidator.ValidationOutcome.NotValidating,
|
||||
PathValidator.ValidationOutcome.PayloadMismatch,
|
||||
-> {
|
||||
Unit
|
||||
}
|
||||
|
||||
is PathValidator.ValidationOutcome.Validated -> {
|
||||
destinationConnectionId = ConnectionId(outcome.newConnectionIdBytes)
|
||||
qlogObserver.onPathValidationSucceeded(outcome.newSequence)
|
||||
qlogObserver.onConnectionIdActivated("peer", outcome.newSequence, outcome.newConnectionIdBytes)
|
||||
pathValidator.acknowledgeTerminal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9000 §19.16: peer asked us to retire one of our own
|
||||
* source CIDs. Today we only ever issue one source CID at
|
||||
* connection start (sequence 0), so any RETIRE_CONNECTION_ID
|
||||
* the peer sends about a sequence > 0 references a CID we
|
||||
* never issued — RFC 9000 says this is PROTOCOL_VIOLATION.
|
||||
* Sequence 0 retire is also a violation per §19.16: "Receipt
|
||||
* of a RETIRE_CONNECTION_ID frame containing a sequence number
|
||||
* greater than any previously sent to the peer MUST be
|
||||
* treated as a connection error of type PROTOCOL_VIOLATION."
|
||||
*
|
||||
* Caller must hold [streamsLock].
|
||||
*/
|
||||
internal fun applyPeerRetireConnectionIdLocked(sequenceNumber: Long) {
|
||||
// We only issue sequence 0 today; any retire request beyond
|
||||
// that references a CID we never advertised. This is a
|
||||
// peer-side violation per §19.16.
|
||||
if (sequenceNumber > 0L) {
|
||||
markClosedExternally(
|
||||
"PROTOCOL_VIOLATION: RETIRE_CONNECTION_ID seq=$sequenceNumber > any we issued (0)",
|
||||
)
|
||||
}
|
||||
// Sequence 0 retire today means "stop using your initial
|
||||
// SCID." We don't model client-issued CID rotation, so the
|
||||
// request is silently honored at the protocol level (we
|
||||
// never re-use the SCID in any new long-header packet —
|
||||
// long headers are gone after handshake completion).
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to start client-initiated path validation + DCID rotation
|
||||
* (RFC 9000 §9). Picks the lowest-sequence unused CID from the
|
||||
* pool, queues a fresh PATH_CHALLENGE, and records the
|
||||
* outstanding state. Returns the result so the caller can
|
||||
* decide whether to retry later (e.g. on the next PTO if the
|
||||
* pool was empty when this fired).
|
||||
*
|
||||
* Caller must hold [streamsLock].
|
||||
*/
|
||||
internal fun triggerPathMigrationLocked(
|
||||
nowMillis: Long,
|
||||
currentPtoMillis: Long,
|
||||
): PathMigrationResult {
|
||||
val result = pathValidator.tryStartValidation(nowMillis, currentPtoMillis)
|
||||
if (result == PathMigrationResult.Started) {
|
||||
val validating = pathValidator.state as PathValidationState.Validating
|
||||
qlogObserver.onPathValidationStarted(validating.newCidSequence)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Public counterpart to [triggerPathMigrationLocked] — acquires
|
||||
* the lock internally. Suitable for application / driver code
|
||||
* that wants to force a rotation without coupling to the
|
||||
* locking discipline.
|
||||
*/
|
||||
suspend fun triggerPathMigration(
|
||||
nowMillis: Long = nowMillis(),
|
||||
currentPtoMillis: Long = lossDetection.ptoBaseMs(peerTransportParameters?.maxAckDelay ?: 0L),
|
||||
): PathMigrationResult = streamsLock.withLock { triggerPathMigrationLocked(nowMillis, currentPtoMillis) }
|
||||
|
||||
/**
|
||||
* Check whether an outstanding PATH_CHALLENGE has exceeded the
|
||||
* 3 * PTO budget (RFC 9000 §8.2.4). Called from the driver's
|
||||
* PTO timer path. Returns true when validation just timed out
|
||||
* — caller may surface that to qlog and decide to retry with
|
||||
* another CID, or close the connection if the budget for
|
||||
* retries is exhausted.
|
||||
*
|
||||
* Caller must hold [streamsLock].
|
||||
*/
|
||||
internal fun checkPathValidationTimeoutLocked(nowMillis: Long): Boolean {
|
||||
val abandoned = pathValidator.checkValidationTimeout(nowMillis) ?: return false
|
||||
qlogObserver.onPathValidationFailed(abandoned.newCidSequence)
|
||||
pathValidator.acknowledgeTerminal()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a PATH_RESPONSE for the given [challengeData]. Called by
|
||||
* the parser when a PATH_CHALLENGE arrives. Idempotent on
|
||||
@@ -1956,12 +2162,19 @@ class QuicConnection(
|
||||
is com.vitorpamplona.quic.connection.recovery.RecoveryToken.MaxData,
|
||||
is com.vitorpamplona.quic.connection.recovery.RecoveryToken.MaxStreamData,
|
||||
is com.vitorpamplona.quic.connection.recovery.RecoveryToken.NewConnectionId,
|
||||
is com.vitorpamplona.quic.connection.recovery.RecoveryToken.PathChallenge,
|
||||
is com.vitorpamplona.quic.connection.recovery.RecoveryToken.RetireConnectionId,
|
||||
-> {
|
||||
// Flow-control extensions and NEW_CONNECTION_ID
|
||||
// have no per-buffer state to release on ACK; the
|
||||
// Flow-control extensions, NEW_CONNECTION_ID,
|
||||
// PATH_CHALLENGE, and RETIRE_CONNECTION_ID have
|
||||
// no per-buffer state to release on ACK; the
|
||||
// pending* maps are populated only on loss, so an
|
||||
// ACK for a frame that never lost is naturally
|
||||
// absent.
|
||||
// absent. PATH_CHALLENGE specifically: a peer ACK
|
||||
// means "we received the challenge frame" — but
|
||||
// that's NOT path validation success. Validation
|
||||
// requires the matching PATH_RESPONSE
|
||||
// (handled in [applyPeerPathResponseLocked]).
|
||||
}
|
||||
|
||||
is com.vitorpamplona.quic.connection.recovery.RecoveryToken.ResetStream -> {
|
||||
@@ -2086,6 +2299,33 @@ class QuicConnection(
|
||||
is com.vitorpamplona.quic.connection.recovery.RecoveryToken.NewConnectionId -> {
|
||||
pendingNewConnectionId[token.sequenceNumber] = token
|
||||
}
|
||||
|
||||
is com.vitorpamplona.quic.connection.recovery.RecoveryToken.PathChallenge -> {
|
||||
// RFC 9000 §8.2.1: if a PATH_CHALLENGE is lost
|
||||
// (carrying packet declared lost without an
|
||||
// ACK), the spec permits but does not require
|
||||
// retransmission — validation simply runs against
|
||||
// the 3 * PTO budget. We re-queue iff the
|
||||
// outstanding validation still matches this
|
||||
// payload (state hasn't progressed to Succeeded /
|
||||
// Failed / a different challenge). The writer
|
||||
// will pick it up on the next drain and emit a
|
||||
// fresh PATH_CHALLENGE with the SAME 8 bytes —
|
||||
// the peer must echo whichever it sees first.
|
||||
val s = pathValidator.state
|
||||
if (s is PathValidationState.Validating && s.challengeData.contentEquals(token.data)) {
|
||||
if (!pathValidator.pendingChallenges.any { it.contentEquals(token.data) }) {
|
||||
pathValidator.pendingChallenges.addLast(token.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is com.vitorpamplona.quic.connection.recovery.RecoveryToken.RetireConnectionId -> {
|
||||
// RFC 9000 §13.3: RETIRE_CONNECTION_ID is
|
||||
// reliable. Re-queue on loss — idempotent on
|
||||
// duplicate (queueRetireSequence dedupes).
|
||||
pathValidator.queueRetireSequence(token.sequenceNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +362,56 @@ internal suspend fun handlePtoFired(conn: QuicConnection) {
|
||||
conn.streamsLock.withLock {
|
||||
conn.requeueAllInflightStreamData()
|
||||
conn.requeueAllInflightCrypto(EncryptionLevel.APPLICATION)
|
||||
// RFC 9000 §9 client-initiated path validation. After
|
||||
// [PATH_PROBE_PTO_THRESHOLD] consecutive PTOs without
|
||||
// any inbound ACK we suspect the path is dead (NAT
|
||||
// rebind, route flap, dead peer). If the peer has
|
||||
// issued spare CIDs via NEW_CONNECTION_ID, rotate to
|
||||
// one and emit a PATH_CHALLENGE on the new DCID. The
|
||||
// validator only triggers on the FIRST crossing of
|
||||
// the threshold per validation cycle — the
|
||||
// [PathValidator] internally rejects re-entry while
|
||||
// [PathValidationState.Validating] holds.
|
||||
//
|
||||
// Also check the §8.2.4 budget on any in-flight
|
||||
// validation: 3*PTO since the challenge went out
|
||||
// without a matching response means the new path is
|
||||
// also dead — abandon and let the next PTO try with
|
||||
// another CID (or surface the failure to the higher
|
||||
// layer).
|
||||
val nowMillis =
|
||||
kotlin.time.Clock.System
|
||||
.now()
|
||||
.toEpochMilliseconds()
|
||||
val maxAckDelayMs =
|
||||
if (conn.application.sendProtection != null) {
|
||||
conn.peerTransportParameters?.maxAckDelay ?: 0L
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
val ptoBaseMs = conn.lossDetection.ptoBaseMs(maxAckDelayMs).coerceAtLeast(1L)
|
||||
conn.checkPathValidationTimeoutLocked(nowMillis)
|
||||
if (conn.consecutivePtoCount >= PATH_PROBE_PTO_THRESHOLD) {
|
||||
conn.triggerPathMigrationLocked(nowMillis = nowMillis, currentPtoMillis = ptoBaseMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
conn.consecutivePtoCount = (conn.consecutivePtoCount + 1).coerceAtMost(6)
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9000 §9 / §10.1.2 — number of consecutive PTOs we tolerate on
|
||||
* the active path before assuming it's dead and probing a new one.
|
||||
* The QUIC RFC doesn't pin a specific value (the spec only says "an
|
||||
* endpoint that has previously discovered a particular path
|
||||
* works"); 2 matches Firefox neqo's `PATH_PROBE_PTO_THRESHOLD`
|
||||
* default and Chrome's behavior. Picking 1 is too aggressive
|
||||
* (single dropped packet trips a rotation); 4+ is too late (user
|
||||
* notices the silence).
|
||||
*
|
||||
* Once the threshold is crossed, [handlePtoFired] calls into
|
||||
* [QuicConnection.triggerPathMigrationLocked] which is itself
|
||||
* idempotent — a second crossing while validation is already in
|
||||
* flight is a no-op.
|
||||
*/
|
||||
internal const val PATH_PROBE_PTO_THRESHOLD: Int = 2
|
||||
|
||||
+36
-9
@@ -36,6 +36,7 @@ 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.RetireConnectionIdFrame
|
||||
import com.vitorpamplona.quic.frame.StopSendingFrame
|
||||
import com.vitorpamplona.quic.frame.StreamFrame
|
||||
import com.vitorpamplona.quic.frame.decodeFrames
|
||||
@@ -716,10 +717,23 @@ private fun dispatchFrames(
|
||||
}
|
||||
|
||||
is NewConnectionIdFrame -> {
|
||||
// RFC 9000 §13.2.1: NEW_CONNECTION_ID is ack-eliciting. We
|
||||
// don't support migration but still need to ACK to keep
|
||||
// peer's loss-recovery happy.
|
||||
// RFC 9000 §13.2.1: NEW_CONNECTION_ID is ack-eliciting.
|
||||
// §19.15 + §5.1.2: store the offered CID + stateless
|
||||
// reset token in the [PathValidator] pool so the
|
||||
// client-initiated migration path (RFC 9000 §9) can
|
||||
// pick one when the active path stops receiving
|
||||
// ACKs. Also enforces `retire_prior_to` semantics
|
||||
// and queues RETIRE_CONNECTION_ID for any cached
|
||||
// entry whose sequence number falls below the new
|
||||
// watermark. Peer protocol violations close the
|
||||
// connection upstream.
|
||||
ackEliciting = true
|
||||
conn.applyPeerNewConnectionIdLocked(
|
||||
sequenceNumber = frame.sequenceNumber,
|
||||
retirePriorTo = frame.retirePriorTo,
|
||||
connectionId = frame.connectionId,
|
||||
statelessResetToken = frame.statelessResetToken,
|
||||
)
|
||||
}
|
||||
|
||||
is PathChallengeFrame -> {
|
||||
@@ -746,13 +760,26 @@ private fun dispatchFrames(
|
||||
|
||||
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.
|
||||
// §8.2.2: if the payload matches our outstanding
|
||||
// PATH_CHALLENGE, validation succeeded — the writer
|
||||
// is told to start stamping the new DCID and a
|
||||
// RETIRE_CONNECTION_ID for the old sequence number
|
||||
// is queued (RFC 9000 §9.5). Mismatched payloads
|
||||
// (no outstanding challenge OR an attacker echoing
|
||||
// random bytes) are silently ignored after the ACK.
|
||||
ackEliciting = true
|
||||
conn.applyPeerPathResponseLocked(frame.data)
|
||||
}
|
||||
|
||||
is RetireConnectionIdFrame -> {
|
||||
// RFC 9000 §13.2.1: RETIRE_CONNECTION_ID is
|
||||
// ack-eliciting. §19.16: peer is asking us to
|
||||
// retire one of OUR source CIDs. We only ever
|
||||
// issued sequence 0 (the SCID we picked at
|
||||
// connection start); any seq > 0 is a protocol
|
||||
// violation per §19.16, closes the connection.
|
||||
ackEliciting = true
|
||||
conn.applyPeerRetireConnectionIdLocked(frame.sequenceNumber)
|
||||
}
|
||||
|
||||
is ConnectionCloseFrame -> {
|
||||
|
||||
@@ -32,9 +32,11 @@ 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.PathChallengeFrame
|
||||
import com.vitorpamplona.quic.frame.PathResponseFrame
|
||||
import com.vitorpamplona.quic.frame.PingFrame
|
||||
import com.vitorpamplona.quic.frame.ResetStreamFrame
|
||||
import com.vitorpamplona.quic.frame.RetireConnectionIdFrame
|
||||
import com.vitorpamplona.quic.frame.StopSendingFrame
|
||||
import com.vitorpamplona.quic.frame.StreamFrame
|
||||
import com.vitorpamplona.quic.frame.encodeFrames
|
||||
@@ -962,6 +964,26 @@ private fun appendFlowControlUpdates(
|
||||
frames += PathResponseFrame(data)
|
||||
}
|
||||
|
||||
// RFC 9000 §9 client-initiated path validation. Drain any
|
||||
// PATH_CHALLENGE the [PathValidator] has queued — including
|
||||
// re-queued ones from the loss dispatcher. Each emission
|
||||
// records a [RecoveryToken.PathChallenge] so loss recovery
|
||||
// can decide to retransmit (the validator's own 3 * PTO
|
||||
// budget is the ultimate timeout per RFC 9000 §8.2.4).
|
||||
while (conn.pathValidator.pendingChallenges.isNotEmpty()) {
|
||||
val payload = conn.pathValidator.pendingChallenges.removeFirst()
|
||||
frames += PathChallengeFrame(payload)
|
||||
tokens += RecoveryToken.PathChallenge(payload)
|
||||
}
|
||||
|
||||
// RFC 9000 §19.16 RETIRE_CONNECTION_ID. Drain pending retires;
|
||||
// the dispatcher re-queues on loss until the peer ACKs.
|
||||
while (conn.pathValidator.pendingRetireSequences.isNotEmpty()) {
|
||||
val seq = conn.pathValidator.pendingRetireSequences.removeFirst()
|
||||
frames += RetireConnectionIdFrame(seq)
|
||||
tokens += RecoveryToken.RetireConnectionId(seq)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
+37
-2
@@ -178,10 +178,45 @@ sealed class RecoveryToken {
|
||||
val errorCode: Long,
|
||||
) : RecoveryToken()
|
||||
|
||||
/**
|
||||
* `PATH_CHALLENGE` frame we emitted as part of client-initiated
|
||||
* path validation (RFC 9000 §8.2 / §9). Carries the same 8-byte
|
||||
* payload the writer put on the wire so the loss dispatcher can
|
||||
* decide whether the validation attempt should be considered
|
||||
* failed or retransmitted.
|
||||
*
|
||||
* RFC 9000 §13.2.1 lists PATH_CHALLENGE as ack-eliciting; §8.2.4
|
||||
* says path validation MUST NOT exceed `3 * PTO` of waiting before
|
||||
* the path is declared failed. The connection-side dispatcher
|
||||
* checks the [com.vitorpamplona.quic.connection.PathValidator]
|
||||
* state — if validation has already moved on (succeeded, failed,
|
||||
* or abandoned), the lost token is dropped silently.
|
||||
*/
|
||||
data class PathChallenge(
|
||||
val data: ByteArray,
|
||||
) : RecoveryToken() {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is PathChallenge) return false
|
||||
return data.contentEquals(other.data)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = data.contentHashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* `RETIRE_CONNECTION_ID` frame we emitted (RFC 9000 §19.16).
|
||||
* Reliable per §13.3 — peer must learn that we've stopped using
|
||||
* the connection ID, otherwise it keeps the routing entry forever.
|
||||
*/
|
||||
data class RetireConnectionId(
|
||||
val sequenceNumber: Long,
|
||||
) : RecoveryToken()
|
||||
|
||||
/**
|
||||
* `NEW_CONNECTION_ID` frame we sent (RFC 9000 §19.15). Reliable
|
||||
* per §13.3. Same scaffolding-only status — connection-ID
|
||||
* rotation isn't wired today.
|
||||
* per §13.3. Same scaffolding-only status — client-issued
|
||||
* connection IDs aren't yet emitted by `:quic`.
|
||||
*/
|
||||
data class NewConnectionId(
|
||||
val sequenceNumber: Long,
|
||||
|
||||
@@ -313,6 +313,25 @@ class NewConnectionIdFrame(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9000 §19.16 — RETIRE_CONNECTION_ID frame. Tells the peer to
|
||||
* stop using one of the connection IDs it issued. Carries the
|
||||
* sequence number from the corresponding [NewConnectionIdFrame].
|
||||
*
|
||||
* The client side emits this when it rotates to a new DCID
|
||||
* post-handshake (see RFC 9000 §9 client-initiated migration): once
|
||||
* the new path is validated, the previously-active CID is retired
|
||||
* so the server can free the routing entry.
|
||||
*/
|
||||
class RetireConnectionIdFrame(
|
||||
val sequenceNumber: Long,
|
||||
) : Frame() {
|
||||
override fun encode(out: QuicWriter) {
|
||||
out.writeByte(FrameType.RETIRE_CONNECTION_ID.toInt())
|
||||
out.writeVarint(sequenceNumber)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9000 §19.17 — PATH_CHALLENGE frame, used for path validation
|
||||
* (§8.2). The 8-byte [data] payload is opaque random bytes the
|
||||
@@ -489,7 +508,7 @@ fun decodeFrames(data: ByteArray): List<Frame> {
|
||||
}
|
||||
|
||||
type == FrameType.RETIRE_CONNECTION_ID -> {
|
||||
r.readVarint()
|
||||
out += RetireConnectionIdFrame(r.readVarint())
|
||||
}
|
||||
|
||||
type == FrameType.PATH_CHALLENGE -> {
|
||||
|
||||
@@ -159,6 +159,51 @@ interface QlogObserver {
|
||||
otherVersionsOffered: List<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* RFC 9000 §9 client-initiated path validation just sent its
|
||||
* first `PATH_CHALLENGE` for [newCidSequence] (the sequence
|
||||
* number of the peer-issued connection ID we'll use on the new
|
||||
* path). qvis renders this as a path-validation milestone.
|
||||
*/
|
||||
fun onPathValidationStarted(newCidSequence: Long) = Unit
|
||||
|
||||
/**
|
||||
* The peer echoed our PATH_CHALLENGE payload — validation
|
||||
* succeeded. The writer has now switched to the new DCID and
|
||||
* a `RETIRE_CONNECTION_ID` for the prior sequence is queued.
|
||||
*/
|
||||
fun onPathValidationSucceeded(newCidSequence: Long) = Unit
|
||||
|
||||
/**
|
||||
* Path validation was abandoned because more than `3 * PTO`
|
||||
* elapsed without a matching `PATH_RESPONSE` (RFC 9000
|
||||
* §8.2.4). Caller may retry with another CID or surface the
|
||||
* failure as a connection close.
|
||||
*/
|
||||
fun onPathValidationFailed(newCidSequence: Long) = Unit
|
||||
|
||||
/**
|
||||
* A connection ID belonging to [keyType] (`"local"` for our
|
||||
* source CID, `"peer"` for the peer-issued destination CID)
|
||||
* just became active — i.e. the writer / parser will start
|
||||
* using it for outbound / inbound packets respectively.
|
||||
*/
|
||||
fun onConnectionIdActivated(
|
||||
keyType: String,
|
||||
sequenceNumber: Long,
|
||||
connectionId: ByteArray,
|
||||
) = Unit
|
||||
|
||||
/**
|
||||
* A connection ID was retired (RFC 9000 §19.16 — either we
|
||||
* sent RETIRE_CONNECTION_ID for a peer-issued CID, or the
|
||||
* peer asked us to retire one of ours).
|
||||
*/
|
||||
fun onConnectionIdRetired(
|
||||
keyType: String,
|
||||
sequenceNumber: Long,
|
||||
) = Unit
|
||||
|
||||
/**
|
||||
* No-op observer. Default for production callers — every method
|
||||
* is an empty body that the JIT inlines. No allocation, no I/O.
|
||||
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* 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.NewConnectionIdFrame
|
||||
import com.vitorpamplona.quic.frame.PathChallengeFrame
|
||||
import com.vitorpamplona.quic.frame.PathResponseFrame
|
||||
import com.vitorpamplona.quic.frame.RetireConnectionIdFrame
|
||||
import com.vitorpamplona.quic.frame.decodeFrames
|
||||
import com.vitorpamplona.quic.frame.encodeFrames
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Integration tests for client-initiated path validation + DCID
|
||||
* rotation (RFC 9000 §9). Drives the real client through the
|
||||
* [InMemoryQuicPipe] harness so the assertions cover the parser /
|
||||
* writer / [PathValidator] wiring end-to-end:
|
||||
*
|
||||
* 1. Server sends NEW_CONNECTION_ID — pool fills.
|
||||
* 2. Application calls [QuicConnection.triggerPathMigration].
|
||||
* 3. Client emits PATH_CHALLENGE (drained from
|
||||
* [PathValidator.pendingChallenges]).
|
||||
* 4. Server replies with PATH_RESPONSE carrying the same 8 bytes.
|
||||
* 5. Client switches `destinationConnectionId` and queues a
|
||||
* RETIRE_CONNECTION_ID for the old sequence.
|
||||
* 6. Next outbound application packet uses the NEW DCID and
|
||||
* contains the RETIRE_CONNECTION_ID.
|
||||
*
|
||||
* Out of scope here:
|
||||
* - PTO-driven rotation: covered by the unit-level
|
||||
* [PathValidatorTest.validationTimeoutAfter3PtoTransitionsToFailed]
|
||||
* plus the driver-level threshold constant. Driving a real PTO
|
||||
* through the in-memory pipe would require simulating timer
|
||||
* advancement, which the pipe doesn't model today.
|
||||
*/
|
||||
class ClientPathMigrationTest {
|
||||
@Test
|
||||
fun retireConnectionIdFrameRoundTripsThroughCodec() {
|
||||
val encoded = encodeFrames(listOf(RetireConnectionIdFrame(sequenceNumber = 7L)))
|
||||
val decoded = decodeFrames(encoded)
|
||||
assertEquals(1, decoded.size)
|
||||
val frame = decoded.first() as RetireConnectionIdFrame
|
||||
assertEquals(7L, frame.sequenceNumber)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newConnectionIdFromServerPopulatesPathValidatorPool() =
|
||||
runBlocking {
|
||||
val (client, pipe) = newConnectedClient()
|
||||
val newCid = ByteArray(8) { (0xC0 or it).toByte() }
|
||||
val token = ByteArray(16) { 0x42 }
|
||||
val packet =
|
||||
pipe.buildServerApplicationDatagram(
|
||||
listOf(
|
||||
NewConnectionIdFrame(
|
||||
sequenceNumber = 1L,
|
||||
retirePriorTo = 0L,
|
||||
connectionId = newCid,
|
||||
statelessResetToken = token,
|
||||
),
|
||||
),
|
||||
)!!
|
||||
feedDatagram(client, packet, nowMillis = 0L)
|
||||
|
||||
assertEquals(1, client.pathValidator.unusedCount())
|
||||
assertEquals(listOf(1L), client.pathValidator.unusedSequences())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newConnectionIdWithRetirePriorToGreaterThanSequenceClosesConnection() =
|
||||
runBlocking {
|
||||
val (client, pipe) = newConnectedClient()
|
||||
val packet =
|
||||
pipe.buildServerApplicationDatagram(
|
||||
listOf(
|
||||
NewConnectionIdFrame(
|
||||
sequenceNumber = 1L,
|
||||
retirePriorTo = 5L,
|
||||
connectionId = ByteArray(8) { 0x01 },
|
||||
statelessResetToken = ByteArray(16),
|
||||
),
|
||||
),
|
||||
)!!
|
||||
feedDatagram(client, packet, nowMillis = 0L)
|
||||
// FRAME_ENCODING_ERROR per RFC 9000 §19.15 — closes the connection.
|
||||
assertTrue(
|
||||
client.status == QuicConnection.Status.CLOSING || client.status == QuicConnection.Status.CLOSED,
|
||||
"got ${client.status}; expected CLOSING/CLOSED",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun triggerPathMigrationWithoutSpareCidIsNoOp() =
|
||||
runBlocking {
|
||||
val (client, _) = newConnectedClient()
|
||||
val priorDcid = client.destinationConnectionId
|
||||
val result = client.triggerPathMigration(nowMillis = 0L, currentPtoMillis = 100L)
|
||||
assertEquals(PathMigrationResult.NoSpareCid, result)
|
||||
assertEquals(priorDcid, client.destinationConnectionId, "DCID must not change without a spare CID")
|
||||
assertTrue(client.pathValidator.state is PathValidationState.Idle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullMigrationRoundTripSwitchesDcidAndQueuesRetire() =
|
||||
runBlocking {
|
||||
val (client, pipe) = newConnectedClient()
|
||||
val originalDcid = client.destinationConnectionId
|
||||
val newCidBytes = ByteArray(8) { (0xA0 or it).toByte() }
|
||||
val token = ByteArray(16) { 0x77 }
|
||||
|
||||
// Step 1 — server offers a fresh CID via NEW_CONNECTION_ID.
|
||||
val offer =
|
||||
pipe.buildServerApplicationDatagram(
|
||||
listOf(
|
||||
NewConnectionIdFrame(
|
||||
sequenceNumber = 1L,
|
||||
retirePriorTo = 0L,
|
||||
connectionId = newCidBytes,
|
||||
statelessResetToken = token,
|
||||
),
|
||||
),
|
||||
)!!
|
||||
feedDatagram(client, offer, nowMillis = 0L)
|
||||
assertEquals(1, client.pathValidator.unusedCount())
|
||||
|
||||
// Drain the client's ACK for that packet so subsequent assertions about
|
||||
// outbound contents aren't polluted by leftover ACK frames.
|
||||
drainOutbound(client, nowMillis = 0L)
|
||||
|
||||
// Step 2 — application triggers migration. The validator picks
|
||||
// sequence 1 and queues a PATH_CHALLENGE.
|
||||
val triggered = client.triggerPathMigration(nowMillis = 100L, currentPtoMillis = 1_000L)
|
||||
assertEquals(PathMigrationResult.Started, triggered)
|
||||
assertTrue(client.pathValidator.state is PathValidationState.Validating)
|
||||
|
||||
// Step 3 — the next outbound application packet must carry a
|
||||
// PATH_CHALLENGE.
|
||||
val challengeOut = drainOutbound(client, nowMillis = 100L)
|
||||
assertTrue(challengeOut != null, "client must emit a packet carrying PATH_CHALLENGE")
|
||||
val outboundFrames = pipe.decryptClientApplicationFrames(challengeOut)
|
||||
assertTrue(outboundFrames != null, "client outbound must decrypt with server keys")
|
||||
val challenge = outboundFrames.firstOrNull { it is PathChallengeFrame } as? PathChallengeFrame
|
||||
assertTrue(challenge != null, "outbound must contain PATH_CHALLENGE — got ${outboundFrames.map { it::class.simpleName }}")
|
||||
|
||||
// Step 4 — server echoes the payload in PATH_RESPONSE.
|
||||
val response =
|
||||
pipe.buildServerApplicationDatagram(
|
||||
listOf(PathResponseFrame(challenge.data.copyOf())),
|
||||
)!!
|
||||
feedDatagram(client, response, nowMillis = 200L)
|
||||
|
||||
// Step 5 — DCID must have rotated to the new bytes; the prior
|
||||
// sequence (0) must be queued for RETIRE_CONNECTION_ID.
|
||||
assertContentEquals(newCidBytes, client.destinationConnectionId.bytes, "DCID must rotate to new bytes")
|
||||
assertEquals(1L, client.pathValidator.activeCidSequence)
|
||||
assertTrue(
|
||||
client.pathValidator.pendingRetireSequences.contains(0L),
|
||||
"old sequence number must be queued for retire",
|
||||
)
|
||||
|
||||
// Step 6 — the next outbound packet should carry the
|
||||
// RETIRE_CONNECTION_ID for the old sequence.
|
||||
val retireOut = drainOutbound(client, nowMillis = 200L)
|
||||
assertTrue(retireOut != null, "client must emit a packet after PATH_RESPONSE")
|
||||
val frames = pipe.decryptClientApplicationFrames(retireOut)
|
||||
assertTrue(frames != null, "outbound after rotation must decrypt")
|
||||
val retire = frames.firstOrNull { it is RetireConnectionIdFrame } as? RetireConnectionIdFrame
|
||||
assertTrue(retire != null, "outbound must contain RETIRE_CONNECTION_ID — got ${frames.map { it::class.simpleName }}")
|
||||
assertEquals(0L, retire.sequenceNumber)
|
||||
|
||||
// Sanity: connection still alive after the rotation.
|
||||
assertEquals(QuicConnection.Status.CONNECTED, client.status)
|
||||
// Sanity: original DCID changed.
|
||||
assertTrue(originalDcid != client.destinationConnectionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pathResponseWithMismatchingPayloadDoesNotRotateDcid() =
|
||||
runBlocking {
|
||||
val (client, pipe) = newConnectedClient()
|
||||
val originalDcid = client.destinationConnectionId
|
||||
|
||||
// Seed a spare CID and trigger migration.
|
||||
val offer =
|
||||
pipe.buildServerApplicationDatagram(
|
||||
listOf(
|
||||
NewConnectionIdFrame(
|
||||
sequenceNumber = 1L,
|
||||
retirePriorTo = 0L,
|
||||
connectionId = ByteArray(8) { 0x01 },
|
||||
statelessResetToken = ByteArray(16) { 0x10 },
|
||||
),
|
||||
),
|
||||
)!!
|
||||
feedDatagram(client, offer, nowMillis = 0L)
|
||||
client.triggerPathMigration(nowMillis = 100L, currentPtoMillis = 1_000L)
|
||||
drainOutbound(client, nowMillis = 100L) // emit challenge, drop content
|
||||
|
||||
// Server replies with WRONG payload — must not rotate.
|
||||
val bogusResponse =
|
||||
pipe.buildServerApplicationDatagram(
|
||||
listOf(PathResponseFrame(ByteArray(8) { 0xFF.toByte() })),
|
||||
)!!
|
||||
feedDatagram(client, bogusResponse, nowMillis = 200L)
|
||||
|
||||
assertEquals(originalDcid, client.destinationConnectionId, "mismatched response must NOT rotate DCID")
|
||||
assertTrue(client.pathValidator.state is PathValidationState.Validating, "still validating")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsolicitedPathResponseIsSilentlyDropped() =
|
||||
runBlocking {
|
||||
// Defence-in-depth: a peer (or attacker) sending PATH_RESPONSE
|
||||
// without a preceding PATH_CHALLENGE from us must NOT rotate the
|
||||
// DCID, must NOT crash, and the connection must stay alive.
|
||||
val (client, pipe) = newConnectedClient()
|
||||
val originalDcid = client.destinationConnectionId
|
||||
val packet = pipe.buildServerApplicationDatagram(listOf(PathResponseFrame(ByteArray(8))))!!
|
||||
feedDatagram(client, packet, nowMillis = 0L)
|
||||
assertEquals(originalDcid, client.destinationConnectionId)
|
||||
assertEquals(QuicConnection.Status.CONNECTED, client.status)
|
||||
assertTrue(client.pathValidator.state is PathValidationState.Idle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retireConnectionIdFromServerForUnissuedSequenceIsProtocolViolation() =
|
||||
runBlocking {
|
||||
val (client, pipe) = newConnectedClient()
|
||||
// We only ever issue sequence 0; any seq > 0 is a protocol
|
||||
// violation per RFC 9000 §19.16.
|
||||
val packet = pipe.buildServerApplicationDatagram(listOf(RetireConnectionIdFrame(sequenceNumber = 7L)))!!
|
||||
feedDatagram(client, packet, nowMillis = 0L)
|
||||
assertTrue(
|
||||
client.status == QuicConnection.Status.CLOSING || client.status == QuicConnection.Status.CLOSED,
|
||||
"got ${client.status}",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* 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 kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Unit tests for the in-memory [PathValidator] state machine — the
|
||||
* piece that implements RFC 9000 §5.1 + §8.2 + §9 client-initiated
|
||||
* path validation. Driven without a real socket so the assertions
|
||||
* are about the state-machine contract rather than the on-wire
|
||||
* encoding (see [PathValidationTest] for the integration shape).
|
||||
*/
|
||||
class PathValidatorTest {
|
||||
@Test
|
||||
fun newConnectionIdStoresInPool() {
|
||||
val v = PathValidator()
|
||||
val cid = byteArrayOf(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08)
|
||||
val tok = ByteArray(16) { 0xAA.toByte() }
|
||||
val r = v.recordPeerNewConnectionId(sequenceNumber = 1L, retirePriorTo = 0L, connectionId = cid, statelessResetToken = tok)
|
||||
assertEquals(PathValidator.RecordResult.Stored, r)
|
||||
assertEquals(1, v.unusedCount())
|
||||
assertEquals(listOf(1L), v.unusedSequences())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateOfferWithSameBytesIsIdempotent() {
|
||||
val v = PathValidator()
|
||||
val cid = ByteArray(8) { 0x42 }
|
||||
val tok = ByteArray(16) { 0x99.toByte() }
|
||||
v.recordPeerNewConnectionId(1L, 0L, cid, tok)
|
||||
val r = v.recordPeerNewConnectionId(1L, 0L, cid, tok)
|
||||
assertEquals(PathValidator.RecordResult.Duplicate, r)
|
||||
assertEquals(1, v.unusedCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateOfferWithDifferentBytesIsProtocolViolation() {
|
||||
val v = PathValidator()
|
||||
val seq = 2L
|
||||
v.recordPeerNewConnectionId(seq, 0L, ByteArray(8) { 0x11 }, ByteArray(16) { 0x22 })
|
||||
val r =
|
||||
v.recordPeerNewConnectionId(
|
||||
sequenceNumber = seq,
|
||||
retirePriorTo = 0L,
|
||||
connectionId = ByteArray(8) { 0x33 },
|
||||
statelessResetToken = ByteArray(16) { 0x44 },
|
||||
)
|
||||
assertEquals(PathValidator.RecordResult.DuplicateSequenceMismatch, r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retirePriorToForcesEarlierEntriesIntoRetireQueue() {
|
||||
val v = PathValidator()
|
||||
v.recordPeerNewConnectionId(1L, 0L, ByteArray(8) { 0x01 }, ByteArray(16) { 0x10 })
|
||||
v.recordPeerNewConnectionId(2L, 0L, ByteArray(8) { 0x02 }, ByteArray(16) { 0x20 })
|
||||
// New offer with retirePriorTo = 2 forces sequences 0 and 1 into retirement.
|
||||
val r = v.recordPeerNewConnectionId(3L, retirePriorTo = 2L, ByteArray(8) { 0x03 }, ByteArray(16) { 0x30 })
|
||||
assertEquals(PathValidator.RecordResult.Stored, r)
|
||||
assertEquals(2L, v.retirePriorToWatermark)
|
||||
// Sequence 1 was in the pool — retired. Sequence 0 was the active CID;
|
||||
// PathValidator queues it implicitly via watermark advancement, but
|
||||
// only sequences ≥ retirePriorToWatermark (i.e. ≥ 2) survive in
|
||||
// unusedCids.
|
||||
assertEquals(setOf(2L, 3L), v.unusedSequences().toSet())
|
||||
assertTrue(v.pendingRetireSequences.contains(1L), "seq 1 must be queued for retire")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retirePriorToRegressionIsClampedNotRejected() {
|
||||
// RFC 9000 §19.15: a smaller `retire_prior_to` than what we
|
||||
// previously saw "MUST be treated as the largest one it has
|
||||
// seen" — i.e. clamp, don't error. Reordered NEW_CONNECTION_ID
|
||||
// arrivals are common on the wire and aren't peer protocol
|
||||
// violations.
|
||||
val v = PathValidator()
|
||||
v.recordPeerNewConnectionId(1L, 1L, ByteArray(8) { 0x01 }, ByteArray(16) { 0x10 })
|
||||
val r = v.recordPeerNewConnectionId(2L, 0L, ByteArray(8) { 0x02 }, ByteArray(16) { 0x20 })
|
||||
assertEquals(PathValidator.RecordResult.Stored, r)
|
||||
assertEquals(1L, v.retirePriorToWatermark, "watermark must NOT decrease on a regressed retire_prior_to")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retirePriorToGreaterThanSequenceIsRejected() {
|
||||
val v = PathValidator()
|
||||
val r = v.recordPeerNewConnectionId(sequenceNumber = 1L, retirePriorTo = 5L, ByteArray(8), ByteArray(16))
|
||||
assertEquals(PathValidator.RecordResult.RetirePriorToExceedsSequence, r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun poolFillsUpAndRejectsExcess() {
|
||||
val v = PathValidator(maxUnusedCids = 3)
|
||||
for (i in 1..3) {
|
||||
assertEquals(
|
||||
PathValidator.RecordResult.Stored,
|
||||
v.recordPeerNewConnectionId(i.toLong(), 0L, ByteArray(8) { i.toByte() }, ByteArray(16) { i.toByte() }),
|
||||
)
|
||||
}
|
||||
val r = v.recordPeerNewConnectionId(4L, 0L, ByteArray(8) { 0xAA.toByte() }, ByteArray(16))
|
||||
assertEquals(PathValidator.RecordResult.PoolFull, r)
|
||||
assertEquals(3, v.unusedCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startValidationReturnsNoSpareCidWhenPoolEmpty() {
|
||||
val v = PathValidator()
|
||||
assertEquals(PathMigrationResult.NoSpareCid, v.tryStartValidation(nowMillis = 0L, currentPtoMillis = 100L))
|
||||
assertTrue(v.state is PathValidationState.Idle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startValidationPicksLowestSequenceAndQueuesChallenge() {
|
||||
val supplied = byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte(), 0x12, 0x34, 0x56, 0x78)
|
||||
val v = PathValidator(challengePayloadFactory = { supplied.copyOf() })
|
||||
v.recordPeerNewConnectionId(1L, 0L, ByteArray(8) { 0x01 }, ByteArray(16) { 0x10 })
|
||||
v.recordPeerNewConnectionId(2L, 0L, ByteArray(8) { 0x02 }, ByteArray(16) { 0x20 })
|
||||
assertEquals(PathMigrationResult.Started, v.tryStartValidation(nowMillis = 0L, currentPtoMillis = 200L))
|
||||
val state = v.state as PathValidationState.Validating
|
||||
assertEquals(1L, state.newCidSequence, "lowest sequence number must be picked first")
|
||||
assertContentEquals(supplied, state.challengeData)
|
||||
assertEquals(1, v.pendingChallenges.size)
|
||||
assertContentEquals(supplied, v.pendingChallenges.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startValidationDoesNothingIfAlreadyInProgress() {
|
||||
val v = PathValidator(challengePayloadFactory = { ByteArray(8) { 0x07 } })
|
||||
v.recordPeerNewConnectionId(1L, 0L, ByteArray(8) { 0x01 }, ByteArray(16))
|
||||
v.recordPeerNewConnectionId(2L, 0L, ByteArray(8) { 0x02 }, ByteArray(16))
|
||||
assertEquals(PathMigrationResult.Started, v.tryStartValidation(0L, 100L))
|
||||
// Second trigger before resolution — must not pick a second CID.
|
||||
assertEquals(PathMigrationResult.AlreadyInProgress, v.tryStartValidation(0L, 100L))
|
||||
// The second CID is still in the pool; only one challenge is in flight.
|
||||
assertEquals(1, v.pendingChallenges.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pathResponseWithMatchingPayloadValidatesAndQueuesRetire() {
|
||||
val payload = ByteArray(8) { 0x55 }
|
||||
val v = PathValidator(challengePayloadFactory = { payload.copyOf() })
|
||||
v.recordPeerNewConnectionId(1L, 0L, ByteArray(8) { 0xAA.toByte() }, ByteArray(16) { 0xBB.toByte() })
|
||||
v.tryStartValidation(0L, 100L)
|
||||
val outcome = v.applyPathResponse(payload)
|
||||
assertTrue(outcome is PathValidator.ValidationOutcome.Validated, "got $outcome")
|
||||
assertEquals(1L, outcome.newSequence)
|
||||
assertEquals(0L, outcome.retiredSequence)
|
||||
assertContentEquals(ByteArray(8) { 0xAA.toByte() }, outcome.newConnectionIdBytes)
|
||||
assertEquals(1L, v.activeCidSequence)
|
||||
assertTrue(v.state is PathValidationState.Succeeded)
|
||||
assertTrue(v.pendingRetireSequences.contains(0L), "old sequence must be queued for RETIRE_CONNECTION_ID")
|
||||
assertEquals(1L, v.successfulValidations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pathResponseWithMismatchedPayloadIsIgnored() {
|
||||
val v = PathValidator(challengePayloadFactory = { ByteArray(8) { 0x77 } })
|
||||
v.recordPeerNewConnectionId(1L, 0L, ByteArray(8) { 0x01 }, ByteArray(16))
|
||||
v.tryStartValidation(0L, 100L)
|
||||
val outcome = v.applyPathResponse(ByteArray(8) { 0x11 })
|
||||
assertEquals(PathValidator.ValidationOutcome.PayloadMismatch, outcome)
|
||||
assertTrue(v.state is PathValidationState.Validating, "still validating; mismatched response is dropped")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pathResponseWithoutOutstandingChallengeIsIgnored() {
|
||||
val v = PathValidator()
|
||||
val outcome = v.applyPathResponse(ByteArray(8))
|
||||
assertEquals(PathValidator.ValidationOutcome.NotValidating, outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validationTimeoutAfter3PtoTransitionsToFailed() {
|
||||
val v = PathValidator(challengePayloadFactory = { ByteArray(8) { 0x44 } })
|
||||
v.recordPeerNewConnectionId(1L, 0L, ByteArray(8) { 0x01 }, ByteArray(16))
|
||||
val pto = 1_000L
|
||||
v.tryStartValidation(nowMillis = 0L, currentPtoMillis = pto)
|
||||
|
||||
assertNull(v.checkValidationTimeout(nowMillis = pto * 2L), "still within budget")
|
||||
val abandoned = v.checkValidationTimeout(nowMillis = pto * 4L)
|
||||
assertTrue(abandoned != null, "validation must time out at 3*PTO")
|
||||
assertEquals(1L, abandoned.newCidSequence)
|
||||
assertTrue(v.state is PathValidationState.Failed)
|
||||
assertEquals(1L, v.failedValidations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acknowledgeTerminalReturnsToIdle() {
|
||||
val v = PathValidator(challengePayloadFactory = { ByteArray(8) { 0x33 } })
|
||||
v.recordPeerNewConnectionId(1L, 0L, ByteArray(8), ByteArray(16))
|
||||
v.tryStartValidation(0L, 100L)
|
||||
v.applyPathResponse(ByteArray(8) { 0x33 })
|
||||
assertTrue(v.state is PathValidationState.Succeeded)
|
||||
v.acknowledgeTerminal()
|
||||
assertTrue(v.state is PathValidationState.Idle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleOfferBelowWatermarkQueuesRetireWithoutStoring() {
|
||||
val v = PathValidator()
|
||||
// Seed watermark to 5 via a normal offer with retirePriorTo = 5.
|
||||
v.recordPeerNewConnectionId(5L, 5L, ByteArray(8) { 0x05 }, ByteArray(16) { 0x50 })
|
||||
assertEquals(5L, v.retirePriorToWatermark)
|
||||
// Now a reordered offer arrives with seq=3 and retirePriorTo=3
|
||||
// (the value the peer sent before raising it to 5). Even
|
||||
// though seq=3 ≥ retirePriorTo=3 (so it isn't a frame error),
|
||||
// the watermark we already raised says seq 3 has been retired.
|
||||
// The validator clamps retirePriorTo to the watermark and
|
||||
// detects that seq is now below the watermark — so the entry
|
||||
// is queued for RETIRE_CONNECTION_ID rather than stored.
|
||||
val r = v.recordPeerNewConnectionId(3L, 3L, ByteArray(8) { 0x03 }, ByteArray(16) { 0x30 })
|
||||
assertEquals(PathValidator.RecordResult.AlreadyRetired, r)
|
||||
assertTrue(v.pendingRetireSequences.contains(3L), "stale offer must be retired immediately")
|
||||
assertEquals(1, v.unusedCount(), "stale entry must not be stored in pool")
|
||||
}
|
||||
}
|
||||
+2
@@ -135,6 +135,8 @@ class RecoveryTokenTest {
|
||||
is RecoveryToken.ResetStream -> "rs:${it.streamId}:${it.errorCode}:${it.finalSize}"
|
||||
is RecoveryToken.StopSending -> "ss:${it.streamId}:${it.errorCode}"
|
||||
is RecoveryToken.NewConnectionId -> "ncid:${it.sequenceNumber}"
|
||||
is RecoveryToken.PathChallenge -> "pc"
|
||||
is RecoveryToken.RetireConnectionId -> "rcid:${it.sequenceNumber}"
|
||||
}
|
||||
}
|
||||
assertEquals(
|
||||
|
||||
Reference in New Issue
Block a user