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 b3ba89e64..89ead7fff 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/LevelState.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/LevelState.kt @@ -122,4 +122,37 @@ class LevelState { largestAckedSentTimeMs = null keysDiscarded = true } + + /** + * 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`). + * + * 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. + */ + internal fun restoreFromRetry(fresh: LevelState) { + sendProtection = fresh.sendProtection + receiveProtection = fresh.receiveProtection + cryptoSend = fresh.cryptoSend + cryptoReceive = fresh.cryptoReceive + ackTracker = + com.vitorpamplona.quic.recovery + .AckTracker() + 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() + } } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/PacketNumberSpace.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/PacketNumberSpace.kt index b187dc512..b6a48a236 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/PacketNumberSpace.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/PacketNumberSpace.kt @@ -64,6 +64,20 @@ class PacketNumberSpaceState { if (nextPacketNumber > 0) nextPacketNumber-- } + /** + * RFC 9000 §17.2.5.2: reset the Initial-level packet-number space + * after a Retry packet rolls our DCID. The new Initial keys are + * derived from a different secret, so the (PN, key) pair the AEAD + * nonce relies on is unique even with the PN going back to 0. + * Inbound state is reset because no Initial packet has been + * received yet on the new keys. + */ + internal fun resetForRetry() { + nextPacketNumber = 0L + largestReceived = -1L + largestReceivedTime = 0L + } + /** Note that an inbound packet was successfully decrypted. */ fun observeInbound( packetNumber: Long, 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 46c86962c..2879caecd 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -100,6 +100,33 @@ class QuicConnection( val handshake = LevelState() val application = LevelState() + /** + * RFC 9000 §17.2.5.1: the Retry token the server handed us in a Retry + * packet, which we must echo verbatim in the Token field of every + * subsequent Initial we send. Null until [applyRetry] runs. + */ + @Volatile + var retryToken: ByteArray? = null + internal set + + /** + * RFC 9000 §17.2.5.2: a client MUST NOT process more than one Retry + * packet per connection. Any subsequent Retry is silently dropped. + * Latched true by [applyRetry] on a successfully-verified Retry. + */ + @Volatile + var retryConsumed: Boolean = false + internal set + + /** + * The verbatim ClientHello CRYPTO bytes the TLS layer emitted at + * [start]. Captured so [applyRetry] can re-enqueue them after Retry + * resets the Initial-level CRYPTO send buffer (TLS itself only + * emits ClientHello once and we have no path to drive it to re-emit). + * Null until [start] has been called. + */ + private var originalClientHello: ByteArray? = null + var handshakeComplete: Boolean = false private set @@ -398,7 +425,123 @@ class QuicConnection( fun start() { tls.start() // Drain ClientHello bytes into the Initial-level CRYPTO send buffer. - tls.pollOutbound(TlsClient.Level.INITIAL)?.let { initial.cryptoSend.enqueue(it) } + // 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) + } + } + + /** + * RFC 9000 §17.2.5 + RFC 9001 §5.8 Retry handling. + * + * 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). + * + * 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. + */ + 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 + } + val savedClientHello = originalClientHello ?: return false + + // Step 3. + destinationConnectionId = retryPacket.scid + // Step 4. + val proto = InitialSecrets.derive(destinationConnectionId.bytes) + val hp = AesEcbHeaderProtection(PlatformAesOneBlock) + val freshInitial = LevelState() + freshInitial.sendProtection = + PacketProtection(bestAes128GcmAead(proto.clientKey), proto.clientKey, proto.clientIv, hp, proto.clientHp) + freshInitial.receiveProtection = + 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) + + // 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) } private fun buildLocalTransportParameters(): TransportParameters = 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 284b05f4f..1a7600e44 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt @@ -39,6 +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.ShortHeaderPacket import com.vitorpamplona.quic.stream.StreamId import com.vitorpamplona.quic.tls.TlsClient @@ -85,6 +86,20 @@ private fun feedLongHeaderPacket( nowMillis: Long, ): Int? { val peeked = LongHeaderPacket.peekHeader(datagram, offset) ?: return null + // RFC 9000 §17.2.5 + RFC 9001 §5.8: Retry has no PN space, no AEAD + // payload protection, and cannot be coalesced with anything else + // (it consumes the rest of the datagram via peekHeader.totalLength). + // Branch out before the standard parse-and-decrypt path. + if (peeked.type == LongHeaderType.RETRY) { + val retryBytes = datagram.copyOfRange(offset, offset + peeked.totalLength) + val retryPacket = RetryPacket.parse(retryBytes) + if (retryPacket != null) { + // applyRetry returns false on bad-tag / second-Retry / pre-start — + // in all of those cases we silently drop without advancing state. + conn.applyRetry(retryPacket, retryBytes) + } + return peeked.totalLength + } val level = when (peeked.type) { LongHeaderType.INITIAL -> EncryptionLevel.INITIAL 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 6bffeb129..27e391460 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt @@ -466,6 +466,16 @@ private fun buildLongHeaderFromFrames( } val pn = state.pnSpace.allocateOutbound() val type = if (level == EncryptionLevel.INITIAL) LongHeaderType.INITIAL else LongHeaderType.HANDSHAKE + // RFC 9000 §17.2.5.1: after a Retry, every Initial we send MUST carry + // the server-issued Retry token verbatim in the Initial header's Token + // field. Handshake packets have no Token field, so this only affects + // Initial-level builds. + val token = + if (type == LongHeaderType.INITIAL) { + conn.retryToken ?: ByteArray(0) + } else { + ByteArray(0) + } val packet = LongHeaderPacket.build( LongHeaderPlaintextPacket( @@ -473,6 +483,7 @@ private fun buildLongHeaderFromFrames( version = QuicVersion.V1, dcid = conn.destinationConnectionId, scid = conn.sourceConnectionId, + token = token, packetNumber = pn, payload = payload, ), diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/RetryHandlingTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/RetryHandlingTest.kt new file mode 100644 index 000000000..a0bd832c8 --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/RetryHandlingTest.kt @@ -0,0 +1,251 @@ +/* + * 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.QuicReader +import com.vitorpamplona.quic.QuicWriter +import com.vitorpamplona.quic.packet.LongHeaderPacket +import com.vitorpamplona.quic.packet.LongHeaderType +import com.vitorpamplona.quic.packet.QuicVersion +import com.vitorpamplona.quic.packet.RetryPacket +import com.vitorpamplona.quic.tls.PermissiveCertificateValidator +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Retry packet handling end-to-end through [QuicConnection], per RFC 9000 + * §17.2.5 (semantics) + RFC 9001 §5.8 (integrity tag). + * + * Synthesizes valid-and-invalid Retry packets, feeds them through + * [feedDatagram], and asserts the resulting connection state: + * + * 1. Happy path: DCID swaps, retryToken stored, Initial PN reset to 0, + * next outbound Initial carries the token in its header, contains the + * ClientHello CRYPTO, and the datagram is padded to ≥ 1200 bytes. + * 2. Bad-tag path: corrupting the integrity tag must be silently dropped; + * no state advances. + * 3. Second-Retry path: a second valid Retry after a first one is dropped + * (RFC 9000 §17.2.5.2 — at most one Retry per connection). + */ +class RetryHandlingTest { + private fun newClient(): QuicConnection = + QuicConnection( + serverName = "example.test", + config = QuicConnectionConfig(), + tlsCertificateValidator = PermissiveCertificateValidator(), + ) + + /** + * Build the on-wire bytes of a valid Retry packet for [client], with the + * given [retryScid] and [retryToken]. Computes the integrity tag using + * the client's [QuicConnection.originalDestinationConnectionId] so the + * client's [RetryPacket.verifyIntegrityTag] check passes. + * + * The Retry packet's DCID is the client's source CID (servers echo it + * even though it's unused — RFC 9000 §17.2.5.1). The high 4 bits of + * the first byte are 1100 (long header + RETRY type); the low 4 bits + * are unused — we set them to 0. + */ + private fun buildRetry( + client: QuicConnection, + retryScid: ConnectionId, + retryToken: ByteArray, + ): ByteArray { + val w = QuicWriter() + // Header form (1) | fixed bit (1) | long packet type RETRY (11) | unused (0000) + w.writeByte(0xC0 or (LongHeaderType.RETRY.code shl 4)) + w.writeUint32(QuicVersion.V1) + w.writeByte(client.sourceConnectionId.length) + w.writeBytes(client.sourceConnectionId.bytes) + w.writeByte(retryScid.length) + w.writeBytes(retryScid.bytes) + w.writeBytes(retryToken) + val withoutTag = w.toByteArray() + val tag = + RetryPacket.computeIntegrityTag( + retryPacketWithoutTag = withoutTag, + originalDestinationConnectionId = client.originalDestinationConnectionId.bytes, + ) + return withoutTag + tag + } + + /** + * Pull the Initial packet's Token field out of an on-wire datagram so + * we can assert on it. [LongHeaderPacket.parseAndDecrypt] decrypts the + * payload but doesn't surface the unprotected Token; we re-walk the + * header here to extract it without crypto. + */ + private fun extractInitialToken(datagram: ByteArray): ByteArray { + val r = QuicReader(datagram, 0) + val first = r.readByte() + require((first and 0x80) != 0) { "expected long header" } + val type = (first ushr 4) and 0x03 + require(type == LongHeaderType.INITIAL.code) { "expected INITIAL, got type=$type" } + r.readUint32() // version + val dcidLen = r.readByte() + r.readBytes(dcidLen) + val scidLen = r.readByte() + r.readBytes(scidLen) + val tokenLen = r.readVarint().toInt() + return r.readBytes(tokenLen) + } + + @Test + fun valid_retry_swaps_dcid_resets_pn_and_threads_token_into_next_initial() { + val client = newClient() + val originalDcid = client.originalDestinationConnectionId.bytes.copyOf() + client.start() + + // Drain the initial datagram (carries ClientHello at PN=0 with empty + // token field) so we can assert the pre-Retry state. + val firstDatagram = drainOutbound(client, nowMillis = 0L) + assertNotNull(firstDatagram, "client.start() should produce an Initial datagram") + assertEquals(0, extractInitialToken(firstDatagram).size, "pre-Retry Initial must have empty token") + + // Server picks a fresh source connection id and a token of its choice. + val retryScid = ConnectionId(byteArrayOf(0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x78)) + val retryToken = "server-issued-retry-token".encodeToByteArray() + val retryDatagram = buildRetry(client, retryScid, retryToken) + + feedDatagram(client, retryDatagram, nowMillis = 1L) + + // DCID is now the Retry's SCID, originalDcid is unchanged. + assertContentEquals(retryScid.bytes, client.destinationConnectionId.bytes) + assertContentEquals(originalDcid, client.originalDestinationConnectionId.bytes) + assertNotEquals(originalDcid.toList(), retryScid.bytes.toList()) + + // Retry token captured. + assertContentEquals(retryToken, client.retryToken) + assertTrue(client.retryConsumed) + + // Initial PN space reset — next allocation is 0 again. + assertEquals(0L, client.initial.pnSpace.nextPacketNumber) + assertEquals(-1L, client.initial.pnSpace.largestReceived) + + // Next drain produces the retried Initial: token in header, ClientHello + // CRYPTO inside, datagram padded to ≥ 1200 (RFC 9000 §14.1). + val secondDatagram = drainOutbound(client, nowMillis = 2L) + assertNotNull(secondDatagram, "post-Retry drain must produce another Initial") + assertContentEquals(retryToken, extractInitialToken(secondDatagram)) + assertTrue( + secondDatagram.size >= 1200, + "retried Initial datagram must be padded to >= 1200 bytes (was ${secondDatagram.size})", + ) + + // The Initial is encrypted under the new keys derived from the retryScid + // DCID. Decrypt + verify it carries CRYPTO with the captured ClientHello + // bytes (== the prefix of the original ClientHello — drained ALL the + // bytes from cryptoSend on Retry replay). + val newSecrets = + com.vitorpamplona.quic.crypto.InitialSecrets + .derive(retryScid.bytes) + val proto = client.initial.sendProtection!! + val parsed = + LongHeaderPacket.parseAndDecrypt( + bytes = secondDatagram, + offset = 0, + aead = proto.aead, + key = newSecrets.clientKey, + iv = newSecrets.clientIv, + hp = + com.vitorpamplona.quic.crypto.AesEcbHeaderProtection( + com.vitorpamplona.quic.crypto.PlatformAesOneBlock, + ), + hpKey = newSecrets.clientHp, + largestReceivedInSpace = -1L, + ) + assertNotNull(parsed, "retried Initial must decrypt under keys derived from new DCID") + assertEquals(0L, parsed.packet.packetNumber, "retried Initial PN must be 0 (RFC 9000 §17.2.5.2)") + // Decoded payload starts with at least one CRYPTO frame (frame type 0x06). + val frames = + com.vitorpamplona.quic.frame + .decodeFrames(parsed.packet.payload) + val cryptoFrames = frames.filterIsInstance() + assertTrue(cryptoFrames.isNotEmpty(), "retried Initial payload must contain CRYPTO frames (the ClientHello)") + assertEquals(0L, cryptoFrames.first().offset, "CRYPTO must restart at offset 0 on the new keys") + } + + @Test + fun retry_with_corrupted_integrity_tag_is_silently_dropped() { + val client = newClient() + val originalDcid = client.destinationConnectionId.bytes.copyOf() + client.start() + // Drain pre-Retry datagram so the test mirrors a realistic ordering. + drainOutbound(client, nowMillis = 0L) + + val retryScid = ConnectionId(byteArrayOf(0xAA.toByte(), 0xBB.toByte(), 0xCC.toByte(), 0xDD.toByte())) + val good = buildRetry(client, retryScid, "tk".encodeToByteArray()) + // Flip a bit in the last byte — the integrity tag. + val corrupted = good.copyOf() + corrupted[corrupted.size - 1] = (corrupted[corrupted.size - 1].toInt() xor 0x01).toByte() + + feedDatagram(client, corrupted, nowMillis = 1L) + + // No state advanced. + assertNull(client.retryToken) + assertFalse(client.retryConsumed) + assertContentEquals(originalDcid, client.destinationConnectionId.bytes) + } + + @Test + fun second_valid_retry_after_one_is_consumed_is_dropped() { + val client = newClient() + client.start() + drainOutbound(client, nowMillis = 0L) + + val firstScid = ConnectionId(byteArrayOf(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08)) + val firstToken = "first".encodeToByteArray() + feedDatagram(client, buildRetry(client, firstScid, firstToken), nowMillis = 1L) + + // Sanity: first applied. + assertTrue(client.retryConsumed) + assertContentEquals(firstToken, client.retryToken) + assertContentEquals(firstScid.bytes, client.destinationConnectionId.bytes) + + // Build a second VALID Retry. The integrity tag is computed against + // [originalDestinationConnectionId] (still the very first random one, + // unchanged), so this packet's tag genuinely verifies. + val secondScid = ConnectionId(byteArrayOf(0x99.toByte(), 0x88.toByte(), 0x77.toByte(), 0x66.toByte())) + val secondToken = "second-should-be-ignored".encodeToByteArray() + val secondRetry = buildRetry(client, secondScid, secondToken) + + // Confirm the integrity tag really would verify in isolation — + // otherwise this test would conflate "bad tag" with "second retry". + val parsedSecond = RetryPacket.parse(secondRetry) + assertNotNull(parsedSecond) + assertTrue( + parsedSecond.verifyIntegrityTag(secondRetry, client.originalDestinationConnectionId.bytes), + "second retry's tag must be valid in isolation; otherwise this test is meaningless", + ) + + feedDatagram(client, secondRetry, nowMillis = 2L) + + // State unchanged from after the first retry. + assertContentEquals(firstToken, client.retryToken) + assertContentEquals(firstScid.bytes, client.destinationConnectionId.bytes) + } +}