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:
@@ -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"
|
||||
}
|
||||
+114
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user