diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/LevelState.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/LevelState.kt index 89ead7fff..e02df4498 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/LevelState.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/LevelState.kt @@ -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 } } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt index 8d2864577..4d69d32d8 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -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 = 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) { + // 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]. diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt index 1a7600e44..0715be564 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt @@ -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(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, diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt index 27e391460..f83c04583 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt @@ -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, diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/packet/PacketTypes.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/packet/PacketTypes.kt index 0f1ff00da..9eb151529 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/packet/PacketTypes.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/packet/PacketTypes.kt @@ -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 } diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/VersionNegotiationTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/VersionNegotiationTest.kt new file mode 100644 index 000000000..657aedb7c --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/VersionNegotiationTest.kt @@ -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 | + * 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, + ): ByteArray { + val out = ArrayList() + 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) + } +} diff --git a/quic/src/jvmTest/kotlin/com/vitorpamplona/quic/interop/InteropRunner.kt b/quic/src/jvmTest/kotlin/com/vitorpamplona/quic/interop/InteropRunner.kt index 514df4d5d..dea87cf79 100644 --- a/quic/src/jvmTest/kotlin/com/vitorpamplona/quic/interop/InteropRunner.kt +++ b/quic/src/jvmTest/kotlin/com/vitorpamplona/quic/interop/InteropRunner.kt @@ -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) { 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) { serverName = host, config = QuicConnectionConfig(), tlsCertificateValidator = PermissiveCertificateValidator(), + initialVersion = initialVersion, ) val driver = QuicConnectionDriver(conn, socket, scope) driver.start()