Merge branch 'worktree-agent-a9d8336181fce16eb' into claude/research-quic-libraries-hH1Dc

This commit is contained in:
Claude
2026-05-07 00:17:49 +00:00
7 changed files with 521 additions and 147 deletions
@@ -26,7 +26,8 @@ import com.vitorpamplona.quic.stream.SendBuffer
/** Per-encryption-level state owned by [QuicConnection]. */
class LevelState {
val pnSpace = PacketNumberSpaceState()
var pnSpace = PacketNumberSpaceState()
private set
var ackTracker =
com.vitorpamplona.quic.recovery
@@ -124,35 +125,32 @@ class LevelState {
}
/**
* RFC 9000 §17.2.5 Retry: install fresh protection material + re-seed
* the CRYPTO send buffer from [fresh] in place. Used by
* [QuicConnection.applyRetry] to re-init Initial-level state on the
* post-Retry DCID without the caller having to swap the level
* reference (which is a `val`).
* RFC 9000 §6: reset every per-level field to a constructor-fresh
* state, then install [sendProtection] / [receiveProtection] keyed
* to the post-VN destination CID.
*
* Re-zeros the PN counter (Initial-level PN restarts at 0 on the new
* keys per §17.2.5.2) and clears the keysDiscarded latch so the
* writer can build packets again.
*
* Caller must already have called [discardKeys] (or otherwise know
* the existing state is dead) — this method does not free the
* existing protection on its own; the assumption is the caller is
* replacing it.
* Differs from [discardKeys] in that this re-arms the level for
* a fresh handshake — caller (
* [QuicConnection.applyVersionNegotiation]) re-enqueues the cached
* ClientHello onto [cryptoSend] immediately afterwards, so the
* next outbound drain emits a v1 Initial with PN=0 and the same
* TLS bytes the original Initial carried.
*/
internal fun restoreFromRetry(fresh: LevelState) {
sendProtection = fresh.sendProtection
receiveProtection = fresh.receiveProtection
cryptoSend = fresh.cryptoSend
cryptoReceive = fresh.cryptoReceive
internal fun resetForVersionNegotiation(
sendProtection: PacketProtection,
receiveProtection: PacketProtection,
) {
pnSpace = PacketNumberSpaceState()
ackTracker =
com.vitorpamplona.quic.recovery
.AckTracker()
cryptoSend = SendBuffer()
cryptoReceive = ReceiveBuffer()
sentPackets.clear()
largestAckedPn = null
largestAckedSentTimeMs = null
// Clear the latch — keys are alive again on the new DCID.
keysDiscarded = false
// Restart the PN space on the new keys (RFC 9000 §17.2.5.2).
pnSpace.resetForRetry()
this.sendProtection = sendProtection
this.receiveProtection = receiveProtection
}
}
@@ -24,6 +24,7 @@ import com.vitorpamplona.quic.crypto.AesEcbHeaderProtection
import com.vitorpamplona.quic.crypto.InitialSecrets
import com.vitorpamplona.quic.crypto.PlatformAesOneBlock
import com.vitorpamplona.quic.crypto.bestAes128GcmAead
import com.vitorpamplona.quic.packet.QuicVersion
import com.vitorpamplona.quic.stream.QuicStream
import com.vitorpamplona.quic.stream.StreamId
import com.vitorpamplona.quic.tls.TlsClient
@@ -74,28 +75,54 @@ class QuicConnection(
},
val alpnList: List<ByteArray> = listOf(TlsConstants.ALPN_H3),
/**
* Optional second listener invoked after the connection's own
* key-installation listener. Used by the interop runner endpoint to
* dump SSLKEYLOG lines so Wireshark can decrypt captured pcaps.
* Default `null` keeps production callers unaffected.
* Version this connection puts in the FIRST Initial it sends. Defaults
* to [QuicVersion.V1]; the interop runner sets it to
* [QuicVersion.FORCE_VERSION_NEGOTIATION] for the `versionnegotiation`
* testcase, which drives the client through the RFC 9000 §6 VN flow.
*
* On a successful Version Negotiation (server replies with a list of
* versions it supports including v1), [applyVersionNegotiation] resets
* [currentVersion] to v1 and the writer's subsequent Initial packets
* carry v1 in the long-header version field.
*/
val extraSecretsListener: TlsSecretsListener? = null,
/**
* TLS cipher suites to offer in the ClientHello. Override to e.g.
* `intArrayOf(TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256)` for the
* `chacha20` interop testcase. Default matches [TlsClient]'s default.
*/
val cipherSuites: IntArray =
intArrayOf(
TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256,
TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256,
),
val initialVersion: Int = QuicVersion.V1,
) {
val sourceConnectionId: ConnectionId = ConnectionId.random(8)
var destinationConnectionId: ConnectionId = ConnectionId.random(8)
internal set
val originalDestinationConnectionId: ConnectionId = destinationConnectionId
/**
* Version the writer stamps into the long-header version field on the
* NEXT outbound Initial / Handshake packet. Initialised to
* [initialVersion]; switched to [QuicVersion.V1] by
* [applyVersionNegotiation] after a successful VN exchange.
*/
@Volatile
var currentVersion: Int = initialVersion
internal set
/**
* RFC 9000 §6.2: a client MUST consume at most one VN response per
* connection. After [applyVersionNegotiation] runs once, any further
* inbound VN packet is dropped silently — the latch defends against a
* mid-handshake attacker who replays an old VN datagram to wedge us
* into an endless re-negotiation loop.
*/
@Volatile
var vnConsumed: Boolean = false
internal set
/**
* Cached ClientHello bytes captured by [start]. Re-enqueued onto the
* fresh Initial-level [LevelState.cryptoSend] when
* [applyVersionNegotiation] resets the encryption level so the new
* Initial datagram still carries a valid TLS handshake. Without this
* the reset wipes the bytes that [TlsClient] already enqueued and the
* post-VN Initial would carry an empty CRYPTO frame.
*/
private var originalClientHello: ByteArray? = null
val initial = LevelState()
val handshake = LevelState()
val application = LevelState()
@@ -425,123 +452,83 @@ class QuicConnection(
fun start() {
tls.start()
// Drain ClientHello bytes into the Initial-level CRYPTO send buffer.
// Capture them too so [applyRetry] can re-queue the ClientHello onto
// the new Initial keys after a Retry rolls our DCID. The TLS state
// machine only emits ClientHello once — without a snapshot we'd have
// no way to resend it on the new keys (RFC 9000 §17.2.5.2).
tls.pollOutbound(TlsClient.Level.INITIAL)?.let { bytes ->
originalClientHello = bytes
initial.cryptoSend.enqueue(bytes)
// Cache the bytes so [applyVersionNegotiation] can re-enqueue them
// onto a fresh cryptoSend after resetting Initial-level state.
// Cannot re-pollOutbound — the queue is destructive.
tls.pollOutbound(TlsClient.Level.INITIAL)?.let {
originalClientHello = it
initial.cryptoSend.enqueue(it)
}
}
/**
* RFC 9000 §17.2.5 + RFC 9001 §5.8 Retry handling.
* Apply a Version Negotiation packet (RFC 9000 §6) received from the
* server. The client offered [initialVersion]; the server replies with
* a list of versions it supports. We pick [QuicVersion.V1] from the
* list, regenerate the destination CID + Initial keys, reset the
* Initial encryption level, and re-emit the cached ClientHello so the
* next drain produces a valid v1 Initial packet.
*
* Called by the parser when a long-header packet of type Retry is seen.
* [originalPacketBytes] is the Retry packet's exact on-wire bytes
* (needed because RFC 9001 §5.8's integrity-tag AAD covers the entire
* Retry header including the unused low 4 bits of the first byte —
* which the server is free to set however).
* RFC 9000 §6.2 invariants enforced here:
* - The supported_versions list MUST NOT contain
* [initialVersion] — including it would mean the server received
* our offer and STILL replied with VN, which is a downgrade signal.
* Drop the packet (treat as no-op).
* - At most one VN per connection (latched via [vnConsumed]).
* - If we cannot speak any of the offered versions, fail the
* handshake.
*
* Steps applied on a verified Retry:
*
* 1. Verify the integrity tag using [originalDestinationConnectionId]
* as the AAD prefix (the DCID we used in our first Initial).
* A bad tag → silently drop, no state changes (RFC 9001 §5.8).
* 2. Drop any second / subsequent Retry (RFC 9000 §17.2.5.2 caps a
* connection to one Retry).
* 3. Swap our DCID to the Retry's SCID — this is what the server
* now expects to see on every Initial we send.
* 4. Re-derive Initial-level packet protection from the new DCID
* and reinstall it on [initial].
* 5. Reset Initial-level state: PN counter back to 0, sentPackets
* cleared (the original PN=0 ClientHello is irrelevant — it's
* not retransmittable and the server already discarded its
* receiving state), inbound ack-tracker reset (no Initials have
* arrived yet on the new keys).
* 6. Re-queue the cached ClientHello bytes onto the fresh Initial
* cryptoSend so the writer's next drain sends a Token-bearing
* Initial.
* 7. Store the Retry token so [QuicConnectionWriter] writes it into
* the Initial header's Token field on the next emit.
* 8. Latch [retryConsumed] so a second Retry is dropped.
*
* Returns true if the Retry was applied, false if dropped (bad tag,
* second-retry, or no [originalClientHello] captured because [start]
* hasn't been called).
*
* Caller is the parser, holding nothing — Retry handling occurs
* before any frame dispatch and before the connection lock is
* acquired by application paths. The mutated fields ([retryToken],
* [retryConsumed], [destinationConnectionId], [initial]) are all
* read by single-threaded loops (parser → writer in the driver) so
* extra synchronization is unnecessary; [retryToken] and
* [retryConsumed] are `@Volatile` for cross-thread visibility on
* the diagnostic side.
* Caller MUST hold [lock] (the parser already does).
*/
internal fun applyRetry(
retryPacket: com.vitorpamplona.quic.packet.RetryPacket,
originalPacketBytes: ByteArray,
): Boolean {
// Step 2 ordering: an attacker who knows our original DCID and
// observed our first Initial could craft a "second Retry" with a
// forged tag computed over a freshly-randomised SCID. Honoring
// it would let them force us onto attacker-chosen keys. Rejecting
// BEFORE the integrity check would cost cycles on every spoofed
// tag — but more importantly, RFC 9000 §17.2.5.2 says a second
// Retry is dropped REGARDLESS of validity.
if (retryConsumed) return false
// Step 1.
if (!retryPacket.verifyIntegrityTag(originalPacketBytes, originalDestinationConnectionId.bytes)) {
return false
internal fun applyVersionNegotiation(supportedVersions: List<Int>) {
// RFC 9000 §6.2: a second VN must be ignored.
if (vnConsumed) return
// Anti-downgrade: server-claimed support for the version we
// already offered indicates VN replay / spoof. Drop silently.
if (supportedVersions.contains(initialVersion)) return
// Pick a version we can speak. Today that's only v1.
if (!supportedVersions.contains(QuicVersion.V1)) {
signalHandshakeFailed(
QuicVersionNegotiationException(
"VERSION_NEGOTIATION: server offered ${supportedVersions.map { v -> "0x" + v.toUInt().toString(16) }}, " +
"client only supports 0x" + QuicVersion.V1.toUInt().toString(16),
),
)
markClosedExternally("VERSION_NEGOTIATION: no mutually supported version")
return
}
val savedClientHello = originalClientHello ?: return false
// Step 3.
destinationConnectionId = retryPacket.scid
// Step 4.
val proto = InitialSecrets.derive(destinationConnectionId.bytes)
// Latch BEFORE reset so a re-entrant inbound VN during the reset
// window is rejected by the early-return at the top.
vnConsumed = true
// Generate a fresh destination CID. RFC 9000 §6.2 doesn't strictly
// require this (the server hasn't indexed our CID with any state
// since its only response was VN), but it matches what reference
// implementations do and keeps the post-VN connection
// cryptographically isolated from the pre-VN exchange.
val newDcid = ConnectionId.random(8)
destinationConnectionId = newDcid
// Reset Initial-level state in place: fresh PN space (next
// allocateOutbound returns 0), fresh ackTracker, fresh
// cryptoSend / cryptoReceive, fresh sentPackets retention.
// Writer + parser only ever reach the level via [conn.initial],
// so we mutate the fields rather than swap the instance.
val proto = InitialSecrets.derive(newDcid.bytes)
val hp = AesEcbHeaderProtection(PlatformAesOneBlock)
val freshInitial = LevelState()
freshInitial.sendProtection =
val newSend =
PacketProtection(bestAes128GcmAead(proto.clientKey), proto.clientKey, proto.clientIv, hp, proto.clientHp)
freshInitial.receiveProtection =
val newReceive =
PacketProtection(bestAes128GcmAead(proto.serverKey), proto.serverKey, proto.serverIv, hp, proto.serverHp)
// Re-enqueue the captured ClientHello onto the fresh CRYPTO send buffer
// so the writer's next drain emits an Initial with PN=0 carrying the
// ClientHello CRYPTO + Retry token. The fresh LevelState gives us PN=0
// automatically (no rewind dance needed).
freshInitial.cryptoSend.enqueue(savedClientHello)
// Step 5: replace the LevelState wholesale via reflection-ish path —
// [initial] is a `val`, so instead of swapping the reference we copy
// the fields. Symmetric to how `discardKeys()` resets in place.
replaceInitialState(freshInitial)
initial.resetForVersionNegotiation(sendProtection = newSend, receiveProtection = newReceive)
// Re-enqueue the ClientHello so the next drainOutbound emits a v1
// Initial datagram with the same TLS handshake the original carried.
originalClientHello?.let { initial.cryptoSend.enqueue(it) }
// Steps 6 + 7 + 8.
retryToken = retryPacket.retryToken
retryConsumed = true
return true
}
/**
* Reinstall the Initial [LevelState] in-place from [fresh]. We can't
* reassign [initial] (it's a `val`); we mirror the logic
* [LevelState.discardKeys] uses — drop everything and re-seed from the
* fresh state's protection. The fresh state's CRYPTO send buffer is
* also taken over so the writer drains the re-queued ClientHello.
*/
private fun replaceInitialState(fresh: LevelState) {
// Reset the existing LevelState's protection + buffers + ack tracking.
// We use [LevelState.discardKeys] to clear, then re-attach the freshly
// derived protection — this avoids leaking sentPackets / cryptoSend
// state from the pre-Retry Initial.
initial.discardKeys()
// discardKeys latches `keysDiscarded = true`; reverse that via a fresh
// LevelState swap. Since we can't replace the reference we use a small
// backdoor — restoreFromRetry on LevelState — to reinstall fields and
// clear the latch.
initial.restoreFromRetry(fresh)
// Switch the writer's stamp to v1 so the next Initial / Handshake
// long-header carries the right version.
currentVersion = QuicVersion.V1
}
private fun buildLocalTransportParameters(): TransportParameters =
@@ -1167,6 +1154,17 @@ class QuicStreamLimitException(
message: String,
) : RuntimeException(message)
/**
* RFC 9000 §6: the server replied with a Version Negotiation packet but
* the supported_versions list does not contain any version the client can
* speak (today: only [com.vitorpamplona.quic.packet.QuicVersion.V1]).
* The handshake is unrecoverable — caller must treat the connection as
* permanently failed.
*/
class QuicVersionNegotiationException(
message: String,
) : RuntimeException(message)
/**
* Diagnostic snapshot of [QuicConnection]'s flow-control accounting at
* a single moment. Returned by [QuicConnection.flowControlSnapshot].
@@ -39,7 +39,7 @@ import com.vitorpamplona.quic.frame.StreamFrame
import com.vitorpamplona.quic.frame.decodeFrames
import com.vitorpamplona.quic.packet.LongHeaderPacket
import com.vitorpamplona.quic.packet.LongHeaderType
import com.vitorpamplona.quic.packet.RetryPacket
import com.vitorpamplona.quic.packet.QuicVersion
import com.vitorpamplona.quic.packet.ShortHeaderPacket
import com.vitorpamplona.quic.stream.StreamId
import com.vitorpamplona.quic.tls.TlsClient
@@ -63,6 +63,23 @@ fun feedDatagram(
val first = datagram[offset].toInt() and 0xFF
val isLong = (first and 0x80) != 0
if (isLong) {
// RFC 9000 §17.2.1: a Version Negotiation packet has the form
// bit set but version=0. Detect it BEFORE peekHeader, which
// assumes a v1-shaped layout (token, length fields).
if (offset + 5 <= datagram.size) {
val version =
((datagram[offset + 1].toInt() and 0xFF) shl 24) or
((datagram[offset + 2].toInt() and 0xFF) shl 16) or
((datagram[offset + 3].toInt() and 0xFF) shl 8) or
(datagram[offset + 4].toInt() and 0xFF)
if (version == QuicVersion.VERSION_NEGOTIATION) {
feedVersionNegotiationPacket(conn, datagram, offset)
// VN packets MUST be the only packet in their datagram
// (RFC 9000 §17.2.1: no length field, body is rest of
// datagram). Stop walking.
return
}
}
// Per RFC 9001 §5.5, drop ONLY the failing packet, not subsequent
// coalesced ones. Use peekHeader to advance over a packet whose
// payload we couldn't decrypt; only break the loop on a header
@@ -79,6 +96,66 @@ fun feedDatagram(
}
}
/**
* RFC 9000 §17.2.1 / §6: parse a Version Negotiation packet and dispatch
* to [QuicConnection.applyVersionNegotiation].
*
* Wire layout:
*
* first byte (form=1, unused 4 bits — server fills with random)
* version (4 bytes, fixed at 0x00000000)
* dcid_len (1 byte) + dcid (dcid_len bytes)
* scid_len (1 byte) + scid (scid_len bytes)
* supported_versions: sequence of 32-bit big-endian version numbers,
* consuming the rest of the UDP datagram.
*
* VN packets are NOT AEAD-protected — there's nothing to decrypt. We do
* a minimal sanity check (DCID matches our SCID) and then hand the
* version list off. Malformed packets are dropped silently per RFC 9000
* §17.2.1 ("an endpoint MUST NOT send … in response to a Version
* Negotiation packet").
*/
private fun feedVersionNegotiationPacket(
conn: QuicConnection,
datagram: ByteArray,
offset: Int,
) {
// Layout fields above; bail early if any read would run past the
// end of the datagram (truncated VN — drop silently).
var pos = offset + 5
if (pos >= datagram.size) return
val dcidLen = datagram[pos].toInt() and 0xFF
pos += 1
if (dcidLen > 20 || pos + dcidLen > datagram.size) return
val dcid = datagram.copyOfRange(pos, pos + dcidLen)
pos += dcidLen
if (pos >= datagram.size) return
val scidLen = datagram[pos].toInt() and 0xFF
pos += 1
if (scidLen > 20 || pos + scidLen > datagram.size) return
pos += scidLen // SCID body — not validated; servers may pick anything.
// RFC 9000 §6.1: the VN packet's destination CID MUST equal the SCID
// the client put in its first Initial. Mismatch ⇒ probable spoof
// from an off-path attacker; drop without state change.
if (!dcid.contentEquals(conn.sourceConnectionId.bytes)) return
val versionsRegion = datagram.size - pos
if (versionsRegion <= 0 || versionsRegion % 4 != 0) return // malformed
val supportedVersions = ArrayList<Int>(versionsRegion / 4)
while (pos + 4 <= datagram.size) {
val v =
((datagram[pos].toInt() and 0xFF) shl 24) or
((datagram[pos + 1].toInt() and 0xFF) shl 16) or
((datagram[pos + 2].toInt() and 0xFF) shl 8) or
(datagram[pos + 3].toInt() and 0xFF)
supportedVersions += v
pos += 4
}
conn.applyVersionNegotiation(supportedVersions)
}
private fun feedLongHeaderPacket(
conn: QuicConnection,
datagram: ByteArray,
@@ -40,7 +40,6 @@ import com.vitorpamplona.quic.frame.encodeFrames
import com.vitorpamplona.quic.packet.LongHeaderPacket
import com.vitorpamplona.quic.packet.LongHeaderPlaintextPacket
import com.vitorpamplona.quic.packet.LongHeaderType
import com.vitorpamplona.quic.packet.QuicVersion
import com.vitorpamplona.quic.packet.ShortHeaderPacket
import com.vitorpamplona.quic.packet.ShortHeaderPlaintextPacket
@@ -329,7 +328,7 @@ private fun buildLongHeaderPacket(
return LongHeaderPacket.build(
LongHeaderPlaintextPacket(
type = type,
version = QuicVersion.V1,
version = conn.currentVersion,
dcid = conn.destinationConnectionId,
scid = conn.sourceConnectionId,
packetNumber = pn,
@@ -480,7 +479,7 @@ private fun buildLongHeaderFromFrames(
LongHeaderPacket.build(
LongHeaderPlaintextPacket(
type = type,
version = QuicVersion.V1,
version = conn.currentVersion,
dcid = conn.destinationConnectionId,
scid = conn.sourceConnectionId,
token = token,
@@ -42,4 +42,17 @@ enum class LongHeaderType(
object QuicVersion {
const val V1: Int = 0x00000001
const val VERSION_NEGOTIATION: Int = 0x00000000
/**
* RFC 9000 §6 / interop runner convention: a "force VN" version
* number that no QUIC server is allowed to support. Sending this
* in the first Initial guarantees the server replies with a
* Version Negotiation packet listing the versions it actually
* supports. The interop runner's `versionnegotiation` testcase
* uses this to drive the client through the VN code path.
*
* Value 0x1a2a3a4a is conventional for this purpose; any
* non-spec-assigned 32-bit value would work equally well.
*/
const val FORCE_VERSION_NEGOTIATION: Int = 0x1a2a3a4a
}
@@ -0,0 +1,272 @@
/*
* 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.packet.QuicVersion
import com.vitorpamplona.quic.tls.PermissiveCertificateValidator
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Version Negotiation flow per RFC 9000 §6.
*
* The runner's `versionnegotiation` testcase has the server reply to the
* client's first Initial with a VN packet whose supported_versions list
* contains v1 (and not the version we offered). The client must:
*
* 1. Validate the VN packet (DCID echoed = our SCID, list does NOT
* include the version we offered).
* 2. Pick a version it can speak from the list (we only support v1).
* 3. Generate a fresh DCID, re-derive Initial keys, reset Initial PN
* space, re-emit the cached ClientHello, and switch the writer's
* stamped version to v1.
* 4. Latch `vnConsumed` so a second VN is dropped.
*
* Failure modes also covered:
*
* - Downgrade defense: VN that lists the version we offered is dropped
* (RFC 9000 §6.2 anti-replay).
* - Unsupported list: VN whose supported_versions doesn't include any
* version we can speak fails the handshake with
* [QuicVersionNegotiationException].
* - Second-VN: after one consumed VN, any subsequent VN is dropped
* even if otherwise valid.
*/
class VersionNegotiationTest {
/**
* Synthesize a VN packet on the wire (RFC 9000 §17.2.1):
* first byte : 0x80 | <random low bits, set to 0 here>
* version : 0x00000000
* dcid_len + dcid (echoes the client's source CID per §6.1)
* scid_len + scid (server picks)
* supported_versions: 32-bit big-endian numbers, one per offered version
*/
private fun encodeVnPacket(
echoedDcid: ConnectionId,
serverScid: ConnectionId,
supportedVersions: List<Int>,
): ByteArray {
val out = ArrayList<Byte>()
out += 0x80.toByte() // form bit set; remaining bits unused / random
// version=0
out += 0x00.toByte()
out += 0x00.toByte()
out += 0x00.toByte()
out += 0x00.toByte()
out += echoedDcid.length.toByte()
for (b in echoedDcid.bytes) out += b
out += serverScid.length.toByte()
for (b in serverScid.bytes) out += b
for (v in supportedVersions) {
out += ((v ushr 24) and 0xFF).toByte()
out += ((v ushr 16) and 0xFF).toByte()
out += ((v ushr 8) and 0xFF).toByte()
out += (v and 0xFF).toByte()
}
return out.toByteArray()
}
private fun newClient(initialVersion: Int) =
QuicConnection(
serverName = "example.test",
config = QuicConnectionConfig(),
tlsCertificateValidator = PermissiveCertificateValidator(),
initialVersion = initialVersion,
)
@Test
fun happy_path_vn_switches_to_v1_and_resets_dcid_pn_keys() {
val client = newClient(QuicVersion.FORCE_VERSION_NEGOTIATION)
client.start()
// The first Initial would be stamped with the forced version.
assertEquals(QuicVersion.FORCE_VERSION_NEGOTIATION, client.currentVersion)
val originalDcid = client.destinationConnectionId
val serverScid = ConnectionId.random(8)
val vn =
encodeVnPacket(
echoedDcid = client.sourceConnectionId,
serverScid = serverScid,
supportedVersions = listOf(QuicVersion.V1),
)
feedDatagram(client, vn, nowMillis = 0L)
assertTrue(client.vnConsumed, "valid VN must latch vnConsumed")
assertEquals(QuicVersion.V1, client.currentVersion, "currentVersion must switch to v1")
assertNotEquals(
originalDcid,
client.destinationConnectionId,
"DCID must be regenerated (RFC 9000 §6 fresh handshake)",
)
// Initial PN space is fresh — next outbound allocate returns 0.
assertEquals(
0L,
client.initial.pnSpace.nextPacketNumber,
"Initial PN space must reset to 0 after VN",
)
// The cached ClientHello must be re-queued so the next drain emits a v1
// Initial with the same handshake bytes.
val drained = drainOutbound(client, nowMillis = 1L)
assertTrue(drained != null && drained.isNotEmpty(), "post-VN drain must emit a fresh Initial")
// Wire-level check: bytes 1..4 of the long-header packet are the version.
val versionOnWire =
((drained[1].toInt() and 0xFF) shl 24) or
((drained[2].toInt() and 0xFF) shl 16) or
((drained[3].toInt() and 0xFF) shl 8) or
(drained[4].toInt() and 0xFF)
assertEquals(
QuicVersion.V1,
versionOnWire,
"post-VN Initial datagram must carry v1 in the long-header version field",
)
}
@Test
fun downgrade_defense_vn_listing_offered_version_is_dropped() {
val client = newClient(QuicVersion.FORCE_VERSION_NEGOTIATION)
client.start()
val originalDcid = client.destinationConnectionId
val originalVersion = client.currentVersion
val vn =
encodeVnPacket(
echoedDcid = client.sourceConnectionId,
serverScid = ConnectionId.random(8),
// The list MUST NOT contain the version we offered. If it does,
// it's a probable replay/spoof — RFC 9000 §6.2 says drop.
supportedVersions = listOf(QuicVersion.V1, QuicVersion.FORCE_VERSION_NEGOTIATION),
)
feedDatagram(client, vn, nowMillis = 0L)
assertFalse(client.vnConsumed, "anti-replay: VN containing offered version must be dropped")
assertEquals(originalVersion, client.currentVersion, "currentVersion unchanged")
assertEquals(originalDcid, client.destinationConnectionId, "DCID unchanged")
}
@Test
fun unsupported_list_fails_handshake() {
val client = newClient(QuicVersion.FORCE_VERSION_NEGOTIATION)
client.start()
// quic-go's force-VN test version. We don't support it, so the
// handshake must fail.
val vn =
encodeVnPacket(
echoedDcid = client.sourceConnectionId,
serverScid = ConnectionId.random(8),
supportedVersions = listOf(0x6b3343cf),
)
feedDatagram(client, vn, nowMillis = 0L)
// Unsupported list ⇒ handshake fails BEFORE the latch is set.
// vnConsumed therefore stays false; the connection is forced
// closed via signalHandshakeFailed → markClosedExternally.
assertFalse(client.vnConsumed, "vnConsumed only latches on successful version pick")
assertEquals(
QuicConnection.Status.CLOSED,
client.status,
"no mutually-supported version must close the connection",
)
}
@Test
fun second_vn_is_ignored_after_first() {
val client = newClient(QuicVersion.FORCE_VERSION_NEGOTIATION)
client.start()
// First VN: valid, switches to v1.
val serverScid1 = ConnectionId.random(8)
feedDatagram(
client,
encodeVnPacket(
echoedDcid = client.sourceConnectionId,
serverScid = serverScid1,
supportedVersions = listOf(QuicVersion.V1),
),
nowMillis = 0L,
)
assertTrue(client.vnConsumed)
assertEquals(QuicVersion.V1, client.currentVersion)
val dcidAfterFirstVn = client.destinationConnectionId
// Second VN: even if structurally fine, must be dropped. We craft
// one whose supported list does NOT include the version we
// ORIGINALLY offered — so it would otherwise look valid.
feedDatagram(
client,
encodeVnPacket(
echoedDcid = client.sourceConnectionId,
serverScid = ConnectionId.random(8),
supportedVersions = listOf(QuicVersion.V1),
),
nowMillis = 1L,
)
assertEquals(
dcidAfterFirstVn,
client.destinationConnectionId,
"second VN must NOT regenerate the DCID",
)
assertEquals(QuicVersion.V1, client.currentVersion, "current version stays at v1")
}
@Test
fun vn_with_dcid_mismatch_is_dropped() {
// Defensive: a VN whose echoed DCID doesn't equal our SCID is
// probably an off-path attacker's spoof — drop without state change.
val client = newClient(QuicVersion.FORCE_VERSION_NEGOTIATION)
client.start()
val vn =
encodeVnPacket(
echoedDcid = ConnectionId.random(8), // wrong
serverScid = ConnectionId.random(8),
supportedVersions = listOf(QuicVersion.V1),
)
feedDatagram(client, vn, nowMillis = 0L)
assertFalse(client.vnConsumed, "DCID mismatch ⇒ no state change")
assertEquals(QuicVersion.FORCE_VERSION_NEGOTIATION, client.currentVersion)
}
@Test
fun default_initial_version_is_v1_for_existing_callers() {
// Backward-compat: not passing initialVersion must keep the writer
// emitting v1 (no behavior change for existing tests).
val client =
QuicConnection(
serverName = "example.test",
config = QuicConnectionConfig(),
tlsCertificateValidator = PermissiveCertificateValidator(),
)
assertEquals(QuicVersion.V1, client.currentVersion)
assertFalse(client.vnConsumed)
assertNull(client.peerTransportParameters)
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.quic.interop
import com.vitorpamplona.quic.connection.QuicConnection
import com.vitorpamplona.quic.connection.QuicConnectionConfig
import com.vitorpamplona.quic.connection.QuicConnectionDriver
import com.vitorpamplona.quic.packet.QuicVersion
import com.vitorpamplona.quic.tls.PermissiveCertificateValidator
import com.vitorpamplona.quic.transport.UdpSocket
import kotlinx.coroutines.CoroutineScope
@@ -52,10 +53,25 @@ fun main(args: Array<String>) {
val host = System.getProperty("interopHost") ?: args.getOrNull(0) ?: "127.0.0.1"
val port = (System.getProperty("interopPort") ?: args.getOrNull(1) ?: "4433").toInt()
val timeoutSec = (System.getProperty("interopTimeoutSec") ?: "10").toLong()
// Public quic-interop-runner contract: TESTCASE env names the scenario.
// We currently only special-case `versionnegotiation`; everything else
// falls through to the default v1-handshake path, which is enough for
// the `transfer` / `handshake` / `multiconnect` testcases.
val testcase =
System.getProperty("interopTestcase")
?: System.getenv("TESTCASE")
?: args.getOrNull(2)
val initialVersion =
when (testcase) {
"versionnegotiation" -> QuicVersion.FORCE_VERSION_NEGOTIATION
else -> QuicVersion.V1
}
println("== :quic interop runner ==")
println("target: $host:$port")
println("timeout: ${timeoutSec}s")
println("target: $host:$port")
println("testcase: ${testcase ?: "(default)"}")
println("init ver: 0x${initialVersion.toUInt().toString(16)}")
println("timeout: ${timeoutSec}s")
println()
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -73,6 +89,7 @@ fun main(args: Array<String>) {
serverName = host,
config = QuicConnectionConfig(),
tlsCertificateValidator = PermissiveCertificateValidator(),
initialVersion = initialVersion,
)
val driver = QuicConnectionDriver(conn, socket, scope)
driver.start()