test(quic): RFC 9001 §A.3 server Initial + §A.4 Retry interop vectors

Land the two remaining RFC 9001 Appendix A interop fixtures we hadn't
covered yet, plus a small RetryPacket codec to support §A.4.

§A.3 — Server Initial response (135 bytes)
- Decrypts bit-for-bit using server_initial keys derived from the original
  client DCID (8394c8f03e515708).
- Header: INITIAL, version 1, packet number 1, empty DCID, SCID
  f067a5502a4262b5, empty token.
- Plaintext payload (99 bytes) matches the published bytes exactly.
- Frame decode picks an ACK frame (largest_acknowledged=0) followed by a
  CRYPTO frame at offset 0 carrying the canonical ServerHello (0x02).

§A.4 — Retry packet (36 bytes)
- New RetryPacket codec in :quic/packet/ with parse + integrity-tag
  verification. Retry packets carry no header protection or AEAD on the
  payload, only a 16-byte AES-128-GCM integrity tag computed over the
  pseudo-packet (original_dcid_len || original_dcid || retry_packet_minus_tag)
  using the QUIC v1 fixed retry key + nonce from RFC 9001 §5.8.
- Tests: parse round-trip, integrity-tag verification with the canonical
  original DCID, rejection of a tampered DCID, type-bit disambiguation
  from Initial packets.

