feat(quic): handle Version Negotiation packets per RFC 9000 §6
Adds the client-side VN flow needed for the interop runner's `versionnegotiation` testcase: - `QuicConnection` accepts an `initialVersion` constructor parameter (default `QuicVersion.V1`) and exposes a mutable `currentVersion` the writer stamps into outbound long-headers. `start()` now caches the ClientHello bytes for VN-driven re-emission. - `applyVersionNegotiation(supportedVersions)` validates per §6.2 (anti-downgrade: reject if list contains the offered version), picks v1 from the offered set, regenerates DCID, re-derives Initial keys against the new DCID, resets the Initial level via `LevelState.resetForVersionNegotiation`, re-enqueues the cached ClientHello, and latches `vnConsumed` so a second VN is dropped. Failure to find a mutually supported version closes the connection with `QuicVersionNegotiationException`. - `QuicConnectionParser.feedDatagram` detects `version == 0` long headers BEFORE peekHeader (whose layout assumes v1) and dispatches to a new `feedVersionNegotiationPacket` that parses the §17.2.1 shape and validates the echoed DCID. - `QuicConnectionWriter` reads `conn.currentVersion` instead of the hardcoded `QuicVersion.V1`. - `QuicVersion.FORCE_VERSION_NEGOTIATION = 0x1a2a3a4a` for the interop runner. - `InteropRunner` honors `TESTCASE=versionnegotiation` (or `-DinteropTestcase=`) and offers the force-VN version. Regression coverage in `VersionNegotiationTest`: - happy path: VN switches `currentVersion` to v1, regenerates DCID, resets PN, and the next drain emits a v1 Initial on the wire. - downgrade defense: VN listing the offered version is dropped. - unsupported list: VN whose versions we can't speak fails the handshake and closes the connection. - second VN: post-consumption VN is ignored. - DCID mismatch: spoofed VN with wrong echoed DCID is dropped. - backward compatibility: default `initialVersion` keeps v1 behavior for existing callers. https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
This commit is contained in:
@@ -26,7 +26,8 @@ import com.vitorpamplona.quic.stream.SendBuffer
|
|||||||
|
|
||||||
/** Per-encryption-level state owned by [QuicConnection]. */
|
/** Per-encryption-level state owned by [QuicConnection]. */
|
||||||
class LevelState {
|
class LevelState {
|
||||||
val pnSpace = PacketNumberSpaceState()
|
var pnSpace = PacketNumberSpaceState()
|
||||||
|
private set
|
||||||
|
|
||||||
var ackTracker =
|
var ackTracker =
|
||||||
com.vitorpamplona.quic.recovery
|
com.vitorpamplona.quic.recovery
|
||||||
@@ -122,4 +123,34 @@ class LevelState {
|
|||||||
largestAckedSentTimeMs = null
|
largestAckedSentTimeMs = null
|
||||||
keysDiscarded = true
|
keysDiscarded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 9000 §6: reset every per-level field to a constructor-fresh
|
||||||
|
* state, then install [sendProtection] / [receiveProtection] keyed
|
||||||
|
* to the post-VN destination CID.
|
||||||
|
*
|
||||||
|
* 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 resetForVersionNegotiation(
|
||||||
|
sendProtection: PacketProtection,
|
||||||
|
receiveProtection: PacketProtection,
|
||||||
|
) {
|
||||||
|
pnSpace = PacketNumberSpaceState()
|
||||||
|
ackTracker =
|
||||||
|
com.vitorpamplona.quic.recovery
|
||||||
|
.AckTracker()
|
||||||
|
cryptoSend = SendBuffer()
|
||||||
|
cryptoReceive = ReceiveBuffer()
|
||||||
|
sentPackets.clear()
|
||||||
|
largestAckedPn = null
|
||||||
|
largestAckedSentTimeMs = null
|
||||||
|
keysDiscarded = false
|
||||||
|
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.InitialSecrets
|
||||||
import com.vitorpamplona.quic.crypto.PlatformAesOneBlock
|
import com.vitorpamplona.quic.crypto.PlatformAesOneBlock
|
||||||
import com.vitorpamplona.quic.crypto.bestAes128GcmAead
|
import com.vitorpamplona.quic.crypto.bestAes128GcmAead
|
||||||
|
import com.vitorpamplona.quic.packet.QuicVersion
|
||||||
import com.vitorpamplona.quic.stream.QuicStream
|
import com.vitorpamplona.quic.stream.QuicStream
|
||||||
import com.vitorpamplona.quic.stream.StreamId
|
import com.vitorpamplona.quic.stream.StreamId
|
||||||
import com.vitorpamplona.quic.tls.TlsClient
|
import com.vitorpamplona.quic.tls.TlsClient
|
||||||
@@ -73,12 +74,55 @@ class QuicConnection(
|
|||||||
.toEpochMilliseconds()
|
.toEpochMilliseconds()
|
||||||
},
|
},
|
||||||
val alpnList: List<ByteArray> = listOf(TlsConstants.ALPN_H3),
|
val alpnList: List<ByteArray> = listOf(TlsConstants.ALPN_H3),
|
||||||
|
/**
|
||||||
|
* 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 initialVersion: Int = QuicVersion.V1,
|
||||||
) {
|
) {
|
||||||
val sourceConnectionId: ConnectionId = ConnectionId.random(8)
|
val sourceConnectionId: ConnectionId = ConnectionId.random(8)
|
||||||
var destinationConnectionId: ConnectionId = ConnectionId.random(8)
|
var destinationConnectionId: ConnectionId = ConnectionId.random(8)
|
||||||
internal set
|
internal set
|
||||||
val originalDestinationConnectionId: ConnectionId = destinationConnectionId
|
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 initial = LevelState()
|
||||||
val handshake = LevelState()
|
val handshake = LevelState()
|
||||||
val application = LevelState()
|
val application = LevelState()
|
||||||
@@ -377,7 +421,83 @@ class QuicConnection(
|
|||||||
fun start() {
|
fun start() {
|
||||||
tls.start()
|
tls.start()
|
||||||
// Drain ClientHello bytes into the Initial-level CRYPTO send buffer.
|
// Drain ClientHello bytes into the Initial-level CRYPTO send buffer.
|
||||||
tls.pollOutbound(TlsClient.Level.INITIAL)?.let { initial.cryptoSend.enqueue(it) }
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Caller MUST hold [lock] (the parser already does).
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 newSend =
|
||||||
|
PacketProtection(bestAes128GcmAead(proto.clientKey), proto.clientKey, proto.clientIv, hp, proto.clientHp)
|
||||||
|
val newReceive =
|
||||||
|
PacketProtection(bestAes128GcmAead(proto.serverKey), proto.serverKey, proto.serverIv, hp, proto.serverHp)
|
||||||
|
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) }
|
||||||
|
|
||||||
|
// 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 =
|
private fun buildLocalTransportParameters(): TransportParameters =
|
||||||
@@ -954,6 +1074,17 @@ class QuicStreamLimitException(
|
|||||||
message: String,
|
message: String,
|
||||||
) : RuntimeException(message)
|
) : 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
|
* Diagnostic snapshot of [QuicConnection]'s flow-control accounting at
|
||||||
* a single moment. Returned by [QuicConnection.flowControlSnapshot].
|
* a single moment. Returned by [QuicConnection.flowControlSnapshot].
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import com.vitorpamplona.quic.frame.StreamFrame
|
|||||||
import com.vitorpamplona.quic.frame.decodeFrames
|
import com.vitorpamplona.quic.frame.decodeFrames
|
||||||
import com.vitorpamplona.quic.packet.LongHeaderPacket
|
import com.vitorpamplona.quic.packet.LongHeaderPacket
|
||||||
import com.vitorpamplona.quic.packet.LongHeaderType
|
import com.vitorpamplona.quic.packet.LongHeaderType
|
||||||
|
import com.vitorpamplona.quic.packet.QuicVersion
|
||||||
import com.vitorpamplona.quic.packet.ShortHeaderPacket
|
import com.vitorpamplona.quic.packet.ShortHeaderPacket
|
||||||
import com.vitorpamplona.quic.stream.StreamId
|
import com.vitorpamplona.quic.stream.StreamId
|
||||||
import com.vitorpamplona.quic.tls.TlsClient
|
import com.vitorpamplona.quic.tls.TlsClient
|
||||||
@@ -62,6 +63,23 @@ fun feedDatagram(
|
|||||||
val first = datagram[offset].toInt() and 0xFF
|
val first = datagram[offset].toInt() and 0xFF
|
||||||
val isLong = (first and 0x80) != 0
|
val isLong = (first and 0x80) != 0
|
||||||
if (isLong) {
|
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
|
// Per RFC 9001 §5.5, drop ONLY the failing packet, not subsequent
|
||||||
// coalesced ones. Use peekHeader to advance over a packet whose
|
// coalesced ones. Use peekHeader to advance over a packet whose
|
||||||
// payload we couldn't decrypt; only break the loop on a header
|
// payload we couldn't decrypt; only break the loop on a header
|
||||||
@@ -78,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(
|
private fun feedLongHeaderPacket(
|
||||||
conn: QuicConnection,
|
conn: QuicConnection,
|
||||||
datagram: ByteArray,
|
datagram: ByteArray,
|
||||||
|
|||||||
+2
-3
@@ -40,7 +40,6 @@ import com.vitorpamplona.quic.frame.encodeFrames
|
|||||||
import com.vitorpamplona.quic.packet.LongHeaderPacket
|
import com.vitorpamplona.quic.packet.LongHeaderPacket
|
||||||
import com.vitorpamplona.quic.packet.LongHeaderPlaintextPacket
|
import com.vitorpamplona.quic.packet.LongHeaderPlaintextPacket
|
||||||
import com.vitorpamplona.quic.packet.LongHeaderType
|
import com.vitorpamplona.quic.packet.LongHeaderType
|
||||||
import com.vitorpamplona.quic.packet.QuicVersion
|
|
||||||
import com.vitorpamplona.quic.packet.ShortHeaderPacket
|
import com.vitorpamplona.quic.packet.ShortHeaderPacket
|
||||||
import com.vitorpamplona.quic.packet.ShortHeaderPlaintextPacket
|
import com.vitorpamplona.quic.packet.ShortHeaderPlaintextPacket
|
||||||
|
|
||||||
@@ -214,7 +213,7 @@ private fun buildLongHeaderPacket(
|
|||||||
return LongHeaderPacket.build(
|
return LongHeaderPacket.build(
|
||||||
LongHeaderPlaintextPacket(
|
LongHeaderPlaintextPacket(
|
||||||
type = type,
|
type = type,
|
||||||
version = QuicVersion.V1,
|
version = conn.currentVersion,
|
||||||
dcid = conn.destinationConnectionId,
|
dcid = conn.destinationConnectionId,
|
||||||
scid = conn.sourceConnectionId,
|
scid = conn.sourceConnectionId,
|
||||||
packetNumber = pn,
|
packetNumber = pn,
|
||||||
@@ -314,7 +313,7 @@ private fun buildLongHeaderFromFrames(
|
|||||||
LongHeaderPacket.build(
|
LongHeaderPacket.build(
|
||||||
LongHeaderPlaintextPacket(
|
LongHeaderPlaintextPacket(
|
||||||
type = type,
|
type = type,
|
||||||
version = QuicVersion.V1,
|
version = conn.currentVersion,
|
||||||
dcid = conn.destinationConnectionId,
|
dcid = conn.destinationConnectionId,
|
||||||
scid = conn.sourceConnectionId,
|
scid = conn.sourceConnectionId,
|
||||||
packetNumber = pn,
|
packetNumber = pn,
|
||||||
|
|||||||
@@ -42,4 +42,17 @@ enum class LongHeaderType(
|
|||||||
object QuicVersion {
|
object QuicVersion {
|
||||||
const val V1: Int = 0x00000001
|
const val V1: Int = 0x00000001
|
||||||
const val VERSION_NEGOTIATION: Int = 0x00000000
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+272
@@ -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.QuicConnection
|
||||||
import com.vitorpamplona.quic.connection.QuicConnectionConfig
|
import com.vitorpamplona.quic.connection.QuicConnectionConfig
|
||||||
import com.vitorpamplona.quic.connection.QuicConnectionDriver
|
import com.vitorpamplona.quic.connection.QuicConnectionDriver
|
||||||
|
import com.vitorpamplona.quic.packet.QuicVersion
|
||||||
import com.vitorpamplona.quic.tls.PermissiveCertificateValidator
|
import com.vitorpamplona.quic.tls.PermissiveCertificateValidator
|
||||||
import com.vitorpamplona.quic.transport.UdpSocket
|
import com.vitorpamplona.quic.transport.UdpSocket
|
||||||
import kotlinx.coroutines.CoroutineScope
|
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 host = System.getProperty("interopHost") ?: args.getOrNull(0) ?: "127.0.0.1"
|
||||||
val port = (System.getProperty("interopPort") ?: args.getOrNull(1) ?: "4433").toInt()
|
val port = (System.getProperty("interopPort") ?: args.getOrNull(1) ?: "4433").toInt()
|
||||||
val timeoutSec = (System.getProperty("interopTimeoutSec") ?: "10").toLong()
|
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("== :quic interop runner ==")
|
||||||
println("target: $host:$port")
|
println("target: $host:$port")
|
||||||
println("timeout: ${timeoutSec}s")
|
println("testcase: ${testcase ?: "(default)"}")
|
||||||
|
println("init ver: 0x${initialVersion.toUInt().toString(16)}")
|
||||||
|
println("timeout: ${timeoutSec}s")
|
||||||
println()
|
println()
|
||||||
|
|
||||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
@@ -73,6 +89,7 @@ fun main(args: Array<String>) {
|
|||||||
serverName = host,
|
serverName = host,
|
||||||
config = QuicConnectionConfig(),
|
config = QuicConnectionConfig(),
|
||||||
tlsCertificateValidator = PermissiveCertificateValidator(),
|
tlsCertificateValidator = PermissiveCertificateValidator(),
|
||||||
|
initialVersion = initialVersion,
|
||||||
)
|
)
|
||||||
val driver = QuicConnectionDriver(conn, socket, scope)
|
val driver = QuicConnectionDriver(conn, socket, scope)
|
||||||
driver.start()
|
driver.start()
|
||||||
|
|||||||
Reference in New Issue
Block a user