feat(quic): Phase C — long-header packets, frames, short-header packets

End-to-end packet codec for QUIC v1 packets:

- Long-header packet builder + parser (RFC 9000 §17.2) with packet-number
  encoding, header protection (AES-ECB sample mask), and AEAD-GCM payload
  protection. Initial packets carry the optional token field.
- Short-header (1-RTT) packet builder + parser with implicit DCID length.
- Stream reassembly buffer that coalesces out-of-order, overlapping chunks
  into a contiguous prefix; consumed bytes are dropped, future overlaps
  are deduplicated.
- Stream-id helpers (RFC 9000 §2.1) — client/server, bidi/uni discrimination.
- Frame codec for the minimal subset MoQ exercises: PADDING, PING, ACK,
  ACK_ECN, CRYPTO, STREAM (all OFF/LEN/FIN flag combos), MAX_DATA,
  MAX_STREAM_DATA, MAX_STREAMS, NEW_CONNECTION_ID, CONNECTION_CLOSE
  (transport + app), HANDSHAKE_DONE, DATAGRAM (RFC 9221).

Round-trip test against RFC 9001 Appendix A.1's canonical client DCID
encrypts an Initial packet with the canonical protection material, then
decrypts it from the wire bit-for-bit. A wrong-key decrypt returns null
(silent drop per RFC 9001 §5.5). ReceiveBuffer reorders, deduplicates,
coalesces, and drops already-consumed prefixes correctly.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
Claude
2026-04-25 17:39:06 +00:00
parent 692b034566
commit ac53853057
8 changed files with 1207 additions and 0 deletions
@@ -0,0 +1,354 @@
/*
* 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.frame
import com.vitorpamplona.quic.QuicCodecException
import com.vitorpamplona.quic.QuicReader
import com.vitorpamplona.quic.QuicWriter
/**
* QUIC frame type codes per RFC 9000 §19. Only the ones we actually emit or
* route are listed.
*/
object FrameType {
const val PADDING: Long = 0x00
const val PING: Long = 0x01
const val ACK: Long = 0x02
const val ACK_ECN: Long = 0x03
const val RESET_STREAM: Long = 0x04
const val STOP_SENDING: Long = 0x05
const val CRYPTO: Long = 0x06
const val NEW_TOKEN: Long = 0x07
// STREAM frames are 0x08..0x0f based on OFF/LEN/FIN flags
const val STREAM_BASE: Long = 0x08
const val STREAM_FIN_BIT: Long = 0x01
const val STREAM_LEN_BIT: Long = 0x02
const val STREAM_OFF_BIT: Long = 0x04
const val MAX_DATA: Long = 0x10
const val MAX_STREAM_DATA: Long = 0x11
const val MAX_STREAMS_BIDI: Long = 0x12
const val MAX_STREAMS_UNI: Long = 0x13
const val DATA_BLOCKED: Long = 0x14
const val STREAM_DATA_BLOCKED: Long = 0x15
const val STREAMS_BLOCKED_BIDI: Long = 0x16
const val STREAMS_BLOCKED_UNI: Long = 0x17
const val NEW_CONNECTION_ID: Long = 0x18
const val RETIRE_CONNECTION_ID: Long = 0x19
const val PATH_CHALLENGE: Long = 0x1A
const val PATH_RESPONSE: Long = 0x1B
const val CONNECTION_CLOSE_TRANSPORT: Long = 0x1C
const val CONNECTION_CLOSE_APP: Long = 0x1D
const val HANDSHAKE_DONE: Long = 0x1E
/** RFC 9221 — DATAGRAM frame. 0x30 = no length, 0x31 = length-prefixed. */
const val DATAGRAM: Long = 0x30
const val DATAGRAM_LEN: Long = 0x31
}
sealed class Frame {
abstract fun encode(out: QuicWriter)
}
object PaddingFrame : Frame() {
override fun encode(out: QuicWriter) {
out.writeByte(FrameType.PADDING.toInt())
}
}
object PingFrame : Frame() {
override fun encode(out: QuicWriter) {
out.writeByte(FrameType.PING.toInt())
}
}
class CryptoFrame(
val offset: Long,
val data: ByteArray,
) : Frame() {
override fun encode(out: QuicWriter) {
out.writeByte(FrameType.CRYPTO.toInt())
out.writeVarint(offset)
out.writeVarint(data.size.toLong())
out.writeBytes(data)
}
}
class StreamFrame(
val streamId: Long,
val offset: Long,
val data: ByteArray,
val fin: Boolean,
val explicitLength: Boolean = true,
) : Frame() {
override fun encode(out: QuicWriter) {
var type = FrameType.STREAM_BASE
if (offset > 0) type = type or FrameType.STREAM_OFF_BIT
if (explicitLength) type = type or FrameType.STREAM_LEN_BIT
if (fin) type = type or FrameType.STREAM_FIN_BIT
out.writeByte(type.toInt())
out.writeVarint(streamId)
if (offset > 0) out.writeVarint(offset)
if (explicitLength) out.writeVarint(data.size.toLong())
out.writeBytes(data)
}
}
class AckFrame(
val largestAcknowledged: Long,
val ackDelay: Long,
/** Pairs of (gap, ackRangeLength). The first range covers `largestAcknowledged - first_range_length`. */
val firstAckRange: Long,
val additionalRanges: List<AckRange> = emptyList(),
) : Frame() {
override fun encode(out: QuicWriter) {
out.writeByte(FrameType.ACK.toInt())
out.writeVarint(largestAcknowledged)
out.writeVarint(ackDelay)
out.writeVarint(additionalRanges.size.toLong())
out.writeVarint(firstAckRange)
for (r in additionalRanges) {
out.writeVarint(r.gap)
out.writeVarint(r.ackRangeLength)
}
}
}
data class AckRange(
val gap: Long,
val ackRangeLength: Long,
)
class ConnectionCloseFrame(
val errorCode: Long,
val frameType: Long?,
val reason: String,
) : Frame() {
override fun encode(out: QuicWriter) {
if (frameType != null) {
out.writeByte(FrameType.CONNECTION_CLOSE_TRANSPORT.toInt())
out.writeVarint(errorCode)
out.writeVarint(frameType)
} else {
out.writeByte(FrameType.CONNECTION_CLOSE_APP.toInt())
out.writeVarint(errorCode)
}
val reasonBytes = reason.encodeToByteArray()
out.writeVarint(reasonBytes.size.toLong())
out.writeBytes(reasonBytes)
}
}
class MaxDataFrame(
val maxData: Long,
) : Frame() {
override fun encode(out: QuicWriter) {
out.writeByte(FrameType.MAX_DATA.toInt())
out.writeVarint(maxData)
}
}
class MaxStreamDataFrame(
val streamId: Long,
val maxStreamData: Long,
) : Frame() {
override fun encode(out: QuicWriter) {
out.writeByte(FrameType.MAX_STREAM_DATA.toInt())
out.writeVarint(streamId)
out.writeVarint(maxStreamData)
}
}
class MaxStreamsFrame(
val bidi: Boolean,
val maxStreams: Long,
) : Frame() {
override fun encode(out: QuicWriter) {
out.writeByte(if (bidi) FrameType.MAX_STREAMS_BIDI.toInt() else FrameType.MAX_STREAMS_UNI.toInt())
out.writeVarint(maxStreams)
}
}
class DatagramFrame(
val data: ByteArray,
val explicitLength: Boolean = true,
) : Frame() {
override fun encode(out: QuicWriter) {
if (explicitLength) {
out.writeByte(FrameType.DATAGRAM_LEN.toInt())
out.writeVarint(data.size.toLong())
} else {
out.writeByte(FrameType.DATAGRAM.toInt())
}
out.writeBytes(data)
}
}
class HandshakeDoneFrame : Frame() {
override fun encode(out: QuicWriter) {
out.writeByte(FrameType.HANDSHAKE_DONE.toInt())
}
}
class NewConnectionIdFrame(
val sequenceNumber: Long,
val retirePriorTo: Long,
val connectionId: ByteArray,
val statelessResetToken: ByteArray,
) : Frame() {
override fun encode(out: QuicWriter) {
out.writeByte(FrameType.NEW_CONNECTION_ID.toInt())
out.writeVarint(sequenceNumber)
out.writeVarint(retirePriorTo)
require(statelessResetToken.size == 16) { "stateless reset token must be 16 bytes" }
out.writeByte(connectionId.size)
out.writeBytes(connectionId)
out.writeBytes(statelessResetToken)
}
}
/**
* Decode a stream of frames from [data]. Padding bytes (0x00) are silently
* absorbed. Unknown frame types raise [QuicCodecException] (per RFC 9000 §19
* we MUST close the connection with FRAME_ENCODING_ERROR).
*/
fun decodeFrames(data: ByteArray): List<Frame> {
val out = mutableListOf<Frame>()
val r = QuicReader(data)
while (r.hasMore()) {
val typeByte = r.readByte()
if (typeByte == 0x00) {
// Padding — skip; many padding bytes coalesce into one logical PaddingFrame.
continue
}
// Move back one byte: type can be a varint, but for the codes < 0x40 it's identical.
// For datagrams with length prefix the type is 0x31 which fits in 1 byte.
// ACK_ECN (0x03), STREAM with all flag combinations (0x08..0x0f) all <= 0x40.
// We don't expect any frame type to exceed 0x40 in our minimal subset.
val type = typeByte.toLong()
when {
type == FrameType.PING -> out += PingFrame
type == FrameType.ACK -> {
val largest = r.readVarint()
val delay = r.readVarint()
val numRanges = r.readVarint().toInt()
val firstRange = r.readVarint()
val ranges = mutableListOf<AckRange>()
repeat(numRanges) {
val gap = r.readVarint()
val len = r.readVarint()
ranges += AckRange(gap, len)
}
out += AckFrame(largest, delay, firstRange, ranges)
}
type == FrameType.ACK_ECN -> {
val largest = r.readVarint()
val delay = r.readVarint()
val numRanges = r.readVarint().toInt()
val firstRange = r.readVarint()
val ranges = mutableListOf<AckRange>()
repeat(numRanges) {
val gap = r.readVarint()
val len = r.readVarint()
ranges += AckRange(gap, len)
}
// skip ECN counts
r.readVarint(); r.readVarint(); r.readVarint()
out += AckFrame(largest, delay, firstRange, ranges)
}
type == FrameType.CRYPTO -> {
val offset = r.readVarint()
val len = r.readVarint().toInt()
val data2 = r.readBytes(len)
out += CryptoFrame(offset, data2)
}
type in FrameType.STREAM_BASE..(FrameType.STREAM_BASE or 0x07) -> {
val flags = (type - FrameType.STREAM_BASE)
val hasOff = (flags and FrameType.STREAM_OFF_BIT) != 0L
val hasLen = (flags and FrameType.STREAM_LEN_BIT) != 0L
val fin = (flags and FrameType.STREAM_FIN_BIT) != 0L
val streamId = r.readVarint()
val offset = if (hasOff) r.readVarint() else 0L
val payload =
if (hasLen) {
val ln = r.readVarint().toInt()
r.readBytes(ln)
} else {
// "remainder of the packet"
r.readBytes(r.remaining)
}
out += StreamFrame(streamId, offset, payload, fin, hasLen)
}
type == FrameType.MAX_DATA -> out += MaxDataFrame(r.readVarint())
type == FrameType.MAX_STREAM_DATA -> out += MaxStreamDataFrame(r.readVarint(), r.readVarint())
type == FrameType.MAX_STREAMS_BIDI -> out += MaxStreamsFrame(true, r.readVarint())
type == FrameType.MAX_STREAMS_UNI -> out += MaxStreamsFrame(false, r.readVarint())
type == FrameType.DATA_BLOCKED -> r.readVarint() // ignored
type == FrameType.STREAM_DATA_BLOCKED -> {
r.readVarint(); r.readVarint()
}
type == FrameType.STREAMS_BLOCKED_BIDI || type == FrameType.STREAMS_BLOCKED_UNI -> r.readVarint()
type == FrameType.NEW_CONNECTION_ID -> {
val seq = r.readVarint()
val retire = r.readVarint()
val cidLen = r.readByte()
val cid = r.readBytes(cidLen)
val token = r.readBytes(16)
out += NewConnectionIdFrame(seq, retire, cid, token)
}
type == FrameType.RETIRE_CONNECTION_ID -> r.readVarint()
type == FrameType.PATH_CHALLENGE -> r.readBytes(8)
type == FrameType.PATH_RESPONSE -> r.readBytes(8)
type == FrameType.CONNECTION_CLOSE_TRANSPORT -> {
val err = r.readVarint()
val frameType2 = r.readVarint()
val reasonLen = r.readVarint().toInt()
val reason = r.readBytes(reasonLen).decodeToString()
out += ConnectionCloseFrame(err, frameType2, reason)
}
type == FrameType.CONNECTION_CLOSE_APP -> {
val err = r.readVarint()
val reasonLen = r.readVarint().toInt()
val reason = r.readBytes(reasonLen).decodeToString()
out += ConnectionCloseFrame(err, null, reason)
}
type == FrameType.HANDSHAKE_DONE -> out += HandshakeDoneFrame()
type == FrameType.DATAGRAM -> {
val payload = r.readBytes(r.remaining)
out += DatagramFrame(payload, explicitLength = false)
}
type == FrameType.DATAGRAM_LEN -> {
val ln = r.readVarint().toInt()
out += DatagramFrame(r.readBytes(ln), explicitLength = true)
}
else -> throw QuicCodecException("unknown frame type 0x${type.toString(16)}")
}
}
return out
}
/** Encode a list of frames to bytes (no padding inserted). */
fun encodeFrames(frames: List<Frame>): ByteArray {
val w = QuicWriter()
for (f in frames) f.encode(w)
return w.toByteArray()
}
@@ -0,0 +1,268 @@
/*
* 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.QuicCodecException
import com.vitorpamplona.quic.QuicReader
import com.vitorpamplona.quic.QuicWriter
import com.vitorpamplona.quic.Varint
import com.vitorpamplona.quic.connection.ConnectionId
import com.vitorpamplona.quic.crypto.Aead
import com.vitorpamplona.quic.crypto.HeaderProtection
import com.vitorpamplona.quic.crypto.aeadNonce
import com.vitorpamplona.quic.crypto.applyHeaderProtectionMask
/**
* Long-header packet per RFC 9000 §17.2. Used for Initial, 0-RTT, Handshake,
* Retry. (Retry has its own structure — we implement build/parse for the
* other three.)
*
* Wire layout:
*
* first_byte (1 byte) — header form, type, packet number length
* version (4 bytes) — 0x00000001 for QUIC v1
* dcid (1 + dcid_len bytes)
* scid (1 + scid_len bytes)
* token (varint length + bytes; only Initial)
* length (varint) — covers PN + payload + AEAD tag
* packet_number (1..4 bytes, length encoded in first_byte low bits)
* payload (encrypted)
*/
data class LongHeaderPlaintextPacket(
val type: LongHeaderType,
val version: Int = QuicVersion.V1,
val dcid: ConnectionId,
val scid: ConnectionId,
val token: ByteArray = ByteArray(0),
val packetNumber: Long,
val payload: ByteArray,
)
object LongHeaderPacket {
/**
* Encode + protect a long-header plaintext packet:
* 1. Build the unprotected header.
* 2. Compute the encrypted payload via AEAD with packet_number as nonce.
* 3. Apply header protection over the first byte + packet number.
*
* Returns the on-the-wire bytes.
*/
fun build(
plain: LongHeaderPlaintextPacket,
aead: Aead,
key: ByteArray,
iv: ByteArray,
hp: HeaderProtection,
hpKey: ByteArray,
largestAckedInSpace: Long,
): ByteArray {
val pnLen = com.vitorpamplona.quic.connection.PacketNumberSpaceState.encodeLength(
plain.packetNumber,
largestAckedInSpace,
)
require(pnLen in 1..4)
// Build the unprotected header
val w = QuicWriter()
val firstByteOffset = w.size
val firstByte = 0xC0 or (plain.type.code shl 4) or (pnLen - 1)
w.writeByte(firstByte)
w.writeUint32(plain.version)
w.writeByte(plain.dcid.length)
w.writeBytes(plain.dcid.bytes)
w.writeByte(plain.scid.length)
w.writeBytes(plain.scid.bytes)
if (plain.type == LongHeaderType.INITIAL) {
w.writeVarint(plain.token.size.toLong())
w.writeBytes(plain.token)
}
// Length covers PN bytes + payload + AEAD tag.
val lengthValue = pnLen + plain.payload.size + aead.tagLength
w.writeVarint(lengthValue.toLong())
val pnOffset = w.size
// Encode the packet number big-endian, low bytes
for (i in pnLen - 1 downTo 0) {
w.writeByte(((plain.packetNumber ushr (i * 8)) and 0xFF).toInt())
}
val headerBytes = w.toByteArray()
// Encrypt payload
val nonce = aeadNonce(iv, plain.packetNumber)
val ciphertext = aead.seal(key, nonce, headerBytes, plain.payload)
// Concatenate header + ciphertext
val packet = ByteArray(headerBytes.size + ciphertext.size)
headerBytes.copyInto(packet, 0)
ciphertext.copyInto(packet, headerBytes.size)
// Apply header protection. Sample is 16 bytes starting 4 bytes after pnOffset.
val sampleStart = pnOffset + 4
require(sampleStart + 16 <= packet.size) { "packet too short for HP sample" }
val sample = packet.copyOfRange(sampleStart, sampleStart + 16)
val mask = hp.mask(hpKey, sample)
applyHeaderProtectionMask(packet, firstByteOffset, pnOffset, pnLen, mask)
return packet
}
/**
* Strip header protection + decrypt a long-header packet, returning the
* parsed packet and the number of bytes consumed (so the caller can
* advance through coalesced datagrams).
*
* Returns null if the packet failed authentication — caller should drop
* silently per RFC 9001 §5.5.
*/
fun parseAndDecrypt(
bytes: ByteArray,
offset: Int,
aead: Aead,
key: ByteArray,
iv: ByteArray,
hp: HeaderProtection,
hpKey: ByteArray,
largestReceivedInSpace: Long,
): ParseResult? {
val packetStart = offset
val r = QuicReader(bytes, offset)
val first = r.readByte()
require((first and 0x80) != 0) { "not a long-header packet" }
val typeBits = (first ushr 4) and 0x03
val type = LongHeaderType.fromTypeBits(typeBits)
val version = r.readUint32().toInt()
val dcidLen = r.readByte()
val dcidBytes = r.readBytes(dcidLen)
val scidLen = r.readByte()
val scidBytes = r.readBytes(scidLen)
val token =
if (type == LongHeaderType.INITIAL) {
val tokenLen = r.readVarint().toInt()
r.readBytes(tokenLen)
} else {
ByteArray(0)
}
val length = r.readVarint().toInt()
val pnOffset = r.position
if (pnOffset + length > offset + bytes.size - packetStart + bytes.size /* room check */) {
// length sanity
}
if (pnOffset + length > bytes.size) return null
// Sample for HP starts at pnOffset + 4.
val sampleStart = pnOffset + 4
if (sampleStart + 16 > bytes.size) return null
val sample = bytes.copyOfRange(sampleStart, sampleStart + 16)
val mask = hp.mask(hpKey, sample)
// Make a private copy of the packet so we can mutate the header in place.
val packetEnd = pnOffset + length
val packet = bytes.copyOfRange(packetStart, packetEnd)
val localPnOffset = pnOffset - packetStart
// Step 1: unmask the first byte so we can read pnLen.
val firstByteMask = if ((first and 0x80) != 0) 0x0F else 0x1F
packet[0] = (first xor (mask[0].toInt() and firstByteMask)).toByte()
val pnLen = ((packet[0].toInt() and 0xFF) and 0x03) + 1
// Step 2: unmask exactly `pnLen` packet-number bytes.
for (i in 0 until pnLen) {
packet[localPnOffset + i] = (packet[localPnOffset + i].toInt() xor mask[1 + i].toInt()).toByte()
}
// Now parse the unprotected packet number (big-endian).
var truncatedPn = 0L
for (i in 0 until pnLen) {
truncatedPn = (truncatedPn shl 8) or (packet[localPnOffset + i].toInt() and 0xFF).toLong()
}
val fullPn = com.vitorpamplona.quic.connection.PacketNumberSpaceState.decodePacketNumber(
largestReceived = largestReceivedInSpace,
truncatedPn = truncatedPn,
pnLen = pnLen,
)
val aadEnd = localPnOffset + pnLen
val aad = packet.copyOfRange(0, aadEnd)
val ciphertext = packet.copyOfRange(aadEnd, packet.size)
val nonce = aeadNonce(iv, fullPn)
val plaintext = aead.open(key, nonce, aad, ciphertext) ?: return null
return ParseResult(
packet =
LongHeaderPlaintextPacket(
type = type,
version = version,
dcid = ConnectionId(dcidBytes),
scid = ConnectionId(scidBytes),
token = token,
packetNumber = fullPn,
payload = plaintext,
),
consumed = packetEnd - packetStart,
)
}
/**
* Peek the destination CID, source CID, and total length of a long-header
* packet without decrypting. Useful for routing inbound coalesced
* datagrams to the right key set.
*/
fun peekHeader(
bytes: ByteArray,
offset: Int = 0,
): PeekedHeader? {
try {
val r = QuicReader(bytes, offset)
val first = r.readByte()
if ((first and 0x80) == 0) return null
val typeBits = (first ushr 4) and 0x03
val type = LongHeaderType.fromTypeBits(typeBits)
val version = r.readUint32().toInt()
val dcidLen = r.readByte()
val dcid = r.readBytes(dcidLen)
val scidLen = r.readByte()
val scid = r.readBytes(scidLen)
val tokenLen =
if (type == LongHeaderType.INITIAL) {
r.readVarint().toInt()
} else {
0
}
if (type == LongHeaderType.INITIAL) r.skip(tokenLen)
val length = r.readVarint().toInt()
val total = r.position - offset + length
return PeekedHeader(type, version, ConnectionId(dcid), ConnectionId(scid), total)
} catch (_: QuicCodecException) {
return null
}
}
data class PeekedHeader(
val type: LongHeaderType,
val version: Int,
val dcid: ConnectionId,
val scid: ConnectionId,
val totalLength: Int,
)
data class ParseResult(
val packet: LongHeaderPlaintextPacket,
val consumed: Int,
)
}
@@ -0,0 +1,46 @@
/*
* 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.connection.PacketNumberSpace
/** QUIC v1 long-header packet types per RFC 9000 §17.2. */
enum class LongHeaderType(
val code: Int,
val space: PacketNumberSpace,
) {
INITIAL(0x00, PacketNumberSpace.INITIAL),
ZERO_RTT(0x01, PacketNumberSpace.APPLICATION),
HANDSHAKE(0x02, PacketNumberSpace.HANDSHAKE),
RETRY(0x03, PacketNumberSpace.INITIAL), // retry has no PN space, INITIAL is a placeholder
;
companion object {
fun fromTypeBits(bits: Int): LongHeaderType =
entries.firstOrNull { it.code == bits } ?: error("unknown long-header type bits: $bits")
}
}
/** QUIC v1 versions we recognise. */
object QuicVersion {
const val V1: Int = 0x00000001
const val VERSION_NEGOTIATION: Int = 0x00000000
}
@@ -0,0 +1,143 @@
/*
* 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.QuicWriter
import com.vitorpamplona.quic.connection.ConnectionId
import com.vitorpamplona.quic.connection.PacketNumberSpaceState
import com.vitorpamplona.quic.crypto.Aead
import com.vitorpamplona.quic.crypto.HeaderProtection
import com.vitorpamplona.quic.crypto.aeadNonce
import com.vitorpamplona.quic.crypto.applyHeaderProtectionMask
/**
* Short-header (1-RTT) QUIC packet per RFC 9000 §17.3.
*
* Wire layout:
* first_byte (1 byte) — bits: 0|1|S|R|R|K|PP (S=spin, K=key-phase, PP=pn length-1)
* dest_cid (variable, length is implicit from connection state)
* packet_number (1..4 bytes)
* payload (encrypted)
*/
data class ShortHeaderPlaintextPacket(
val dcid: ConnectionId,
val packetNumber: Long,
val payload: ByteArray,
val keyPhase: Boolean = false,
)
object ShortHeaderPacket {
fun build(
plain: ShortHeaderPlaintextPacket,
aead: Aead,
key: ByteArray,
iv: ByteArray,
hp: HeaderProtection,
hpKey: ByteArray,
largestAckedInSpace: Long,
): ByteArray {
val pnLen = PacketNumberSpaceState.encodeLength(plain.packetNumber, largestAckedInSpace)
require(pnLen in 1..4)
val w = QuicWriter()
val firstByteOffset = w.size
var firstByte = 0x40 or (pnLen - 1) // 01..0..PP
if (plain.keyPhase) firstByte = firstByte or 0x04
w.writeByte(firstByte)
w.writeBytes(plain.dcid.bytes)
val pnOffset = w.size
for (i in pnLen - 1 downTo 0) {
w.writeByte(((plain.packetNumber ushr (i * 8)) and 0xFF).toInt())
}
val headerBytes = w.toByteArray()
val nonce = aeadNonce(iv, plain.packetNumber)
val ciphertext = aead.seal(key, nonce, headerBytes, plain.payload)
val packet = ByteArray(headerBytes.size + ciphertext.size)
headerBytes.copyInto(packet, 0)
ciphertext.copyInto(packet, headerBytes.size)
val sampleStart = pnOffset + 4
require(sampleStart + 16 <= packet.size) { "packet too short for HP sample" }
val sample = packet.copyOfRange(sampleStart, sampleStart + 16)
val mask = hp.mask(hpKey, sample)
applyHeaderProtectionMask(packet, firstByteOffset, pnOffset, pnLen, mask)
return packet
}
/** Strip HP + decrypt a short-header packet. The DCID length must be known from connection state. */
fun parseAndDecrypt(
bytes: ByteArray,
offset: Int,
dcidLen: Int,
aead: Aead,
key: ByteArray,
iv: ByteArray,
hp: HeaderProtection,
hpKey: ByteArray,
largestReceivedInSpace: Long,
): ParseResult? {
if (offset >= bytes.size) return null
val first = bytes[offset].toInt() and 0xFF
if ((first and 0x80) != 0) return null
val pnOffset = offset + 1 + dcidLen
val sampleStart = pnOffset + 4
if (sampleStart + 16 > bytes.size) return null
val sample = bytes.copyOfRange(sampleStart, sampleStart + 16)
val mask = hp.mask(hpKey, sample)
val packetEnd = bytes.size
val packet = bytes.copyOfRange(offset, packetEnd)
val localPnOffset = pnOffset - offset
val firstByteMask = 0x1F
packet[0] = (first xor (mask[0].toInt() and firstByteMask)).toByte()
val pnLen = ((packet[0].toInt() and 0xFF) and 0x03) + 1
for (i in 0 until pnLen) {
packet[localPnOffset + i] = (packet[localPnOffset + i].toInt() xor mask[1 + i].toInt()).toByte()
}
var truncatedPn = 0L
for (i in 0 until pnLen) {
truncatedPn = (truncatedPn shl 8) or (packet[localPnOffset + i].toInt() and 0xFF).toLong()
}
val fullPn = PacketNumberSpaceState.decodePacketNumber(largestReceivedInSpace, truncatedPn, pnLen)
val aadEnd = localPnOffset + pnLen
val aad = packet.copyOfRange(0, aadEnd)
val ciphertext = packet.copyOfRange(aadEnd, packet.size)
val nonce = aeadNonce(iv, fullPn)
val plaintext = aead.open(key, nonce, aad, ciphertext) ?: return null
return ParseResult(
packet =
ShortHeaderPlaintextPacket(
dcid = com.vitorpamplona.quic.connection.ConnectionId(bytes.copyOfRange(offset + 1, offset + 1 + dcidLen)),
packetNumber = fullPn,
payload = plaintext,
keyPhase = (packet[0].toInt() and 0x04) != 0,
),
consumed = packetEnd - offset,
)
}
data class ParseResult(
val packet: ShortHeaderPlaintextPacket,
val consumed: Int,
)
}
@@ -0,0 +1,123 @@
/*
* 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.stream
/**
* Out-of-order chunk reassembly for one direction of one QUIC stream
* (or for the per-encryption-level CRYPTO offset stream).
*
* Chunks may arrive in any order with possibly overlapping ranges. The buffer
* coalesces them into a single contiguous prefix that's available to the
* consumer via [readContiguous]. We never expose bytes past the contiguous
* frontier — gaps stall further reads until the missing offsets arrive.
*
* For a fully streamed CRYPTO transcript, the consumer just reads contiguous
* bytes whenever new data arrives; the lookup is O(log N) on the gap tree.
*
* Implementation: we store raw chunks sorted by offset. Calls to [insert]
* coalesce adjacent and overlapping ranges. [readContiguous] returns the
* range from the current cursor up to the first gap.
*/
class ReceiveBuffer {
private val chunks = mutableListOf<Chunk>() // sorted by offset, non-overlapping after insert
var readOffset: Long = 0L
private set
/** True once all sender-emitted bytes have been fully delivered. */
var finReceived: Boolean = false
private set
/** Insert a chunk at [offset] of size [data.size]. Idempotent on overlap. */
fun insert(
offset: Long,
data: ByteArray,
fin: Boolean = false,
) {
if (data.isEmpty() && !fin) return
if (fin) finReceived = true
if (data.isEmpty()) return
val end = offset + data.size
// Drop chunk parts already consumed.
if (end <= readOffset) return
val effOffset: Long
val effData: ByteArray
if (offset < readOffset) {
val dropFront = (readOffset - offset).toInt()
effOffset = readOffset
effData = data.copyOfRange(dropFront, data.size)
} else {
effOffset = offset
effData = data
}
var startIdx = 0
while (startIdx < chunks.size && chunks[startIdx].endOffset() < effOffset) startIdx++
var endIdx = startIdx
while (endIdx < chunks.size && chunks[endIdx].offset <= effOffset + effData.size) endIdx++
if (startIdx == endIdx) {
// No overlap — just insert.
chunks.add(startIdx, Chunk(effOffset, effData))
return
}
// Coalesce [startIdx, endIdx) plus the new chunk.
var lo = effOffset
var hi = effOffset + effData.size
for (i in startIdx until endIdx) {
lo = minOf(lo, chunks[i].offset)
hi = maxOf(hi, chunks[i].endOffset())
}
val merged = ByteArray((hi - lo).toInt())
for (i in startIdx until endIdx) {
chunks[i].data.copyInto(merged, (chunks[i].offset - lo).toInt())
}
effData.copyInto(merged, (effOffset - lo).toInt())
// Replace
for (i in 1..(endIdx - startIdx)) chunks.removeAt(startIdx)
chunks.add(startIdx, Chunk(lo, merged))
}
/** Returns and consumes the contiguous bytes available starting from [readOffset]. */
fun readContiguous(): ByteArray {
if (chunks.isEmpty()) return ByteArray(0)
val first = chunks[0]
if (first.offset != readOffset) return ByteArray(0)
val data = first.data
readOffset += data.size
chunks.removeAt(0)
return data
}
/** Bytes already buffered and held back due to gaps. */
fun bufferedAhead(): Long = chunks.sumOf { it.data.size.toLong() }
/** Highest contiguous offset received so far. */
fun contiguousEnd(): Long = readOffset
private class Chunk(
val offset: Long,
val data: ByteArray,
) {
fun endOffset() = offset + data.size
}
}
@@ -0,0 +1,66 @@
/*
* 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.stream
/**
* Helpers for QUIC stream-id semantics per RFC 9000 §2.1.
*
* The two low bits of a stream id encode:
* bit 0: 0 = client-initiated, 1 = server-initiated
* bit 1: 0 = bidirectional, 1 = unidirectional
*/
object StreamId {
fun isClientInitiated(id: Long) = (id and 0x01L) == 0L
fun isBidirectional(id: Long) = (id and 0x02L) == 0L
fun isUnidirectional(id: Long) = (id and 0x02L) != 0L
fun isServerInitiated(id: Long) = (id and 0x01L) != 0L
enum class Kind {
CLIENT_BIDI,
SERVER_BIDI,
CLIENT_UNI,
SERVER_UNI,
}
fun kindOf(id: Long): Kind =
when (id and 0x03L) {
0x00L -> Kind.CLIENT_BIDI
0x01L -> Kind.SERVER_BIDI
0x02L -> Kind.CLIENT_UNI
0x03L -> Kind.SERVER_UNI
else -> error("unreachable")
}
/** Build the n-th stream id of [kind] (n starts at 0). */
fun build(
kind: Kind,
index: Long,
): Long =
when (kind) {
Kind.CLIENT_BIDI -> index shl 2
Kind.SERVER_BIDI -> (index shl 2) or 0x01L
Kind.CLIENT_UNI -> (index shl 2) or 0x02L
Kind.SERVER_UNI -> (index shl 2) or 0x03L
}
}
@@ -0,0 +1,132 @@
/*
* 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.connection.ConnectionId
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 kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class InitialPacketRoundTripTest {
/**
* Round-trip an Initial packet through:
* client-side encrypt + HP-apply → wire bytes → server-side HP-strip + decrypt.
*
* Uses RFC 9001 Appendix A.1's canonical client DCID `0x8394c8f03e515708`
* so the protection material matches the canonical vectors.
*/
@Test
fun client_initial_round_trip() {
val dcid = ConnectionId("8394c8f03e515708".hexToByteArray())
val scid = ConnectionId("00".hexToByteArray())
val proto = InitialSecrets.derive(dcid.bytes)
val hp = AesEcbHeaderProtection(PlatformAesOneBlock)
val payload = "deadbeefcafebabe1234567890abcdef".hexToByteArray()
val plain =
LongHeaderPlaintextPacket(
type = LongHeaderType.INITIAL,
dcid = dcid,
scid = scid,
packetNumber = 0L,
payload = payload,
)
val wire =
LongHeaderPacket.build(
plain = plain,
aead = Aes128Gcm,
key = proto.clientKey,
iv = proto.clientIv,
hp = hp,
hpKey = proto.clientHp,
largestAckedInSpace = -1L,
)
// Server side reverses
val parsed =
LongHeaderPacket.parseAndDecrypt(
bytes = wire,
offset = 0,
aead = Aes128Gcm,
key = proto.clientKey,
iv = proto.clientIv,
hp = hp,
hpKey = proto.clientHp,
largestReceivedInSpace = -1L,
)
assertNotNull(parsed)
assertEquals(LongHeaderType.INITIAL, parsed.packet.type)
assertEquals(dcid, parsed.packet.dcid)
assertEquals(scid, parsed.packet.scid)
assertEquals(0L, parsed.packet.packetNumber)
assertContentEquals(payload, parsed.packet.payload)
assertEquals(wire.size, parsed.consumed)
}
/**
* Auth tag failure with a wrong key must surface as null (drop silently).
*/
@Test
fun decrypt_with_wrong_key_returns_null() {
val dcid = ConnectionId("8394c8f03e515708".hexToByteArray())
val scid = ConnectionId(byteArrayOf(0x42))
val proto = InitialSecrets.derive(dcid.bytes)
val wrongProto = InitialSecrets.derive("0000000000000000".hexToByteArray())
val hp = AesEcbHeaderProtection(PlatformAesOneBlock)
val payload = "00112233445566778899aabbccddeeff".hexToByteArray()
val wire =
LongHeaderPacket.build(
plain =
LongHeaderPlaintextPacket(
type = LongHeaderType.INITIAL,
dcid = dcid,
scid = scid,
packetNumber = 0L,
payload = payload,
),
aead = Aes128Gcm,
key = proto.clientKey,
iv = proto.clientIv,
hp = hp,
hpKey = proto.clientHp,
largestAckedInSpace = -1L,
)
val parsed =
LongHeaderPacket.parseAndDecrypt(
bytes = wire,
offset = 0,
aead = Aes128Gcm,
key = wrongProto.clientKey,
iv = wrongProto.clientIv,
hp = hp,
hpKey = wrongProto.clientHp,
largestReceivedInSpace = -1L,
)
// With a wrong HP key the first byte/PN are mis-unmasked and AEAD will
// certainly fail. We expect a clean null.
assertEquals(null, parsed)
}
}
@@ -0,0 +1,75 @@
/*
* 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.stream
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ReceiveBufferTest {
@Test
fun in_order_chunks_pass_through() {
val buf = ReceiveBuffer()
buf.insert(0, byteArrayOf(1, 2, 3))
assertContentEquals(byteArrayOf(1, 2, 3), buf.readContiguous())
buf.insert(3, byteArrayOf(4, 5))
assertContentEquals(byteArrayOf(4, 5), buf.readContiguous())
assertEquals(5, buf.contiguousEnd())
}
@Test
fun reordered_chunks_are_buffered_until_filled() {
val buf = ReceiveBuffer()
buf.insert(2, byteArrayOf(3, 4, 5))
// Gap at 0..1; nothing yet.
assertEquals(0, buf.readContiguous().size)
buf.insert(0, byteArrayOf(1, 2))
// Now fully contiguous up to 5.
assertContentEquals(byteArrayOf(1, 2, 3, 4, 5), buf.readContiguous())
}
@Test
fun overlapping_chunks_are_deduplicated() {
val buf = ReceiveBuffer()
buf.insert(0, byteArrayOf(1, 2, 3, 4))
buf.insert(2, byteArrayOf(3, 4, 5, 6))
assertContentEquals(byteArrayOf(1, 2, 3, 4, 5, 6), buf.readContiguous())
}
@Test
fun fin_propagates_through_buffer() {
val buf = ReceiveBuffer()
buf.insert(0, byteArrayOf(1, 2, 3), fin = true)
assertTrue(buf.finReceived)
}
@Test
fun later_chunk_preceding_already_consumed_data_is_dropped() {
val buf = ReceiveBuffer()
buf.insert(0, byteArrayOf(1, 2, 3))
buf.readContiguous()
// Now readOffset = 3; this chunk overlaps with already-consumed 0..2.
buf.insert(0, byteArrayOf(1, 2, 3, 4, 5))
// The remaining 4..5 should still come through.
assertContentEquals(byteArrayOf(4, 5), buf.readContiguous())
}
}