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:
@@ -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
|
||||
}
|
||||
}
|
||||
+132
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user