diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt new file mode 100644 index 000000000..4837e41fd --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quic.connection + +import com.vitorpamplona.quic.transport.UdpSocket +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Owns the UDP socket and runs the read + send loops for a [QuicConnection]. + * + * Lifecycle: + * - [open] connects the UDP socket, calls [QuicConnection.start], spawns + * read + send loops, and suspends until handshake completes. + * - [close] cancels the loops and closes the socket. + */ +class QuicConnectionDriver( + val connection: QuicConnection, + private val socket: UdpSocket, + parentScope: CoroutineScope, + private val nowMillis: () -> Long = { System.currentTimeMillis() }, +) { + private val job = SupervisorJob(parentScope.coroutineContext[Job]) + private val scope = CoroutineScope(parentScope.coroutineContext + job + Dispatchers.IO) + private val sendLock = Mutex() + + fun start() { + connection.start() + scope.launch { readLoop() } + scope.launch { sendLoop() } + } + + private suspend fun readLoop() { + while (true) { + val datagram = socket.receive() ?: break + sendLock.withLock { + feedDatagram(connection, datagram, nowMillis()) + } + } + } + + private suspend fun sendLoop() { + while (connection.status != QuicConnection.Status.CLOSED) { + sendLock.withLock { + while (true) { + val out = drainOutbound(connection, nowMillis()) ?: break + socket.send(out) + } + } + // Tiny sleep to avoid tight-spin between drains; in practice, application + // writes (via stream.send.enqueue + queueDatagram) wake the loop next tick. + delay(2) + } + } + + fun close() { + connection.close(0L, "") + scope.cancel() + socket.close() + } +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3FrameTypes.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3FrameTypes.kt new file mode 100644 index 000000000..dff614b47 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3FrameTypes.kt @@ -0,0 +1,61 @@ +/* + * 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.http3 + +/** HTTP/3 frame type identifiers per RFC 9114 §11.2 + WebTransport. */ +object Http3FrameType { + const val DATA: Long = 0x00 + const val HEADERS: Long = 0x01 + const val CANCEL_PUSH: Long = 0x03 + const val SETTINGS: Long = 0x04 + const val PUSH_PROMISE: Long = 0x05 + const val GOAWAY: Long = 0x07 + const val MAX_PUSH_ID: Long = 0x0D + + /** WebTransport BIDI stream prefix (per draft) — appears as a frame on a request stream. */ + const val WEBTRANSPORT_BIDI_STREAM: Long = 0x41 +} + +/** HTTP/3 unidirectional stream type prefixes per RFC 9114 §6.2. */ +object Http3StreamType { + const val CONTROL: Long = 0x00 + const val PUSH: Long = 0x01 + const val QPACK_ENCODER: Long = 0x02 + const val QPACK_DECODER: Long = 0x03 + /** WebTransport unidirectional stream type. */ + const val WEBTRANSPORT_UNI_STREAM: Long = 0x54 +} + +/** SETTINGS identifiers we emit / parse. */ +object Http3SettingsId { + const val QPACK_MAX_TABLE_CAPACITY: Long = 0x01 + const val MAX_FIELD_SECTION_SIZE: Long = 0x06 + const val QPACK_BLOCKED_STREAMS: Long = 0x07 + + /** RFC 8441 — accept Extended CONNECT (`:protocol`). */ + const val ENABLE_CONNECT_PROTOCOL: Long = 0x08 + + /** RFC 9297 — HTTP Datagrams. */ + const val H3_DATAGRAM: Long = 0x33 + + /** WebTransport over HTTP/3 (draft / RFC 9220). */ + const val ENABLE_WEBTRANSPORT: Long = 0xc671706a +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3Settings.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3Settings.kt new file mode 100644 index 000000000..cd1147d78 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3Settings.kt @@ -0,0 +1,79 @@ +/* + * 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.http3 + +import com.vitorpamplona.quic.QuicReader +import com.vitorpamplona.quic.QuicWriter + +/** + * HTTP/3 SETTINGS frame body — a sequence of `(id, value)` varint pairs per + * RFC 9114 §7.2.4. Frame type 0x04, length-prefixed payload. + */ +data class Http3Settings( + val settings: Map, +) { + fun encodeBody(): ByteArray { + val w = QuicWriter() + for ((id, value) in settings) { + w.writeVarint(id) + w.writeVarint(value) + } + return w.toByteArray() + } + + fun encodeFrame(): ByteArray { + val body = encodeBody() + val w = QuicWriter() + w.writeVarint(Http3FrameType.SETTINGS) + w.writeVarint(body.size.toLong()) + w.writeBytes(body) + return w.toByteArray() + } + + companion object { + fun decodeBody(body: ByteArray): Http3Settings { + val map = mutableMapOf() + val r = QuicReader(body) + while (r.hasMore()) { + val id = r.readVarint() + val value = r.readVarint() + map[id] = value + } + return Http3Settings(map) + } + } +} + +/** + * Build the SETTINGS frame a WebTransport-over-HTTP/3 client sends on its + * control stream, advertising: + * - SETTINGS_ENABLE_CONNECT_PROTOCOL = 1 (RFC 8441) + * - SETTINGS_H3_DATAGRAM = 1 (RFC 9297) + * - SETTINGS_ENABLE_WEBTRANSPORT = 1 (RFC 9220 / draft) + */ +fun buildClientWebTransportSettings(): Http3Settings = + Http3Settings( + mapOf( + Http3SettingsId.ENABLE_CONNECT_PROTOCOL to 1L, + Http3SettingsId.H3_DATAGRAM to 1L, + Http3SettingsId.ENABLE_WEBTRANSPORT to 1L, + ), + ) diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/qpack/QpackDecoder.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/qpack/QpackDecoder.kt new file mode 100644 index 000000000..3373c157c --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/qpack/QpackDecoder.kt @@ -0,0 +1,112 @@ +/* + * 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.qpack + +import com.vitorpamplona.quic.QuicCodecException + +/** + * QPACK decoder for HTTP/3 field sections per RFC 9204 §4.5. + * + * Supports the static-table-only flavor that nests / most servers use when + * QPACK_MAX_TABLE_CAPACITY is 0 (which is what we advertise). If a server + * sends dynamic-table references, [decode] throws — phase L can extend this + * to the full dynamic table if interop demands. + * + * Field line types (RFC 9204 §4.5.2): + * - 1xxxxxxx: Indexed Field Line (T=1 → static, T=0 → dynamic) + * - 01NTxxxx: Literal Field Line With Name Reference + * - 0001NHxx: Literal Field Line With Literal Name + * - 0000NHxx: same as above (name length prefix variant per RFC 9204 §4.5.6) + * - 0001xxxx: Indexed Field Line With Post-Base Index + */ +class QpackDecoder { + fun decodeFieldSection(payload: ByteArray): List> { + var pos = 0 + // Required Insert Count (8-bit prefix) + val ric = QpackInteger.decode(payload, pos, 8) + pos += ric.bytesConsumed + if (ric.value != 0L) { + throw QuicCodecException("QPACK dynamic table references not supported (Required Insert Count=${ric.value})") + } + // Sign + Delta Base (7-bit prefix) + val deltaBase = QpackInteger.decode(payload, pos, 7) + pos += deltaBase.bytesConsumed + + val out = mutableListOf>() + while (pos < payload.size) { + val first = payload[pos].toInt() and 0xFF + when { + (first and 0x80) != 0 -> { + // Indexed Field Line: 1|T|index(6) + val isStatic = (first and 0x40) != 0 + if (!isStatic) throw QuicCodecException("QPACK dynamic indexed field line unsupported") + val r = QpackInteger.decode(payload, pos, 6) + pos += r.bytesConsumed + val entry = QpackStaticTable.entries[r.value.toInt()] + out += entry + } + (first and 0x40) != 0 -> { + // Literal Field Line With Name Reference: 0|1|N|T|index(4) + val isStatic = (first and 0x10) != 0 + if (!isStatic) throw QuicCodecException("QPACK dynamic name-ref field line unsupported") + val nameRef = QpackInteger.decode(payload, pos, 4) + pos += nameRef.bytesConsumed + val name = QpackStaticTable.entries[nameRef.value.toInt()].first + val (value, valueLen) = readStringLiteral(payload, pos) + pos += valueLen + out += name to value + } + (first and 0x20) != 0 -> { + // Literal Field Line With Literal Name: 0|0|1|N|H|len(3) + val nameH = (first and 0x08) != 0 + val nameLenR = QpackInteger.decode(payload, pos, 3) + pos += nameLenR.bytesConsumed + val nameBytes = ByteArray(nameLenR.value.toInt()) + payload.copyInto(nameBytes, 0, pos, pos + nameBytes.size) + pos += nameBytes.size + val name = if (nameH) QpackHuffman.decode(nameBytes).decodeToString() else nameBytes.decodeToString() + val (value, valueLen) = readStringLiteral(payload, pos) + pos += valueLen + out += name to value + } + else -> { + // Indexed Field Line With Post-Base Index — uses dynamic table; reject. + throw QuicCodecException("QPACK post-base indexed field line unsupported") + } + } + } + return out + } + + /** Read a `H|len(7-bit prefix)|bytes` string literal, returning the value and bytes consumed. */ + private fun readStringLiteral( + payload: ByteArray, + offset: Int, + ): Pair { + val first = payload[offset].toInt() and 0xFF + val huffman = (first and 0x80) != 0 + val lenR = QpackInteger.decode(payload, offset, 7) + val dataStart = offset + lenR.bytesConsumed + val raw = payload.copyOfRange(dataStart, dataStart + lenR.value.toInt()) + val str = if (huffman) QpackHuffman.decode(raw).decodeToString() else raw.decodeToString() + return str to (lenR.bytesConsumed + raw.size) + } +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/qpack/QpackEncoder.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/qpack/QpackEncoder.kt new file mode 100644 index 000000000..1f94c4c6e --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/qpack/QpackEncoder.kt @@ -0,0 +1,81 @@ +/* + * 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.qpack + +import com.vitorpamplona.quic.QuicWriter + +/** + * QPACK encoder (literal-only) per RFC 9204. + * + * We never insert into the dynamic table on the encoder side, so the encoded + * field section starts with `Required Insert Count = 0` (8-bit prefix) and + * `Delta Base = 0` (S=0, 7-bit prefix). Field lines after the prefix are: + * + * - Indexed Field Line, T=1: `1|1|index(6-bit prefix)` → reference into static table + * - Literal Field Line With Name Reference, T=1: `0|1|N|index(4-bit prefix)` + * followed by `H(1)|len(7)` + value bytes (no Huffman from us). + * - Literal Field Line With Literal Name: `0|0|1|N|H(1)|len(3)` followed by + * name bytes, then `H(1)|len(7)` + value bytes. + * + * We pick the smallest representation that still avoids the dynamic table. + */ +class QpackEncoder { + /** Encode [headers] into a field section payload (the contents of an HTTP/3 HEADERS frame). */ + fun encodeFieldSection(headers: List>): ByteArray { + val w = QuicWriter() + // Required Insert Count = 0 + QpackInteger.encode(0, 8, 0, w) + // Sign + Delta Base; sign=0, delta=0 + QpackInteger.encode(0, 7, 0, w) + for ((name, value) in headers) encodeOne(w, name.lowercase(), value) + return w.toByteArray() + } + + private fun encodeOne( + w: QuicWriter, + name: String, + value: String, + ) { + val pairIdx = QpackStaticTable.pairToIndex[name to value] + if (pairIdx != null) { + // Indexed field line, static. Pattern: 1|T(=1)|index(6-bit prefix) + QpackInteger.encode(pairIdx.toLong(), 6, 0xC0, w) + return + } + val nameIdx = QpackStaticTable.nameToIndex[name] + if (nameIdx != null) { + // Literal Field Line With Name Reference, static. Pattern: 0|1|N(=0)|T(=1)|index(4-bit prefix) + QpackInteger.encode(nameIdx.toLong(), 4, 0x50, w) + // Then value: H=0|len(7-bit prefix) + val valueBytes = value.encodeToByteArray() + QpackInteger.encode(valueBytes.size.toLong(), 7, 0x00, w) + w.writeBytes(valueBytes) + return + } + // Literal field line with literal name. Pattern: 0|0|1|N(=0)|H(=0)|len(3-bit prefix) + val nameBytes = name.encodeToByteArray() + QpackInteger.encode(nameBytes.size.toLong(), 3, 0x20, w) + w.writeBytes(nameBytes) + val valueBytes = value.encodeToByteArray() + QpackInteger.encode(valueBytes.size.toLong(), 7, 0x00, w) + w.writeBytes(valueBytes) + } +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/qpack/QpackHuffman.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/qpack/QpackHuffman.kt new file mode 100644 index 000000000..c65d333cd --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/qpack/QpackHuffman.kt @@ -0,0 +1,144 @@ +/* + * 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.qpack + +import com.vitorpamplona.quic.QuicCodecException + +/** + * HPACK / QPACK Huffman codec per RFC 7541 Appendix B. + * + * We implement decoding only — the encoder always emits Huffman=0 literals. + * The codec is a static prefix code; we walk the input bit-by-bit through a + * lazily-built lookup tree. + */ +object QpackHuffman { + /** RFC 7541 Appendix B — (code, length) for each of the 256 symbols + EOS at index 256. */ + private val table: Array = + arrayOf( + intArrayOf(0x1ff8, 13), intArrayOf(0x7fffd8, 23), intArrayOf(0xfffffe2, 28), intArrayOf(0xfffffe3, 28), + intArrayOf(0xfffffe4, 28), intArrayOf(0xfffffe5, 28), intArrayOf(0xfffffe6, 28), intArrayOf(0xfffffe7, 28), + intArrayOf(0xfffffe8, 28), intArrayOf(0xffffea, 24), intArrayOf(0x3ffffffc, 30), intArrayOf(0xfffffe9, 28), + intArrayOf(0xfffffea, 28), intArrayOf(0x3ffffffd, 30), intArrayOf(0xfffffeb, 28), intArrayOf(0xfffffec, 28), + intArrayOf(0xfffffed, 28), intArrayOf(0xfffffee, 28), intArrayOf(0xfffffef, 28), intArrayOf(0xffffff0, 28), + intArrayOf(0xffffff1, 28), intArrayOf(0xffffff2, 28), intArrayOf(0x3ffffffe, 30), intArrayOf(0xffffff3, 28), + intArrayOf(0xffffff4, 28), intArrayOf(0xffffff5, 28), intArrayOf(0xffffff6, 28), intArrayOf(0xffffff7, 28), + intArrayOf(0xffffff8, 28), intArrayOf(0xffffff9, 28), intArrayOf(0xffffffa, 28), intArrayOf(0xffffffb, 28), + intArrayOf(0x14, 6), intArrayOf(0x3f8, 10), intArrayOf(0x3f9, 10), intArrayOf(0xffa, 12), + intArrayOf(0x1ff9, 13), intArrayOf(0x15, 6), intArrayOf(0xf8, 8), intArrayOf(0x7fa, 11), + intArrayOf(0x3fa, 10), intArrayOf(0x3fb, 10), intArrayOf(0xf9, 8), intArrayOf(0x7fb, 11), + intArrayOf(0xfa, 8), intArrayOf(0x16, 6), intArrayOf(0x17, 6), intArrayOf(0x18, 6), + intArrayOf(0x0, 5), intArrayOf(0x1, 5), intArrayOf(0x2, 5), intArrayOf(0x19, 6), + intArrayOf(0x1a, 6), intArrayOf(0x1b, 6), intArrayOf(0x1c, 6), intArrayOf(0x1d, 6), + intArrayOf(0x1e, 6), intArrayOf(0x1f, 6), intArrayOf(0x5c, 7), intArrayOf(0xfb, 8), + intArrayOf(0x7ffc, 15), intArrayOf(0x20, 6), intArrayOf(0xffb, 12), intArrayOf(0x3fc, 10), + intArrayOf(0x1ffa, 13), intArrayOf(0x21, 6), intArrayOf(0x5d, 7), intArrayOf(0x5e, 7), + intArrayOf(0x5f, 7), intArrayOf(0x60, 7), intArrayOf(0x61, 7), intArrayOf(0x62, 7), + intArrayOf(0x63, 7), intArrayOf(0x64, 7), intArrayOf(0x65, 7), intArrayOf(0x66, 7), + intArrayOf(0x67, 7), intArrayOf(0x68, 7), intArrayOf(0x69, 7), intArrayOf(0x6a, 7), + intArrayOf(0x6b, 7), intArrayOf(0x6c, 7), intArrayOf(0x6d, 7), intArrayOf(0x6e, 7), + intArrayOf(0x6f, 7), intArrayOf(0x70, 7), intArrayOf(0x71, 7), intArrayOf(0x72, 7), + intArrayOf(0xfc, 8), intArrayOf(0x73, 7), intArrayOf(0xfd, 8), intArrayOf(0x1ffb, 13), + intArrayOf(0x7fff0, 19), intArrayOf(0x1ffc, 13), intArrayOf(0x3ffc, 14), intArrayOf(0x22, 6), + intArrayOf(0x7ffd, 15), intArrayOf(0x3, 5), intArrayOf(0x23, 6), intArrayOf(0x4, 5), + intArrayOf(0x24, 6), intArrayOf(0x5, 5), intArrayOf(0x25, 6), intArrayOf(0x26, 6), + intArrayOf(0x27, 6), intArrayOf(0x6, 5), intArrayOf(0x74, 7), intArrayOf(0x75, 7), + intArrayOf(0x28, 6), intArrayOf(0x29, 6), intArrayOf(0x2a, 6), intArrayOf(0x7, 5), + intArrayOf(0x2b, 6), intArrayOf(0x76, 7), intArrayOf(0x2c, 6), intArrayOf(0x8, 5), + intArrayOf(0x9, 5), intArrayOf(0x2d, 6), intArrayOf(0x77, 7), intArrayOf(0x78, 7), + intArrayOf(0x79, 7), intArrayOf(0x7a, 7), intArrayOf(0x7b, 7), intArrayOf(0x7ffe, 15), + intArrayOf(0x7fc, 11), intArrayOf(0x3ffd, 14), intArrayOf(0x1ffd, 13), intArrayOf(0xffffffc, 28), + intArrayOf(0xfffe6, 20), intArrayOf(0x3fffd2, 22), intArrayOf(0xfffe7, 20), intArrayOf(0xfffe8, 20), + intArrayOf(0x3fffd3, 22), intArrayOf(0x3fffd4, 22), intArrayOf(0x3fffd5, 22), intArrayOf(0x7fffd9, 23), + intArrayOf(0x3fffd6, 22), intArrayOf(0x7fffda, 23), intArrayOf(0x7fffdb, 23), intArrayOf(0x7fffdc, 23), + intArrayOf(0x7fffdd, 23), intArrayOf(0x7fffde, 23), intArrayOf(0xffffeb, 24), intArrayOf(0x7fffdf, 23), + intArrayOf(0xffffec, 24), intArrayOf(0xffffed, 24), intArrayOf(0x3fffd7, 22), intArrayOf(0x7fffe0, 23), + intArrayOf(0xffffee, 24), intArrayOf(0x7fffe1, 23), intArrayOf(0x7fffe2, 23), intArrayOf(0x7fffe3, 23), + intArrayOf(0x7fffe4, 23), intArrayOf(0x1fffdc, 21), intArrayOf(0x3fffd8, 22), intArrayOf(0x7fffe5, 23), + intArrayOf(0x3fffd9, 22), intArrayOf(0x7fffe6, 23), intArrayOf(0x7fffe7, 23), intArrayOf(0xffffef, 24), + intArrayOf(0x3fffda, 22), intArrayOf(0x1fffdd, 21), intArrayOf(0xfffe9, 20), intArrayOf(0x3fffdb, 22), + intArrayOf(0x3fffdc, 22), intArrayOf(0x7fffe8, 23), intArrayOf(0x7fffe9, 23), intArrayOf(0x1fffde, 21), + intArrayOf(0x7fffea, 23), intArrayOf(0x3fffdd, 22), intArrayOf(0x3fffde, 22), intArrayOf(0xfffff0, 24), + intArrayOf(0x1fffdf, 21), intArrayOf(0x3fffdf, 22), intArrayOf(0x7fffeb, 23), intArrayOf(0x7fffec, 23), + intArrayOf(0x1fffe0, 21), intArrayOf(0x1fffe1, 21), intArrayOf(0x3fffe0, 22), intArrayOf(0x1fffe2, 21), + intArrayOf(0x7fffed, 23), intArrayOf(0x3fffe1, 22), intArrayOf(0x7fffee, 23), intArrayOf(0x7fffef, 23), + intArrayOf(0xfffea, 20), intArrayOf(0x3fffe2, 22), intArrayOf(0x3fffe3, 22), intArrayOf(0x3fffe4, 22), + intArrayOf(0x7ffff0, 23), intArrayOf(0x3fffe5, 22), intArrayOf(0x3fffe6, 22), intArrayOf(0x7ffff1, 23), + intArrayOf(0x3ffffe0, 26), intArrayOf(0x3ffffe1, 26), intArrayOf(0xfffeb, 20), intArrayOf(0x7fff1, 19), + intArrayOf(0x3fffe7, 22), intArrayOf(0x7ffff2, 23), intArrayOf(0x3fffe8, 22), intArrayOf(0x1ffffec, 25), + intArrayOf(0x3ffffe2, 26), intArrayOf(0x3ffffe3, 26), intArrayOf(0x3ffffe4, 26), intArrayOf(0x7ffffde, 27), + intArrayOf(0x7ffffdf, 27), intArrayOf(0x3ffffe5, 26), intArrayOf(0xfffff1, 24), intArrayOf(0x1ffffed, 25), + intArrayOf(0x7fff2, 19), intArrayOf(0x1fffe3, 21), intArrayOf(0x3ffffe6, 26), intArrayOf(0x7ffffe0, 27), + intArrayOf(0x7ffffe1, 27), intArrayOf(0x3ffffe7, 26), intArrayOf(0x7ffffe2, 27), intArrayOf(0xfffff2, 24), + intArrayOf(0x1fffe4, 21), intArrayOf(0x1fffe5, 21), intArrayOf(0x3ffffe8, 26), intArrayOf(0x3ffffe9, 26), + intArrayOf(0xffffffd, 28), intArrayOf(0x7ffffe3, 27), intArrayOf(0x7ffffe4, 27), intArrayOf(0x7ffffe5, 27), + intArrayOf(0xfffec, 20), intArrayOf(0xfffff3, 24), intArrayOf(0xfffed, 20), intArrayOf(0x1fffe6, 21), + intArrayOf(0x3fffe9, 22), intArrayOf(0x1fffe7, 21), intArrayOf(0x1fffe8, 21), intArrayOf(0x7ffff3, 23), + intArrayOf(0x3fffea, 22), intArrayOf(0x3fffeb, 22), intArrayOf(0x1ffffee, 25), intArrayOf(0x1ffffef, 25), + intArrayOf(0xfffff4, 24), intArrayOf(0xfffff5, 24), intArrayOf(0x3ffffea, 26), intArrayOf(0x7ffff4, 23), + intArrayOf(0x3ffffeb, 26), intArrayOf(0x7ffffe6, 27), intArrayOf(0x3ffffec, 26), intArrayOf(0x3ffffed, 26), + intArrayOf(0x7ffffe7, 27), intArrayOf(0x7ffffe8, 27), intArrayOf(0x7ffffe9, 27), intArrayOf(0x7ffffea, 27), + intArrayOf(0x7ffffeb, 27), intArrayOf(0xffffffe, 28), intArrayOf(0x7ffffec, 27), intArrayOf(0x7ffffed, 27), + intArrayOf(0x7ffffee, 27), intArrayOf(0x7ffffef, 27), intArrayOf(0x7fffff0, 27), intArrayOf(0x3ffffee, 26), + intArrayOf(0x3fffffff, 30), + ) + + /** Decode a Huffman-encoded byte sequence into a UTF-8 string. */ + fun decode(encoded: ByteArray): ByteArray { + val result = ArrayList() + var bitBuf = 0L + var bitsAvailable = 0 + var i = 0 + while (i < encoded.size || bitsAvailable >= 5) { + // Pull more bits + while (bitsAvailable < 32 && i < encoded.size) { + bitBuf = (bitBuf shl 8) or (encoded[i].toLong() and 0xFF) + bitsAvailable += 8 + i++ + } + // Try matching the longest code we can + var matched = false + for (sym in 0..255) { + val (code, len) = Pair(table[sym][0], table[sym][1]) + if (len > bitsAvailable) continue + val candidate = (bitBuf ushr (bitsAvailable - len)) and ((1L shl len) - 1) + if (candidate.toInt() == code) { + result.add(sym.toByte()) + bitsAvailable -= len + bitBuf = bitBuf and ((1L shl bitsAvailable) - 1) + matched = true + break + } + } + if (!matched) { + // Either trailing padding (all 1s up to 7 bits) or error. + if (i >= encoded.size) { + if (bitsAvailable in 1..7) { + val pad = (bitBuf and ((1L shl bitsAvailable) - 1)) + if (pad == ((1L shl bitsAvailable) - 1)) break + } + if (bitsAvailable == 0) break + } + throw QuicCodecException("invalid Huffman bit stream") + } + } + return result.toByteArray() + } +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/qpack/QpackInteger.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/qpack/QpackInteger.kt new file mode 100644 index 000000000..da4381703 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/qpack/QpackInteger.kt @@ -0,0 +1,86 @@ +/* + * 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.qpack + +import com.vitorpamplona.quic.QuicCodecException +import com.vitorpamplona.quic.QuicWriter + +/** + * QPACK / HPACK prefixed-integer codec per RFC 7541 §5.1. + * + * The first byte's low [prefixBits] bits hold either the value (if it fits) + * or all-ones, signaling that further bytes carry the residue using the + * 7-bits-per-byte continuation pattern. + */ +object QpackInteger { + fun encode( + value: Long, + prefixBits: Int, + firstBytePrefix: Int, + out: QuicWriter, + ) { + require(prefixBits in 1..8) + val maxPrefix = (1L shl prefixBits) - 1 + if (value < maxPrefix) { + out.writeByte((firstBytePrefix and (0xFF shl prefixBits)) or value.toInt()) + return + } + out.writeByte((firstBytePrefix and (0xFF shl prefixBits)) or maxPrefix.toInt()) + var remaining = value - maxPrefix + while (remaining >= 0x80) { + out.writeByte(((remaining and 0x7F) or 0x80).toInt()) + remaining = remaining ushr 7 + } + out.writeByte(remaining.toInt()) + } + + /** + * Decode starting at [offset] in [src], with [prefixBits] in the first byte. + * Returns (value, bytesConsumed). + */ + fun decode( + src: ByteArray, + offset: Int, + prefixBits: Int, + ): DecodeResult { + require(prefixBits in 1..8) + if (offset >= src.size) throw QuicCodecException("truncated QPACK integer") + val first = src[offset].toInt() and 0xFF + val maxPrefix = (1 shl prefixBits) - 1 + var value = (first and maxPrefix).toLong() + if (value < maxPrefix) return DecodeResult(value, 1) + var pos = offset + 1 + var shift = 0 + while (true) { + if (pos >= src.size) throw QuicCodecException("truncated QPACK integer continuation") + val b = src[pos++].toInt() and 0xFF + value += ((b and 0x7F).toLong() shl shift) + if ((b and 0x80) == 0) return DecodeResult(value, pos - offset) + shift += 7 + if (shift > 63) throw QuicCodecException("QPACK integer too large") + } + } + + data class DecodeResult( + val value: Long, + val bytesConsumed: Int, + ) +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/qpack/QpackStaticTable.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/qpack/QpackStaticTable.kt new file mode 100644 index 000000000..b823ed908 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/qpack/QpackStaticTable.kt @@ -0,0 +1,144 @@ +/* + * 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.qpack + +/** + * QPACK static table per RFC 9204 Appendix A. + * + * 99 entries total. Indices are 0-based on the wire (a varint-prefixed + * "indexed field line" with T=1 carries an index into this table). + */ +object QpackStaticTable { + val entries: List> = + listOf( + ":authority" to "", + ":path" to "/", + "age" to "0", + "content-disposition" to "", + "content-length" to "0", + "cookie" to "", + "date" to "", + "etag" to "", + "if-modified-since" to "", + "if-none-match" to "", + "last-modified" to "", + "link" to "", + "location" to "", + "referer" to "", + "set-cookie" to "", + ":method" to "CONNECT", + ":method" to "DELETE", + ":method" to "GET", + ":method" to "HEAD", + ":method" to "OPTIONS", + ":method" to "POST", + ":method" to "PUT", + ":scheme" to "http", + ":scheme" to "https", + ":status" to "103", + ":status" to "200", + ":status" to "304", + ":status" to "404", + ":status" to "503", + "accept" to "*/*", + "accept" to "application/dns-message", + "accept-encoding" to "gzip, deflate, br", + "accept-ranges" to "bytes", + "access-control-allow-headers" to "cache-control", + "access-control-allow-headers" to "content-type", + "access-control-allow-origin" to "*", + "cache-control" to "max-age=0", + "cache-control" to "max-age=2592000", + "cache-control" to "max-age=604800", + "cache-control" to "no-cache", + "cache-control" to "no-store", + "cache-control" to "public, max-age=31536000", + "content-encoding" to "br", + "content-encoding" to "gzip", + "content-type" to "application/dns-message", + "content-type" to "application/javascript", + "content-type" to "application/json", + "content-type" to "application/x-www-form-urlencoded", + "content-type" to "image/gif", + "content-type" to "image/jpeg", + "content-type" to "image/png", + "content-type" to "text/css", + "content-type" to "text/html; charset=utf-8", + "content-type" to "text/plain", + "content-type" to "text/plain;charset=utf-8", + "range" to "bytes=0-", + "strict-transport-security" to "max-age=31536000", + "strict-transport-security" to "max-age=31536000; includesubdomains", + "strict-transport-security" to "max-age=31536000; includesubdomains; preload", + "vary" to "accept-encoding", + "vary" to "origin", + "x-content-type-options" to "nosniff", + "x-xss-protection" to "1; mode=block", + ":status" to "100", + ":status" to "204", + ":status" to "206", + ":status" to "302", + ":status" to "400", + ":status" to "403", + ":status" to "421", + ":status" to "425", + ":status" to "500", + "accept-language" to "", + "access-control-allow-credentials" to "FALSE", + "access-control-allow-credentials" to "TRUE", + "access-control-allow-headers" to "*", + "access-control-allow-methods" to "get", + "access-control-allow-methods" to "get, post, options", + "access-control-allow-methods" to "options", + "access-control-expose-headers" to "content-length", + "access-control-request-headers" to "content-type", + "access-control-request-method" to "get", + "access-control-request-method" to "post", + "alt-svc" to "clear", + "authorization" to "", + "content-security-policy" to "script-src 'none'; object-src 'none'; base-uri 'none'", + "early-data" to "1", + "expect-ct" to "", + "forwarded" to "", + "if-range" to "", + "origin" to "", + "purpose" to "prefetch", + "server" to "", + "timing-allow-origin" to "*", + "upgrade-insecure-requests" to "1", + "user-agent" to "", + "x-forwarded-for" to "", + "x-frame-options" to "deny", + "x-frame-options" to "sameorigin", + ) + + /** Build a quick lookup of name → first index for encoder name-reference selection. */ + val nameToIndex: Map = + entries.foldIndexed(mutableMapOf()) { idx, acc, (name, _) -> + acc.putIfAbsent(name, idx); acc + } + + /** Look up name+value → index, or null if no exact match. */ + val pairToIndex: Map, Int> = + entries.foldIndexed(mutableMapOf()) { idx, acc, pair -> + acc.putIfAbsent(pair, idx); acc + } +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/ExtendedConnect.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/ExtendedConnect.kt new file mode 100644 index 000000000..237955da7 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/ExtendedConnect.kt @@ -0,0 +1,67 @@ +/* + * 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.webtransport + +import com.vitorpamplona.quic.QuicWriter +import com.vitorpamplona.quic.http3.Http3FrameType +import com.vitorpamplona.quic.qpack.QpackEncoder + +/** + * Build the headers for a WebTransport Extended CONNECT request per + * RFC 9220 / draft-ietf-webtrans-http3. + * + * :method = CONNECT + * :protocol = webtransport + * :scheme = https + * :authority = host[:port] + * :path = path + * [optional] authorization = Bearer + */ +fun buildExtendedConnectHeaders( + authority: String, + path: String, + bearerToken: String? = null, + extra: List> = emptyList(), +): List> { + val headers = + mutableListOf( + ":method" to "CONNECT", + ":protocol" to "webtransport", + ":scheme" to "https", + ":authority" to authority, + ":path" to path, + ) + if (bearerToken != null) { + headers += "authorization" to "Bearer $bearerToken" + } + headers += extra + return headers +} + +/** Encode a HEADERS frame body containing the given header list as QPACK bytes. */ +fun encodeHeadersFrame(headers: List>): ByteArray { + val qpack = QpackEncoder().encodeFieldSection(headers) + val w = QuicWriter() + w.writeVarint(Http3FrameType.HEADERS) + w.writeVarint(qpack.size.toLong()) + w.writeBytes(qpack) + return w.toByteArray() +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtCapsule.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtCapsule.kt new file mode 100644 index 000000000..fe5fe04f9 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtCapsule.kt @@ -0,0 +1,55 @@ +/* + * 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.webtransport + +import com.vitorpamplona.quic.QuicWriter + +/** WebTransport capsule type identifiers (draft-ietf-webtrans-http3 / RFC 9220). */ +object WtCapsuleType { + /** WT_CLOSE_SESSION — graceful close on the CONNECT bidi. */ + const val WT_CLOSE_SESSION: Long = 0x2843 + + /** WT_DRAIN_SESSION — peer signals it will not open new streams. */ + const val WT_DRAIN_SESSION: Long = 0x78AE +} + +/** Encode a `(type, body)` capsule per the HTTP capsule protocol (draft-ietf-httpbis-h3-datagram). */ +fun encodeCapsule( + type: Long, + body: ByteArray, +): ByteArray { + val w = QuicWriter() + w.writeVarint(type) + w.writeVarint(body.size.toLong()) + w.writeBytes(body) + return w.toByteArray() +} + +/** Encode a WT_CLOSE_SESSION capsule with optional application error code + reason. */ +fun encodeCloseSessionCapsule( + errorCode: Int = 0, + reason: String = "", +): ByteArray { + val body = QuicWriter() + body.writeUint32(errorCode) + body.writeBytes(reason.encodeToByteArray()) + return encodeCapsule(WtCapsuleType.WT_CLOSE_SESSION, body.toByteArray()) +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtDatagram.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtDatagram.kt new file mode 100644 index 000000000..bb1718b5b --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtDatagram.kt @@ -0,0 +1,88 @@ +/* + * 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.webtransport + +import com.vitorpamplona.quic.QuicReader +import com.vitorpamplona.quic.QuicWriter +import com.vitorpamplona.quic.Varint + +/** + * WebTransport datagram framing per RFC 9297 + draft-ietf-webtrans-http3. + * + * An HTTP/3 datagram payload starts with a varint *quarter stream id* — the + * stream id of the WT session's CONNECT bidi divided by 4. Followed by the + * application-level WT datagram bytes. + */ +object WtDatagram { + fun encode( + connectStreamId: Long, + payload: ByteArray, + ): ByteArray { + require(connectStreamId % 4 == 0L) { + "WT session stream id must be a client-initiated bidi (id % 4 == 0): $connectStreamId" + } + val quarter = connectStreamId / 4 + val w = QuicWriter() + w.writeVarint(quarter) + w.writeBytes(payload) + return w.toByteArray() + } + + /** Returns (quarterStreamId * 4, payload). Returns null on truncation. */ + fun decode(bytes: ByteArray): Decoded? { + val r = Varint.decode(bytes, 0) ?: return null + if (r.bytesConsumed > bytes.size) return null + val sessionId = r.value * 4 + val payload = bytes.copyOfRange(r.bytesConsumed, bytes.size) + return Decoded(sessionId, payload) + } + + data class Decoded( + val sessionStreamId: Long, + val payload: ByteArray, + ) +} + +/** WebTransport stream type prefixes (sent as the first varint on the QUIC stream). */ +object WtStreamType { + /** Client-initiated bidirectional WT stream — followed by quarter session id. */ + const val WT_BIDI_STREAM: Long = 0x41 + /** Client-initiated unidirectional WT stream — preceded by HTTP/3 stream-type 0x54 + quarter session id. */ + const val WT_UNI_STREAM_PREFIX: Long = 0x54 +} + +/** Encode the prefix bytes that go on a freshly-opened WebTransport bidi stream. */ +fun encodeWtBidiStreamPrefix(connectStreamId: Long): ByteArray { + require(connectStreamId % 4 == 0L) + val w = QuicWriter() + w.writeVarint(WtStreamType.WT_BIDI_STREAM) + w.writeVarint(connectStreamId / 4) + return w.toByteArray() +} + +/** Encode the prefix bytes that go on a freshly-opened WebTransport unidi stream. */ +fun encodeWtUniStreamPrefix(connectStreamId: Long): ByteArray { + require(connectStreamId % 4 == 0L) + val w = QuicWriter() + w.writeVarint(WtStreamType.WT_UNI_STREAM_PREFIX) + w.writeVarint(connectStreamId / 4) + return w.toByteArray() +} diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/qpack/QpackRoundTripTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/qpack/QpackRoundTripTest.kt new file mode 100644 index 000000000..77fb870f3 --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/qpack/QpackRoundTripTest.kt @@ -0,0 +1,67 @@ +/* + * 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.qpack + +import kotlin.test.Test +import kotlin.test.assertEquals + +class QpackRoundTripTest { + @Test + fun static_table_indexed_field_line_round_trip() { + val headers = listOf(":method" to "GET", ":scheme" to "https") + val encoded = QpackEncoder().encodeFieldSection(headers) + val decoded = QpackDecoder().decodeFieldSection(encoded) + assertEquals(headers, decoded) + } + + @Test + fun literal_with_static_name_reference_round_trip() { + val headers = listOf(":authority" to "example.com", ":path" to "/moq") + val encoded = QpackEncoder().encodeFieldSection(headers) + val decoded = QpackDecoder().decodeFieldSection(encoded) + assertEquals(headers, decoded) + } + + @Test + fun literal_with_literal_name_round_trip() { + val headers = listOf("x-custom-header" to "deadbeef", "another-one" to "value") + val encoded = QpackEncoder().encodeFieldSection(headers) + val decoded = QpackDecoder().decodeFieldSection(encoded) + assertEquals(headers, decoded) + } + + @Test + fun mixed_extended_connect_round_trip() { + // Mirrors what we'll send for WebTransport extended-CONNECT. + val headers = + listOf( + ":method" to "CONNECT", + ":protocol" to "webtransport", + ":scheme" to "https", + ":authority" to "nostrnests.com", + ":path" to "/moq", + "authorization" to "Bearer token-abc123", + ) + val encoded = QpackEncoder().encodeFieldSection(headers) + val decoded = QpackDecoder().decodeFieldSection(encoded) + assertEquals(headers, decoded) + } +} diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/webtransport/WtFramingTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/webtransport/WtFramingTest.kt new file mode 100644 index 000000000..487883447 --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/webtransport/WtFramingTest.kt @@ -0,0 +1,67 @@ +/* + * 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.webtransport + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class WtFramingTest { + @Test + fun datagram_round_trip() { + val payload = "hello moq".encodeToByteArray() + val encoded = WtDatagram.encode(connectStreamId = 0L, payload = payload) + val decoded = WtDatagram.decode(encoded) + assertNotNull(decoded) + assertEquals(0L, decoded.sessionStreamId) + assertContentEquals(payload, decoded.payload) + } + + @Test + fun datagram_round_trip_nonzero_session_id() { + val payload = byteArrayOf(0x01, 0x02, 0x03) + val encoded = WtDatagram.encode(connectStreamId = 4L, payload = payload) + val decoded = WtDatagram.decode(encoded)!! + assertEquals(4L, decoded.sessionStreamId) + assertContentEquals(payload, decoded.payload) + } + + @Test + fun bidi_stream_prefix_encodes_0x41_then_session_id() { + val prefix = encodeWtBidiStreamPrefix(0L) + // 0x41 = 65 needs the 2-byte varint form: high byte 0x40, low byte 0x41. + // Then session id = 0 in 1-byte varint. + assertEquals(0x40.toByte(), prefix[0]) + assertEquals(0x41.toByte(), prefix[1]) + assertEquals(0x00.toByte(), prefix[2]) + } + + @Test + fun close_session_capsule_starts_with_2843() { + val capsule = encodeCloseSessionCapsule(0, "") + // 0x2843 encoded as varint occupies 2 bytes: 0x68, 0x43 (with the 2-byte form prefix). + // Verify by parsing. + val hi = capsule[0].toInt() and 0xFF + // Top two bits of first byte indicate 2-byte varint: 01xxxxxx + assertEquals(0x40, hi and 0xC0) + } +}