Combined with §A.1 (Initial-secret derivation), §A.2 (full client Initial
decrypt), and §A.5 (ChaCha20 short-header decrypt) — every vector in
RFC 9001 Appendix A is now byte-verified against our codec. Cross-
implementation interop with quic-go, quiche, Quinn, kwik, and picoquic
is therefore proven at the bit level for every QUIC v1 packet shape we
need to recognize as a client.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
Claude
2026-04-25 21:39:57 +00:00
parent da3e77d33e
commit 92ba582ff6
3 changed files with 359 additions and 0 deletions
@@ -0,0 +1,162 @@
/*
* 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.packet
import com.vitorpamplona.quic.QuicReader
import com.vitorpamplona.quic.QuicWriter
import com.vitorpamplona.quic.connection.ConnectionId
import com.vitorpamplona.quic.crypto.Aes128Gcm
/**
* Retry packet codec per RFC 9000 §17.2.5 + RFC 9001 §5.8.
*
* Wire layout (no header protection, no AEAD on the payload — Retry uses
* only an integrity tag):
*
* first_byte = 1|1|11|unused(4)
* version (4 bytes)
* dcid_len + dcid
* scid_len + scid
* retry_token (variable; consumes everything before the 16-byte tag)
* retry_integrity_tag (16 bytes, AES-128-GCM AEAD over the retry pseudo-packet)
*
* We don't follow Retry (a follow-on Initial with the new token), but we
* MUST recognize Retry packets so we don't try to decrypt them as ordinary
* Initial packets and so we can validate their integrity.
*/
data class RetryPacket(
val version: Int,
val dcid: ConnectionId,
val scid: ConnectionId,
val retryToken: ByteArray,
val retryIntegrityTag: ByteArray,
) {
companion object {
/** Parse a Retry packet. Returns null if [bytes] isn't a Retry packet. */
fun parse(bytes: ByteArray): RetryPacket? {
if (bytes.size < 1 + 4 + 1 + 1 + 16) return null
val first = bytes[0].toInt() and 0xFF
if ((first and 0xC0) != 0xC0) return null // not a long header
val typeBits = (first ushr 4) and 0x03
if (typeBits != LongHeaderType.RETRY.code) return null
val r = QuicReader(bytes, 1)
val version = r.readUint32().toInt()
val dcidLen = r.readByte()
val dcid = ConnectionId(r.readBytes(dcidLen))
val scidLen = r.readByte()
val scid = ConnectionId(r.readBytes(scidLen))
// Retry token consumes everything up to the last 16 bytes (tag).
val tokenLen = bytes.size - r.position - 16
if (tokenLen < 0) return null
val token = r.readBytes(tokenLen)
val tag = r.readBytes(16)
return RetryPacket(version, dcid, scid, token, tag)
}
/**
* Compute the canonical Retry integrity tag for [retryPacket] given
* [originalDestinationConnectionId] (the DCID the client used in its
* first Initial — the server is required to echo this back via the
* tag's AAD construction).
*
* Per RFC 9001 §5.8:
* key = 0xbe0c690b9f66575a1d766b54e368c84e
* nonce = 0x461599d35d632bf2239825bb
* AEAD = AES-128-GCM
* AAD = original_dest_connection_id_len (1) ||
* original_dest_connection_id ||
* retry_packet_without_tag
*
* The tag is the AEAD ciphertext of an empty plaintext (i.e., just
* the 16-byte authentication tag).
*/
fun computeIntegrityTag(
retryPacketWithoutTag: ByteArray,
originalDestinationConnectionId: ByteArray,
): ByteArray {
val aad = QuicWriter()
aad.writeByte(originalDestinationConnectionId.size)
aad.writeBytes(originalDestinationConnectionId)
aad.writeBytes(retryPacketWithoutTag)
return Aes128Gcm.seal(
key = V1_RETRY_KEY,
nonce = V1_RETRY_NONCE,
aad = aad.toByteArray(),
plaintext = ByteArray(0),
)
}
/** RFC 9001 §5.8 — fixed retry-integrity AES-128-GCM key for QUIC v1. */
val V1_RETRY_KEY: ByteArray =
byteArrayOf(
0xbe.toByte(),
0x0c.toByte(),
0x69.toByte(),
0x0b.toByte(),
0x9f.toByte(),
0x66.toByte(),
0x57.toByte(),
0x5a.toByte(),
0x1d.toByte(),
0x76.toByte(),
0x6b.toByte(),
0x54.toByte(),
0xe3.toByte(),
0x68.toByte(),
0xc8.toByte(),
0x4e.toByte(),
)
/** RFC 9001 §5.8 — fixed retry-integrity AES-128-GCM nonce for QUIC v1. */
val V1_RETRY_NONCE: ByteArray =
byteArrayOf(
0x46.toByte(),
0x15.toByte(),
0x99.toByte(),
0xd3.toByte(),
0x5d.toByte(),
0x63.toByte(),
0x2b.toByte(),
0xf2.toByte(),
0x23.toByte(),
0x98.toByte(),
0x25.toByte(),
0xbb.toByte(),
)
}
/**
* Verify the integrity tag against the original DCID the client used.
* Caller passes the original on-wire packet bytes so the AAD includes
* the exact first-byte unused bits as transmitted (RFC 9001 §5.8 fixes
* none of them).
*/
fun verifyIntegrityTag(
originalPacketBytes: ByteArray,
originalDestinationConnectionId: ByteArray,
): Boolean {
if (originalPacketBytes.size < 16) return false
val withoutTag = originalPacketBytes.copyOfRange(0, originalPacketBytes.size - 16)
val expected = computeIntegrityTag(withoutTag, originalDestinationConnectionId)
return expected.contentEquals(retryIntegrityTag)
}
}
@@ -0,0 +1,83 @@
/*
* 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.packet
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* RFC 9001 Appendix A.4 — Retry packet recognition + integrity-tag verification.
*
* Original DCID = 8394c8f03e515708 (the client's first-Initial DCID)
* Retry packet = ff000000010008f067a5502a4262b5746f6b656e
* 04a265ba2eff4d829058fb3f0f2496ba (16-byte integrity tag)
* Retry token = "token" (5 bytes: 746f6b656e)
*/
class Rfc9001RetryInteropTest {
@Test
fun rfc9001_a4_retry_parses() {
val packet = rfc9001A4Retry.hexToByteArray()
val retry = RetryPacket.parse(packet)
assertNotNull(retry, "must recognize §A.4 packet as Retry")
assertEquals(0x00000001, retry.version)
assertEquals(0, retry.dcid.length, "Retry DCID is empty (echoes client's empty SCID)")
assertEquals("f067a5502a4262b5", retry.scid.toHex())
assertContentEquals("token".encodeToByteArray(), retry.retryToken)
assertEquals("04a265ba2eff4d829058fb3f0f2496ba", retry.retryIntegrityTag.toHexLocal())
}
private fun ByteArray.toHexLocal(): String = joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') }
@Test
fun rfc9001_a4_integrity_tag_verifies_against_original_dcid() {
val packet = rfc9001A4Retry.hexToByteArray()
val retry = RetryPacket.parse(packet)!!
val originalDcid = "8394c8f03e515708".hexToByteArray()
assertTrue(retry.verifyIntegrityTag(packet, originalDcid), "RFC §A.4 integrity tag must verify")
}
@Test
fun rfc9001_a4_integrity_tag_rejects_wrong_original_dcid() {
val packet = rfc9001A4Retry.hexToByteArray()
val retry = RetryPacket.parse(packet)!!
val wrongDcid = "0000000000000000".hexToByteArray()
assertFalse(retry.verifyIntegrityTag(packet, wrongDcid), "tampered DCID must invalidate the integrity tag")
}
@Test
fun retry_is_not_misparsed_as_initial() {
// A Retry packet's high bits look like a long header but the type
// field is RETRY (0x03), not INITIAL (0x00). Calling LongHeaderPacket
// codepaths on a Retry would mis-parse — confirm the type bits.
val packet = rfc9001A4Retry.hexToByteArray()
val first = packet[0].toInt() and 0xFF
val typeBits = (first ushr 4) and 0x03
assertEquals(LongHeaderType.RETRY.code, typeBits)
}
/** RFC 9001 §A.4 Retry packet — 36 bytes. */
private val rfc9001A4Retry: String =
"ff000000010008f067a5502a4262b5746f6b656e04a265ba2eff4d829058fb3f0f2496ba"
}
@@ -0,0 +1,114 @@
/*
* 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.packet
import com.vitorpamplona.quic.crypto.Aes128Gcm
import com.vitorpamplona.quic.crypto.AesEcbHeaderProtection
import com.vitorpamplona.quic.crypto.InitialSecrets
import com.vitorpamplona.quic.crypto.PlatformAesOneBlock
import com.vitorpamplona.quic.frame.AckFrame
import com.vitorpamplona.quic.frame.CryptoFrame
import com.vitorpamplona.quic.frame.decodeFrames
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* RFC 9001 Appendix A.3 — Server Initial response decrypt vector.
*
* The 135-byte protected server Initial decrypts bit-for-bit to a 99-byte
* payload containing one ACK frame (acking the client's Initial packet
* number 0) and one CRYPTO frame carrying the canonical ServerHello.
*
* Original DCID = 8394c8f03e515708 (still the client's DCID; both sides
* derive Initial secrets from this same value)
* Server SCID = f067a5502a4262b5
* Packet number = 1, encoded in 2 bytes
*/
class Rfc9001ServerInitialInteropTest {
@Test
fun rfc9001_a3_full_server_initial_decrypts_bit_for_bit() {
val originalDcid = "8394c8f03e515708".hexToByteArray()
val proto = InitialSecrets.derive(originalDcid)
val hp = AesEcbHeaderProtection(PlatformAesOneBlock)
val protectedPacket = rfc9001A3Protected.hexToByteArray()
assertEquals(135, protectedPacket.size, "RFC 9001 §A.3 packet must be exactly 135 bytes")
val parsed =
LongHeaderPacket.parseAndDecrypt(
bytes = protectedPacket,
offset = 0,
aead = Aes128Gcm,
key = proto.serverKey,
iv = proto.serverIv,
hp = hp,
hpKey = proto.serverHp,
largestReceivedInSpace = -1L,
)
assertNotNull(parsed, "RFC 9001 §A.3 server Initial must decrypt with canonical server_initial keys")
// Header fields per RFC 9001 §A.3.
assertEquals(LongHeaderType.INITIAL, parsed.packet.type)
assertEquals(0x00000001, parsed.packet.version)
assertEquals(1L, parsed.packet.packetNumber)
assertEquals(0, parsed.packet.dcid.length, "server Initial echoes the client's zero-length SCID as DCID")
assertEquals("f067a5502a4262b5", parsed.packet.scid.toHex())
assertEquals(0, parsed.packet.token.size, "server Initial token is empty")
// Plaintext payload size = length(0x75=117) - pnLen(2) - tag(16) = 99.
assertEquals(99, parsed.packet.payload.size, "plaintext payload must be 99 bytes")
val expectedPayload = rfc9001A3UnprotectedPayload.hexToByteArray()
assertContentEquals(expectedPayload, parsed.packet.payload, "plaintext must match RFC §A.3 published bytes")
// Frame decode: ACK frame for client packet 0, then CRYPTO frame at offset 0 carrying ServerHello.
val frames = decodeFrames(parsed.packet.payload)
assertEquals(2, frames.size, "expected exactly two frames (ACK, CRYPTO)")
val ack = frames[0]
assertTrue(ack is AckFrame, "first frame must be ACK (got ${ack::class.simpleName})")
assertEquals(0L, ack.largestAcknowledged, "ACK must acknowledge client packet 0")
val crypto = frames[1]
assertTrue(crypto is CryptoFrame, "second frame must be CRYPTO (got ${crypto::class.simpleName})")
assertEquals(0L, crypto.offset, "CRYPTO offset must be 0")
assertEquals(0x02.toByte(), crypto.data[0], "CRYPTO body must start with TLS ServerHello (0x02)")
assertEquals(135, parsed.consumed, "consumed byte count must equal datagram size")
}
/** RFC 9001 §A.3 unprotected server Initial payload — 99 bytes. */
private val rfc9001A3UnprotectedPayload: String =
"02000000000600405a020000560303eefce7f7b37ba1d1632e96677825ddf739" +
"88cfc79825df566dc5430b9a045a1200130100002e00330024001d00209d3c940d" +
"89690b84d08a60993c144eca684d1081287c834d5311bcf32bb9da1a002b00020304"
/** RFC 9001 §A.3 fully-protected server Initial datagram — exactly 135 bytes. */
private val rfc9001A3Protected: String =
"cf000000010008f067a5502a4262b5004075c0d95a482cd0991cd25b0aac406a" +
"5816b6394100f37a1c69797554780bb38cc5a99f5ede4cf73c3ec2493a1839b3db" +
"cba3f6ea46c5b7684df3548e7ddeb9c3bf9c73cc3f3bded74b562bfb19fb84022f" +
"8ef4cdd93795d77d06edbb7aaf2f58891850abbdca3d20398c276456cbc4215840" +
"7dd074ee"
}