diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/Hkdf.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/Hkdf.kt index 8b9ee39cd5..9ede033861 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/Hkdf.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/Hkdf.kt @@ -47,6 +47,72 @@ class Hkdf( return mac.doFinal() } + /** + * General-purpose HKDF-Expand per RFC 5869 §2.3. + * + * `T(0) = empty; T(i) = HMAC(prk, T(i-1) || info || i)` and the output is + * the first [length] bytes of `T(1) || T(2) || ...`. The PRK should already + * be at least [hashLen] bytes (typically the output of [extract]). + * + * Output length is capped at `255 * hashLen` per RFC 5869. + */ + fun expand( + prk: ByteArray, + info: ByteArray, + length: Int, + ): ByteArray { + require(length >= 0) { "negative length: $length" } + require(length <= 255 * hashLen) { "HKDF expand length too large: $length > ${255 * hashLen}" } + if (length == 0) return ByteArray(0) + + val mac = MacInstance(algorithm, prk) + val out = ByteArray(length) + var prev = ByteArray(0) + var written = 0 + var counter = 1 + while (written < length) { + mac.update(prev) + mac.update(info) + mac.update(counter.toByte()) + prev = mac.doFinal() + mac.init(prk, algorithm) // reset the MAC for the next round + val toCopy = minOf(prev.size, length - written) + prev.copyInto(out, written, 0, toCopy) + written += toCopy + counter++ + } + return out + } + + /** + * RFC 8446 §7.1 HKDF-Expand-Label. + * + * Builds the labeled HKDFLabel structure: + * uint16 length + * opaque label<7..255> = "tls13 " + label + * opaque context<0..255> = transcript hash bytes + * and feeds it into [expand]. + */ + fun expandLabel( + prk: ByteArray, + label: String, + context: ByteArray, + length: Int, + ): ByteArray { + val labelBytes = "tls13 $label".encodeToByteArray() + require(labelBytes.size <= 255) { "label too long: ${labelBytes.size}" } + require(context.size <= 255) { "context too long: ${context.size}" } + // 2 (length) + 1 (label-len) + label + 1 (context-len) + context + val info = ByteArray(2 + 1 + labelBytes.size + 1 + context.size) + info[0] = (length ushr 8 and 0xFF).toByte() + info[1] = (length and 0xFF).toByte() + info[2] = labelBytes.size.toByte() + labelBytes.copyInto(info, 3) + info[3 + labelBytes.size] = context.size.toByte() + context.copyInto(info, 3 + labelBytes.size + 1) + return expand(prk, info, length) + } + /* Old expand version for reference before we converted to the faster below. fun expand( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/HkdfText.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/HkdfText.kt index ce2c789116..23541d1ad2 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/HkdfText.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/HkdfText.kt @@ -106,4 +106,91 @@ class HkdfText { assertEquals("3769af12ff4dbf44e516a22d1d0512e8bc42516d59e8bf401ea346a4d60dccf7", result2.chachaKey.toHexKey()) assertEquals("77938d29bb13ea73f677ac27", result2.chachaNonce.toHexKey()) } + + /** + * RFC 5869 Test Case 1 — basic HKDF-SHA256 with non-empty info. + */ + @Test + fun rfc5869_test_case_1() { + val ikm = "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b".hexToByteArray() + val salt = "000102030405060708090a0b0c".hexToByteArray() + val info = "f0f1f2f3f4f5f6f7f8f9".hexToByteArray() + val prk = hkdf.extract(ikm, salt) + assertEquals( + "077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5", + prk.toHexKey(), + ) + val okm = hkdf.expand(prk, info, 42) + assertEquals( + "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865", + okm.toHexKey(), + ) + } + + /** + * RFC 5869 Test Case 2 — longer inputs, 82-byte output (spans multiple HMAC rounds). + */ + @Test + fun rfc5869_test_case_2() { + val ikm = + ( + "000102030405060708090a0b0c0d0e0f" + + "101112131415161718191a1b1c1d1e1f" + + "202122232425262728292a2b2c2d2e2f" + + "303132333435363738393a3b3c3d3e3f" + + "404142434445464748494a4b4c4d4e4f" + ).hexToByteArray() + val salt = + ( + "606162636465666768696a6b6c6d6e6f" + + "707172737475767778797a7b7c7d7e7f" + + "808182838485868788898a8b8c8d8e8f" + + "909192939495969798999a9b9c9d9e9f" + + "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf" + ).hexToByteArray() + val info = + ( + "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf" + + "c0c1c2c3c4c5c6c7c8c9cacbcccdcecf" + + "d0d1d2d3d4d5d6d7d8d9dadbdcdddedf" + + "e0e1e2e3e4e5e6e7e8e9eaebecedeeef" + + "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff" + ).hexToByteArray() + val prk = hkdf.extract(ikm, salt) + assertEquals( + "06a6b88c5853361a06104c9ceb35b45cef760014904671014a193f40c15fc244", + prk.toHexKey(), + ) + val okm = hkdf.expand(prk, info, 82) + assertEquals( + "b11e398dc80327a1c8e7f78c596a4934" + + "4f012eda2d4efad8a050cc4c19afa97c" + + "59045a99cac7827271cb41c65e590e09" + + "da3275600c2f09b8367793a9aca3db71" + + "cc30c58179ec3e87c14c01d5c1f3434f1d87", + okm.toHexKey(), + ) + } + + /** + * RFC 8448 §3 — TLS 1.3 ClientHello derived "early secret" expand-label vectors. + * + * Verifies our expandLabel implementation matches the canonical TLS 1.3 derivation. + */ + @Test + fun rfc8448_early_secret_derived() { + // PSK = all zeros, salt = all zeros → standard early-secret PRK + val earlySecret = hkdf.extract(ByteArray(32), ByteArray(32)) + assertEquals( + "33ad0a1c607ec03b09e6cd9893680ce210adf300aa1f2660e1b22e10f170f92a", + earlySecret.toHexKey(), + ) + // SHA-256 of empty string + val emptyHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".hexToByteArray() + val derived = hkdf.expandLabel(earlySecret, "derived", emptyHash, 32) + assertEquals( + "6f2615a108c702c5678f54fc9dbab69716c076189c48250cebeac3576c3611ba", + derived.toHexKey(), + ) + } } diff --git a/quic/build.gradle.kts b/quic/build.gradle.kts index e93f0942af..5814d2ecc0 100644 --- a/quic/build.gradle.kts +++ b/quic/build.gradle.kts @@ -26,6 +26,9 @@ plugins { } kotlin { + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } jvm { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/Buffer.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/Buffer.kt new file mode 100644 index 0000000000..f46e0b3bdb --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/Buffer.kt @@ -0,0 +1,280 @@ +/* + * 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 + +/** + * Append-only big-endian buffer used by the QUIC + TLS 1.3 + HTTP/3 + QPACK + * encoders. Doubles in capacity when full. + */ +class QuicWriter( + initialCapacity: Int = 64, +) { + private var buf: ByteArray = ByteArray(initialCapacity) + private var pos: Int = 0 + + val size: Int get() = pos + + fun toByteArray(): ByteArray = buf.copyOf(pos) + + fun writeByte(value: Int) { + ensure(1) + buf[pos++] = value.toByte() + } + + fun writeUint16(value: Int) { + ensure(2) + buf[pos++] = (value ushr 8 and 0xFF).toByte() + buf[pos++] = (value and 0xFF).toByte() + } + + fun writeUint24(value: Int) { + ensure(3) + buf[pos++] = (value ushr 16 and 0xFF).toByte() + buf[pos++] = (value ushr 8 and 0xFF).toByte() + buf[pos++] = (value and 0xFF).toByte() + } + + fun writeUint32(value: Int) { + ensure(4) + buf[pos++] = (value ushr 24 and 0xFF).toByte() + buf[pos++] = (value ushr 16 and 0xFF).toByte() + buf[pos++] = (value ushr 8 and 0xFF).toByte() + buf[pos++] = (value and 0xFF).toByte() + } + + fun writeUint32(value: Long) = writeUint32(value.toInt()) + + fun writeUint64(value: Long) { + ensure(8) + buf[pos++] = (value ushr 56 and 0xFF).toByte() + buf[pos++] = (value ushr 48 and 0xFF).toByte() + buf[pos++] = (value ushr 40 and 0xFF).toByte() + buf[pos++] = (value ushr 32 and 0xFF).toByte() + buf[pos++] = (value ushr 24 and 0xFF).toByte() + buf[pos++] = (value ushr 16 and 0xFF).toByte() + buf[pos++] = (value ushr 8 and 0xFF).toByte() + buf[pos++] = (value and 0xFF).toByte() + } + + fun writeBytes(bytes: ByteArray) { + ensure(bytes.size) + bytes.copyInto(buf, pos) + pos += bytes.size + } + + fun writeBytes( + bytes: ByteArray, + offset: Int, + length: Int, + ) { + ensure(length) + bytes.copyInto(buf, pos, offset, offset + length) + pos += length + } + + fun writeVarint(value: Long) { + ensure(Varint.size(value)) + pos += Varint.writeTo(value, buf, pos) + } + + fun writeVarint(value: Int) = writeVarint(value.toLong()) + + /** Write a TLS-style 1-byte length prefixed byte array. */ + fun writeTlsOpaque1(bytes: ByteArray) { + require(bytes.size <= 0xFF) { "tls opaque<0..255> too long: ${bytes.size}" } + writeByte(bytes.size) + writeBytes(bytes) + } + + /** Write a TLS-style 2-byte length prefixed byte array. */ + fun writeTlsOpaque2(bytes: ByteArray) { + require(bytes.size <= 0xFFFF) { "tls opaque<0..65535> too long: ${bytes.size}" } + writeUint16(bytes.size) + writeBytes(bytes) + } + + /** Write a TLS-style 3-byte length prefixed byte array. */ + fun writeTlsOpaque3(bytes: ByteArray) { + require(bytes.size <= 0xFFFFFF) { "tls opaque<0..16M> too long: ${bytes.size}" } + writeUint24(bytes.size) + writeBytes(bytes) + } + + /** + * Reserve a 2-byte length placeholder, run [block] which writes content, + * then back-fill the length with `pos_after - pos_after_length_field`. + */ + inline fun withUint16Length(block: QuicWriter.() -> Unit) { + val lenAt = size + writeUint16(0) + val before = size + block() + val len = size - before + require(len <= 0xFFFF) { "uint16 length overflow: $len" } + backpatchUint16(lenAt, len) + } + + inline fun withUint24Length(block: QuicWriter.() -> Unit) { + val lenAt = size + writeUint24(0) + val before = size + block() + val len = size - before + require(len <= 0xFFFFFF) { "uint24 length overflow: $len" } + backpatchUint24(lenAt, len) + } + + inline fun withUint8Length(block: QuicWriter.() -> Unit) { + val lenAt = size + writeByte(0) + val before = size + block() + val len = size - before + require(len <= 0xFF) { "uint8 length overflow: $len" } + buf()[lenAt] = len.toByte() + } + + @PublishedApi + internal fun buf(): ByteArray = buf + + @PublishedApi + internal fun backpatchUint16( + offset: Int, + value: Int, + ) { + buf[offset] = (value ushr 8 and 0xFF).toByte() + buf[offset + 1] = (value and 0xFF).toByte() + } + + @PublishedApi + internal fun backpatchUint24( + offset: Int, + value: Int, + ) { + buf[offset] = (value ushr 16 and 0xFF).toByte() + buf[offset + 1] = (value ushr 8 and 0xFF).toByte() + buf[offset + 2] = (value and 0xFF).toByte() + } + + private fun ensure(more: Int) { + if (pos + more > buf.size) { + var newSize = buf.size * 2 + while (newSize < pos + more) newSize *= 2 + buf = buf.copyOf(newSize) + } + } +} + +/** Big-endian read cursor with bounds-checked accessors. */ +class QuicReader( + val src: ByteArray, + private var pos: Int = 0, + private val end: Int = src.size, +) { + val position: Int get() = pos + val remaining: Int get() = end - pos + val limit: Int get() = end + + fun hasMore(): Boolean = pos < end + + fun seek(offset: Int) { + require(offset in 0..end) { "seek out of bounds: $offset" } + pos = offset + } + + fun skip(n: Int) { + require(n) + pos += n + } + + fun readByte(): Int { + require(1) + return src[pos++].toInt() and 0xFF + } + + fun readUint16(): Int { + require(2) + val a = src[pos].toInt() and 0xFF + val b = src[pos + 1].toInt() and 0xFF + pos += 2 + return (a shl 8) or b + } + + fun readUint24(): Int { + require(3) + val a = src[pos].toInt() and 0xFF + val b = src[pos + 1].toInt() and 0xFF + val c = src[pos + 2].toInt() and 0xFF + pos += 3 + return (a shl 16) or (b shl 8) or c + } + + fun readUint32(): Long { + require(4) + val a = (src[pos].toInt() and 0xFF).toLong() + val b = (src[pos + 1].toInt() and 0xFF).toLong() + val c = (src[pos + 2].toInt() and 0xFF).toLong() + val d = (src[pos + 3].toInt() and 0xFF).toLong() + pos += 4 + return (a shl 24) or (b shl 16) or (c shl 8) or d + } + + fun readUint64(): Long { + require(8) + var v = 0L + for (i in 0 until 8) v = (v shl 8) or ((src[pos + i].toInt() and 0xFF).toLong()) + pos += 8 + return v + } + + fun readBytes(n: Int): ByteArray { + require(n) + val out = src.copyOfRange(pos, pos + n) + pos += n + return out + } + + fun readVarint(): Long { + val dec = + Varint.decode(src, pos) + ?: throw QuicCodecException("truncated varint at pos=$pos remaining=$remaining") + require(dec.bytesConsumed) + pos += dec.bytesConsumed + return dec.value + } + + fun readTlsOpaque1(): ByteArray = readBytes(readByte()) + + fun readTlsOpaque2(): ByteArray = readBytes(readUint16()) + + fun readTlsOpaque3(): ByteArray = readBytes(readUint24()) + + private fun require(n: Int) { + if (pos + n > end) { + throw QuicCodecException("short read at pos=$pos: wanted $n, have $remaining") + } + } +} + +class QuicCodecException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/TransportParameters.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/TransportParameters.kt new file mode 100644 index 0000000000..d06debe982 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/TransportParameters.kt @@ -0,0 +1,200 @@ +/* + * 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.QuicReader +import com.vitorpamplona.quic.QuicWriter +import com.vitorpamplona.quic.Varint + +/** + * QUIC transport parameter identifiers per RFC 9000 §18.2 + RFC 9221 (datagrams). + * + * Each parameter is carried as `(id varint)(length varint)(value)`. + */ +object TransportParameterId { + const val ORIGINAL_DESTINATION_CONNECTION_ID: Long = 0x00 + const val MAX_IDLE_TIMEOUT: Long = 0x01 + const val STATELESS_RESET_TOKEN: Long = 0x02 + const val MAX_UDP_PAYLOAD_SIZE: Long = 0x03 + const val INITIAL_MAX_DATA: Long = 0x04 + const val INITIAL_MAX_STREAM_DATA_BIDI_LOCAL: Long = 0x05 + const val INITIAL_MAX_STREAM_DATA_BIDI_REMOTE: Long = 0x06 + const val INITIAL_MAX_STREAM_DATA_UNI: Long = 0x07 + const val INITIAL_MAX_STREAMS_BIDI: Long = 0x08 + const val INITIAL_MAX_STREAMS_UNI: Long = 0x09 + const val ACK_DELAY_EXPONENT: Long = 0x0a + const val MAX_ACK_DELAY: Long = 0x0b + const val DISABLE_ACTIVE_MIGRATION: Long = 0x0c + const val PREFERRED_ADDRESS: Long = 0x0d + const val ACTIVE_CONNECTION_ID_LIMIT: Long = 0x0e + const val INITIAL_SOURCE_CONNECTION_ID: Long = 0x0f + const val RETRY_SOURCE_CONNECTION_ID: Long = 0x10 + + /** RFC 9221 — `max_datagram_frame_size`. */ + const val MAX_DATAGRAM_FRAME_SIZE: Long = 0x20 +} + +/** + * QUIC transport parameters as exchanged inside the TLS QUIC transport_params + * extension. + * + * Only the parameters we actually advertise / interpret are surfaced as named + * fields. Unknown parameters are kept in [unknown] to be re-emitted verbatim + * if needed (we don't currently echo). + */ +data class TransportParameters( + val initialMaxData: Long? = null, + val initialMaxStreamDataBidiLocal: Long? = null, + val initialMaxStreamDataBidiRemote: Long? = null, + val initialMaxStreamDataUni: Long? = null, + val initialMaxStreamsBidi: Long? = null, + val initialMaxStreamsUni: Long? = null, + val maxIdleTimeoutMillis: Long? = null, + val maxUdpPayloadSize: Long? = null, + val ackDelayExponent: Long? = null, + val maxAckDelay: Long? = null, + val activeConnectionIdLimit: Long? = null, + val disableActiveMigration: Boolean = false, + val initialSourceConnectionId: ByteArray? = null, + val originalDestinationConnectionId: ByteArray? = null, + val retrySourceConnectionId: ByteArray? = null, + val statelessResetToken: ByteArray? = null, + val maxDatagramFrameSize: Long? = null, + val unknown: Map = emptyMap(), +) { + fun encode(): ByteArray { + val w = QuicWriter() + + fun writeVarintParam( + id: Long, + value: Long, + ) { + w.writeVarint(id) + w.writeVarint(Varint.size(value).toLong()) + w.writeVarint(value) + } + + fun writeBytesParam( + id: Long, + value: ByteArray, + ) { + w.writeVarint(id) + w.writeVarint(value.size.toLong()) + w.writeBytes(value) + } + + fun writeFlagParam(id: Long) { + w.writeVarint(id) + w.writeVarint(0L) + } + + initialMaxData?.let { writeVarintParam(TransportParameterId.INITIAL_MAX_DATA, it) } + initialMaxStreamDataBidiLocal?.let { writeVarintParam(TransportParameterId.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL, it) } + initialMaxStreamDataBidiRemote?.let { writeVarintParam(TransportParameterId.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE, it) } + initialMaxStreamDataUni?.let { writeVarintParam(TransportParameterId.INITIAL_MAX_STREAM_DATA_UNI, it) } + initialMaxStreamsBidi?.let { writeVarintParam(TransportParameterId.INITIAL_MAX_STREAMS_BIDI, it) } + initialMaxStreamsUni?.let { writeVarintParam(TransportParameterId.INITIAL_MAX_STREAMS_UNI, it) } + maxIdleTimeoutMillis?.let { writeVarintParam(TransportParameterId.MAX_IDLE_TIMEOUT, it) } + maxUdpPayloadSize?.let { writeVarintParam(TransportParameterId.MAX_UDP_PAYLOAD_SIZE, it) } + ackDelayExponent?.let { writeVarintParam(TransportParameterId.ACK_DELAY_EXPONENT, it) } + maxAckDelay?.let { writeVarintParam(TransportParameterId.MAX_ACK_DELAY, it) } + activeConnectionIdLimit?.let { writeVarintParam(TransportParameterId.ACTIVE_CONNECTION_ID_LIMIT, it) } + if (disableActiveMigration) writeFlagParam(TransportParameterId.DISABLE_ACTIVE_MIGRATION) + initialSourceConnectionId?.let { writeBytesParam(TransportParameterId.INITIAL_SOURCE_CONNECTION_ID, it) } + originalDestinationConnectionId?.let { writeBytesParam(TransportParameterId.ORIGINAL_DESTINATION_CONNECTION_ID, it) } + retrySourceConnectionId?.let { writeBytesParam(TransportParameterId.RETRY_SOURCE_CONNECTION_ID, it) } + statelessResetToken?.let { writeBytesParam(TransportParameterId.STATELESS_RESET_TOKEN, it) } + maxDatagramFrameSize?.let { writeVarintParam(TransportParameterId.MAX_DATAGRAM_FRAME_SIZE, it) } + for ((id, bytes) in unknown) writeBytesParam(id, bytes) + + return w.toByteArray() + } + + companion object { + fun decode(bytes: ByteArray): TransportParameters { + val r = QuicReader(bytes) + var initialMaxData: Long? = null + var initialMaxStreamDataBidiLocal: Long? = null + var initialMaxStreamDataBidiRemote: Long? = null + var initialMaxStreamDataUni: Long? = null + var initialMaxStreamsBidi: Long? = null + var initialMaxStreamsUni: Long? = null + var maxIdleTimeoutMillis: Long? = null + var maxUdpPayloadSize: Long? = null + var ackDelayExponent: Long? = null + var maxAckDelay: Long? = null + var activeConnectionIdLimit: Long? = null + var disableActiveMigration = false + var initialSourceConnectionId: ByteArray? = null + var originalDestinationConnectionId: ByteArray? = null + var retrySourceConnectionId: ByteArray? = null + var statelessResetToken: ByteArray? = null + var maxDatagramFrameSize: Long? = null + val unknown = mutableMapOf() + + while (r.hasMore()) { + val id = r.readVarint() + val len = r.readVarint().toInt() + val sub = QuicReader(r.readBytes(len)) + when (id) { + TransportParameterId.INITIAL_MAX_DATA -> initialMaxData = sub.readVarint() + TransportParameterId.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL -> initialMaxStreamDataBidiLocal = sub.readVarint() + TransportParameterId.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE -> initialMaxStreamDataBidiRemote = sub.readVarint() + TransportParameterId.INITIAL_MAX_STREAM_DATA_UNI -> initialMaxStreamDataUni = sub.readVarint() + TransportParameterId.INITIAL_MAX_STREAMS_BIDI -> initialMaxStreamsBidi = sub.readVarint() + TransportParameterId.INITIAL_MAX_STREAMS_UNI -> initialMaxStreamsUni = sub.readVarint() + TransportParameterId.MAX_IDLE_TIMEOUT -> maxIdleTimeoutMillis = sub.readVarint() + TransportParameterId.MAX_UDP_PAYLOAD_SIZE -> maxUdpPayloadSize = sub.readVarint() + TransportParameterId.ACK_DELAY_EXPONENT -> ackDelayExponent = sub.readVarint() + TransportParameterId.MAX_ACK_DELAY -> maxAckDelay = sub.readVarint() + TransportParameterId.ACTIVE_CONNECTION_ID_LIMIT -> activeConnectionIdLimit = sub.readVarint() + TransportParameterId.DISABLE_ACTIVE_MIGRATION -> disableActiveMigration = true + TransportParameterId.INITIAL_SOURCE_CONNECTION_ID -> initialSourceConnectionId = sub.src.copyOfRange(0, len) + TransportParameterId.ORIGINAL_DESTINATION_CONNECTION_ID -> originalDestinationConnectionId = sub.src.copyOfRange(0, len) + TransportParameterId.RETRY_SOURCE_CONNECTION_ID -> retrySourceConnectionId = sub.src.copyOfRange(0, len) + TransportParameterId.STATELESS_RESET_TOKEN -> statelessResetToken = sub.src.copyOfRange(0, len) + TransportParameterId.MAX_DATAGRAM_FRAME_SIZE -> maxDatagramFrameSize = sub.readVarint() + else -> unknown[id] = sub.src.copyOfRange(0, len) + } + } + return TransportParameters( + initialMaxData = initialMaxData, + initialMaxStreamDataBidiLocal = initialMaxStreamDataBidiLocal, + initialMaxStreamDataBidiRemote = initialMaxStreamDataBidiRemote, + initialMaxStreamDataUni = initialMaxStreamDataUni, + initialMaxStreamsBidi = initialMaxStreamsBidi, + initialMaxStreamsUni = initialMaxStreamsUni, + maxIdleTimeoutMillis = maxIdleTimeoutMillis, + maxUdpPayloadSize = maxUdpPayloadSize, + ackDelayExponent = ackDelayExponent, + maxAckDelay = maxAckDelay, + activeConnectionIdLimit = activeConnectionIdLimit, + disableActiveMigration = disableActiveMigration, + initialSourceConnectionId = initialSourceConnectionId, + originalDestinationConnectionId = originalDestinationConnectionId, + retrySourceConnectionId = retrySourceConnectionId, + statelessResetToken = statelessResetToken, + maxDatagramFrameSize = maxDatagramFrameSize, + unknown = unknown, + ) + } + } +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/Aead.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/Aead.kt new file mode 100644 index 0000000000..da1b488cb7 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/Aead.kt @@ -0,0 +1,129 @@ +/* + * 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.crypto + +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 +import com.vitorpamplona.quartz.utils.ciphers.AESGCM + +/** AEAD selector, parameterised by TLS cipher-suite identifier. */ +sealed class Aead { + abstract val keyLength: Int + abstract val nonceLength: Int + abstract val tagLength: Int + + abstract fun seal( + key: ByteArray, + nonce: ByteArray, + aad: ByteArray, + plaintext: ByteArray, + ): ByteArray + + /** Returns null on auth-tag failure. */ + abstract fun open( + key: ByteArray, + nonce: ByteArray, + aad: ByteArray, + ciphertext: ByteArray, + ): ByteArray? +} + +/** AES-128-GCM AEAD via Quartz's AESGCM (which uses JCA underneath on JVM/Android). */ +object Aes128Gcm : Aead() { + override val keyLength = 16 + override val nonceLength = 12 + override val tagLength = 16 + + override fun seal( + key: ByteArray, + nonce: ByteArray, + aad: ByteArray, + plaintext: ByteArray, + ): ByteArray { + require(key.size == keyLength) { "AES-128-GCM key must be 16 bytes" } + require(nonce.size == nonceLength) { "AES-128-GCM nonce must be 12 bytes" } + return AESGCM(key, nonce).encrypt(plaintext, aad) + } + + override fun open( + key: ByteArray, + nonce: ByteArray, + aad: ByteArray, + ciphertext: ByteArray, + ): ByteArray? { + require(key.size == keyLength) { "AES-128-GCM key must be 16 bytes" } + require(nonce.size == nonceLength) { "AES-128-GCM nonce must be 12 bytes" } + return try { + AESGCM(key, nonce).decrypt(ciphertext, aad) + } catch (_: Throwable) { + null + } + } +} + +/** ChaCha20-Poly1305 AEAD via Quartz's pure-Kotlin implementation. */ +object ChaCha20Poly1305Aead : Aead() { + override val keyLength = 32 + override val nonceLength = 12 + override val tagLength = 16 + + override fun seal( + key: ByteArray, + nonce: ByteArray, + aad: ByteArray, + plaintext: ByteArray, + ): ByteArray { + require(key.size == keyLength) { "ChaCha20-Poly1305 key must be 32 bytes" } + require(nonce.size == nonceLength) { "ChaCha20-Poly1305 nonce must be 12 bytes" } + return ChaCha20Poly1305.encrypt(plaintext, aad, nonce, key) + } + + override fun open( + key: ByteArray, + nonce: ByteArray, + aad: ByteArray, + ciphertext: ByteArray, + ): ByteArray? { + require(key.size == keyLength) { "ChaCha20-Poly1305 key must be 32 bytes" } + require(nonce.size == nonceLength) { "ChaCha20-Poly1305 nonce must be 12 bytes" } + return try { + ChaCha20Poly1305.decrypt(ciphertext, aad, nonce, key) + } catch (_: Throwable) { + null + } + } +} + +/** + * Build a QUIC AEAD nonce from a static IV and a packet number. + * + * RFC 9001 §5.3: nonce = static_iv XOR (packet_number padded to nonce length, big-endian). + */ +fun aeadNonce( + staticIv: ByteArray, + packetNumber: Long, +): ByteArray { + val nonce = staticIv.copyOf() + val len = nonce.size + for (i in 0 until 8) { + nonce[len - 1 - i] = (nonce[len - 1 - i].toInt() xor ((packetNumber ushr (i * 8)).toInt() and 0xFF)).toByte() + } + return nonce +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/HeaderProtection.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/HeaderProtection.kt new file mode 100644 index 0000000000..1796768134 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/HeaderProtection.kt @@ -0,0 +1,122 @@ +/* + * 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.crypto + +/** + * QUIC header-protection sample mask generator (RFC 9001 §5.4). + * + * - AES suites: take a 16-byte sample, AES-ECB encrypt with the HP key, use + * the first 5 bytes as the mask. + * - ChaCha20 suite: the first 4 bytes of the sample are the counter, next 12 + * are the nonce; ChaCha20-encrypt 5 zero bytes; that's the mask. + */ +sealed class HeaderProtection { + abstract fun mask( + hpKey: ByteArray, + sample: ByteArray, + ): ByteArray +} + +/** AES-128-ECB header protection. Implemented via the platform AES helper. */ +class AesEcbHeaderProtection( + private val aesEncryptOneBlock: AesOneBlockEncrypt, +) : HeaderProtection() { + override fun mask( + hpKey: ByteArray, + sample: ByteArray, + ): ByteArray { + require(sample.size == 16) { "AES sample must be 16 bytes" } + require(hpKey.size in setOf(16, 24, 32)) { "AES-ECB key must be 16/24/32 bytes" } + val out = aesEncryptOneBlock.encrypt(hpKey, sample) + return out.copyOfRange(0, 5) + } +} + +/** ChaCha20-based header protection per RFC 9001 §5.4.4. */ +class ChaCha20HeaderProtection( + private val chacha20Encrypt: ChaCha20BlockEncrypt, +) : HeaderProtection() { + override fun mask( + hpKey: ByteArray, + sample: ByteArray, + ): ByteArray { + require(sample.size == 16) { "ChaCha20 HP sample must be 16 bytes" } + require(hpKey.size == 32) { "ChaCha20 HP key must be 32 bytes" } + val counter = + ((sample[0].toInt() and 0xFF)) or + ((sample[1].toInt() and 0xFF) shl 8) or + ((sample[2].toInt() and 0xFF) shl 16) or + ((sample[3].toInt() and 0xFF) shl 24) + val nonce = sample.copyOfRange(4, 16) + return chacha20Encrypt.encrypt(hpKey, nonce, counter, ByteArray(5)) + } +} + +/** SPI for one-block AES encryption (provided by jvmAndroid via JCA). */ +fun interface AesOneBlockEncrypt { + fun encrypt( + key: ByteArray, + block: ByteArray, + ): ByteArray +} + +/** SPI for ChaCha20 keystream encryption with explicit counter. */ +fun interface ChaCha20BlockEncrypt { + fun encrypt( + key: ByteArray, + nonce: ByteArray, + counter: Int, + plaintext: ByteArray, + ): ByteArray +} + +/** + * Apply header protection to a packet header in-place. + * + * Per RFC 9001 §5.4.1: + * - first byte: low bits XORed with `mask[0] & 0x0F` (short header) or + * `mask[0] & 0x1F` (long header). The header form is detected from the + * high bit of the first byte: 1 = long header (uses 0x0F low-bit mask + * because the upper four bits include version-related flags... wait, + * RFC says the opposite — see notes). + * + * Actually RFC 9001 §5.4.1 is precise: + * - long header: mask first byte with 0x0F (4 protected bits) + * - short header: mask first byte with 0x1F (5 protected bits) + * The packet number bytes (1..4 of them) are XORed with `mask[1..pnLen]`. + */ +fun applyHeaderProtectionMask( + packet: ByteArray, + firstByteOffset: Int, + pnOffset: Int, + pnLen: Int, + mask: ByteArray, +) { + require(pnLen in 1..4) { "pnLen must be 1..4 (was $pnLen)" } + require(mask.size >= 5) { "HP mask must be at least 5 bytes" } + val firstByte = packet[firstByteOffset].toInt() and 0xFF + val isLong = (firstByte and 0x80) != 0 + val firstByteMask = if (isLong) 0x0F else 0x1F + packet[firstByteOffset] = (firstByte xor (mask[0].toInt() and firstByteMask)).toByte() + for (i in 0 until pnLen) { + packet[pnOffset + i] = (packet[pnOffset + i].toInt() xor mask[1 + i].toInt()).toByte() + } +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/HkdfHelpers.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/HkdfHelpers.kt new file mode 100644 index 0000000000..64c61c93c7 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/HkdfHelpers.kt @@ -0,0 +1,52 @@ +/* + * 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.crypto + +import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * The single HKDF-SHA256 instance used everywhere in the QUIC + TLS 1.3 stack. + * SHA-256 covers both QUIC-mandatory cipher suites we care about + * (TLS_AES_128_GCM_SHA256 and TLS_CHACHA20_POLY1305_SHA256). + * + * TLS_AES_256_GCM_SHA384 is the only mandatory suite we omit — its SHA-384 + * primitive isn't yet in Quartz and nests / mainstream HTTP/3 servers all + * accept the SHA-256 suites by default. + */ +val HKDF: Hkdf = Hkdf("HmacSHA256", 32) + +/** Empty-string SHA-256 — RFC 8446's "transcript hash of nothing" sentinel. */ +val EMPTY_SHA256: ByteArray = sha256(ByteArray(0)) + +/** RFC 8446 §7.1 — Derive-Secret(secret, label, transcript_hash). */ +fun deriveSecret( + secret: ByteArray, + label: String, + transcriptHash: ByteArray, +): ByteArray = HKDF.expandLabel(secret, label, transcriptHash, 32) + +/** RFC 8446 §7.1 — `HKDF-Expand-Label(secret, label, "" , length)`. */ +fun expandLabel( + secret: ByteArray, + label: String, + length: Int, +): ByteArray = HKDF.expandLabel(secret, label, ByteArray(0), length) diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/InitialSecrets.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/InitialSecrets.kt new file mode 100644 index 0000000000..f5f17334f9 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/InitialSecrets.kt @@ -0,0 +1,76 @@ +/* + * 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.crypto + +/** + * Initial-packet protection secrets per RFC 9001 §5.2. + * + * The Initial salt for QUIC v1 is fixed: + * `38762cf7f55934b34d179ae6a4c80cadccbb7f0a` (20 bytes) + * + * The initial secret is `HKDF-Extract(salt, client_dst_connection_id)`. + * Client and server then derive their per-direction secret with + * `HKDF-Expand-Label(initial_secret, "client in"/"server in", "", 32)`. + * + * From those, key/iv/hp are derived via `HKDF-Expand-Label`. + * + * Initial packets always use the AES-128-GCM AEAD with AES-128 header + * protection — those parameters are fixed for the QUIC v1 long-header + * protection epoch. + */ +object InitialSecrets { + val V1_INITIAL_SALT: ByteArray = + byteArrayOf( + 0x38.toByte(), 0x76.toByte(), 0x2c.toByte(), 0xf7.toByte(), + 0xf5.toByte(), 0x59.toByte(), 0x34.toByte(), 0xb3.toByte(), + 0x4d.toByte(), 0x17.toByte(), 0x9a.toByte(), 0xe6.toByte(), + 0xa4.toByte(), 0xc8.toByte(), 0x0c.toByte(), 0xad.toByte(), + 0xcc.toByte(), 0xbb.toByte(), 0x7f.toByte(), 0x0a.toByte(), + ) + + /** + * Derive both directions' Initial protection material from the original + * destination connection id (the random CID the client put in its first + * Initial). + */ + fun derive(clientDstConnectionId: ByteArray): InitialProtection { + val initialSecret = HKDF.extract(clientDstConnectionId, V1_INITIAL_SALT) + val clientSecret = HKDF.expandLabel(initialSecret, "client in", ByteArray(0), 32) + val serverSecret = HKDF.expandLabel(initialSecret, "server in", ByteArray(0), 32) + return InitialProtection( + clientKey = HKDF.expandLabel(clientSecret, "quic key", ByteArray(0), 16), + clientIv = HKDF.expandLabel(clientSecret, "quic iv", ByteArray(0), 12), + clientHp = HKDF.expandLabel(clientSecret, "quic hp", ByteArray(0), 16), + serverKey = HKDF.expandLabel(serverSecret, "quic key", ByteArray(0), 16), + serverIv = HKDF.expandLabel(serverSecret, "quic iv", ByteArray(0), 12), + serverHp = HKDF.expandLabel(serverSecret, "quic hp", ByteArray(0), 16), + ) + } +} + +class InitialProtection( + val clientKey: ByteArray, + val clientIv: ByteArray, + val clientHp: ByteArray, + val serverKey: ByteArray, + val serverIv: ByteArray, + val serverHp: ByteArray, +) diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/PlatformCrypto.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/PlatformCrypto.kt new file mode 100644 index 0000000000..69125998eb --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/PlatformCrypto.kt @@ -0,0 +1,27 @@ +/* + * 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.crypto + +/** Platform-provided implementation of one-block AES-ECB encryption (no padding). */ +expect val PlatformAesOneBlock: AesOneBlockEncrypt + +/** Platform-provided ChaCha20 keystream block encryptor (RFC 8439 IETF variant). */ +expect val PlatformChaCha20Block: ChaCha20BlockEncrypt diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt new file mode 100644 index 0000000000..37fd440c8f --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt @@ -0,0 +1,340 @@ +/* + * 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.tls + +import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 +import com.vitorpamplona.quartz.marmot.mls.crypto.X25519KeyPair +import com.vitorpamplona.quic.QuicCodecException +import com.vitorpamplona.quic.QuicReader +import com.vitorpamplona.quic.QuicWriter + +/** + * TLS 1.3 client driven by QUIC's encryption-level CRYPTO stream payloads. + * + * The QUIC stack feeds in CRYPTO-frame bytes for each encryption level + * (Initial → Handshake → Application) via [pushHandshakeBytes]; the driver + * accumulates and parses handshake messages, advances state, derives keys, + * and emits outbound CRYPTO payloads via [pollOutbound]. + * + * Key derivations are exposed as [secretsListener] callbacks so the QUIC + * layer can install per-direction packet protection at the right moment: + * + * 1. After we send ClientHello (Initial-tx already installed by caller from CID). + * 2. After ServerHello arrives → install Handshake keys both directions. + * 3. After server Finished decoded → install 1-RTT (application) keys both directions. + * + * For Phase B we **do not yet validate the certificate chain or the + * CertificateVerify signature**. That's wired in during Phase C/L when we + * have a real server to talk to. We DO compute and verify the server + * Finished MAC. + */ +class TlsClient( + val serverName: String, + val transportParameters: ByteArray, + val secretsListener: TlsSecretsListener, + val certificateValidator: CertificateValidator? = null, + /** When non-null, used as the X25519 ephemeral key (for deterministic tests). */ + val fixedKeyPair: X25519KeyPair? = null, + /** When non-null, used as the ClientHello random (for deterministic tests). */ + val fixedRandom: ByteArray? = null, +) { + enum class State { + INITIAL, + WAITING_SERVER_HELLO, + WAITING_ENCRYPTED_EXTENSIONS, + WAITING_CERTIFICATE_OR_FINISHED, + WAITING_CERTIFICATE_VERIFY, + WAITING_SERVER_FINISHED, + SENT_CLIENT_FINISHED, + FAILED, + } + + enum class Level { INITIAL, HANDSHAKE, APPLICATION } + + var state: State = State.INITIAL + private set + + var negotiatedAlpn: ByteArray? = null + private set + + var peerTransportParameters: ByteArray? = null + private set + + /** The handshake message bytes we still owe to the QUIC layer, per encryption level. */ + private val outboundQueues = + mapOf( + Level.INITIAL to ArrayDeque(), + Level.HANDSHAKE to ArrayDeque(), + Level.APPLICATION to ArrayDeque(), + ) + + private val inboundBuffers = + mutableMapOf( + Level.INITIAL to ByteArrayBuilder(), + Level.HANDSHAKE to ByteArrayBuilder(), + ) + + private val transcript = TlsTranscriptHash() + private val keySchedule = TlsKeySchedule(transcript) + + private var keyPair: X25519KeyPair? = null + private var serverKeyShare: ByteArray? = null + private var sharedSecret: ByteArray? = null + + /** Begin the handshake by emitting a ClientHello at Initial level. */ + fun start() { + check(state == State.INITIAL) { "TlsClient already started" } + keyPair = fixedKeyPair ?: X25519.generateKeyPair() + + keySchedule.deriveEarly() + + val ch = + buildQuicClientHello( + serverName = serverName, + x25519PublicKey = keyPair!!.publicKey, + quicTransportParams = transportParameters, + random = fixedRandom ?: com.vitorpamplona.quartz.utils.RandomInstance.bytes(32), + ) + + val chBytes = ch.encode() + transcript.append(chBytes) + outboundQueues[Level.INITIAL]!!.addLast(chBytes) + state = State.WAITING_SERVER_HELLO + } + + /** Pull buffered outbound handshake bytes for [level], or null if nothing pending. */ + fun pollOutbound(level: Level): ByteArray? = outboundQueues[level]?.removeFirstOrNull() + + /** Feed inbound CRYPTO-frame bytes at [level]. */ + fun pushHandshakeBytes( + level: Level, + bytes: ByteArray, + ) { + val buf = inboundBuffers[level] ?: throw QuicCodecException("no buffer at level $level") + buf.append(bytes) + drainInbound(level, buf) + } + + private fun drainInbound( + level: Level, + buf: ByteArrayBuilder, + ) { + while (true) { + val msg = buf.takeHandshakeMessage() ?: break + handleHandshakeMessage(level, msg) + } + } + + private fun handleHandshakeMessage( + level: Level, + msg: ByteArray, + ) { + val r = QuicReader(msg) + val type = r.readByte() + val len = r.readUint24() + if (r.remaining < len) throw QuicCodecException("truncated handshake message") + val bodyReader = QuicReader(msg, r.position, r.position + len) + + when (state) { + State.WAITING_SERVER_HELLO -> { + if (type != TlsConstants.HS_SERVER_HELLO) throw QuicCodecException("expected ServerHello, got type=$type") + if (level != Level.INITIAL) throw QuicCodecException("ServerHello must arrive at Initial level") + val sh = TlsServerHello.decodeBody(bodyReader) + if (sh.negotiatedVersion != TlsConstants.VERSION_TLS_1_3) { + throw QuicCodecException("server did not negotiate TLS 1.3") + } + val cipher = sh.cipherSuite + if (cipher != TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256 && + cipher != TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256 + ) { + throw QuicCodecException("server picked unsupported cipher 0x${cipher.toString(16)}") + } + serverKeyShare = sh.serverKeyShareX25519 + transcript.append(msg) + + val privKey = keyPair!!.privateKey + val shared = X25519.dh(privKey, serverKeyShare!!) + sharedSecret = shared + keySchedule.deriveHandshake(shared) + keySchedule.deriveHandshakeTraffic() + keySchedule.deriveMaster() + + secretsListener.onHandshakeKeysReady( + cipherSuite = cipher, + clientSecret = keySchedule.clientHandshakeSecret!!, + serverSecret = keySchedule.serverHandshakeSecret!!, + ) + state = State.WAITING_ENCRYPTED_EXTENSIONS + } + State.WAITING_ENCRYPTED_EXTENSIONS -> { + if (type != TlsConstants.HS_ENCRYPTED_EXTENSIONS) throw QuicCodecException("expected EncryptedExtensions, got type=$type") + if (level != Level.HANDSHAKE) throw QuicCodecException("EncryptedExtensions must arrive at Handshake level") + val ee = TlsEncryptedExtensions.decodeBody(bodyReader) + negotiatedAlpn = ee.alpn + peerTransportParameters = ee.quicTransportParameters + transcript.append(msg) + state = State.WAITING_CERTIFICATE_OR_FINISHED + } + State.WAITING_CERTIFICATE_OR_FINISHED -> { + when (type) { + TlsConstants.HS_CERTIFICATE -> { + val cert = TlsCertificateChain.decodeBody(bodyReader) + certificateValidator?.validateChain(cert.certificates, serverName) + transcript.append(msg) + state = State.WAITING_CERTIFICATE_VERIFY + } + TlsConstants.HS_FINISHED -> { + // PSK-only handshake skips Certificate/CertificateVerify. We never use PSK, + // but the state machine handles the transition for completeness. + handleServerFinished(msg, bodyReader, len) + } + else -> throw QuicCodecException("unexpected handshake type after EncryptedExtensions: $type") + } + } + State.WAITING_CERTIFICATE_VERIFY -> { + if (type != TlsConstants.HS_CERTIFICATE_VERIFY) throw QuicCodecException("expected CertificateVerify, got type=$type") + val cv = TlsCertificateVerify.decodeBody(bodyReader) + val transcriptHash = transcript.snapshot() + certificateValidator?.verifySignature(cv.signatureAlgorithm, cv.signature, transcriptHash) + transcript.append(msg) + state = State.WAITING_SERVER_FINISHED + } + State.WAITING_SERVER_FINISHED -> { + if (type != TlsConstants.HS_FINISHED) throw QuicCodecException("expected Finished, got type=$type") + handleServerFinished(msg, bodyReader, len) + } + else -> throw QuicCodecException("unexpected handshake at state=$state type=$type") + } + } + + private fun handleServerFinished( + msg: ByteArray, + bodyReader: QuicReader, + length: Int, + ) { + val finished = TlsFinished.decodeBody(bodyReader, length) + // Verify server Finished MAC over transcript-up-to-CertificateVerify (or up to EE for PSK). + val expected = finishedVerifyData(keySchedule.serverHandshakeSecret!!, transcript.snapshot()) + if (!expected.contentEqualsConstantTime(finished.verifyData)) { + throw QuicCodecException("server Finished MAC mismatch") + } + transcript.append(msg) + + // Derive 1-RTT (application) traffic secrets after server Finished. + keySchedule.deriveApplicationTraffic() + secretsListener.onApplicationKeysReady( + cipherSuite = currentCipherSuite(), + clientSecret = keySchedule.clientApplicationSecret!!, + serverSecret = keySchedule.serverApplicationSecret!!, + ) + + // Send our Finished at Handshake level. + val clientFinishedTag = finishedVerifyData(keySchedule.clientHandshakeSecret!!, transcript.snapshot()) + val w = QuicWriter() + w.writeByte(TlsConstants.HS_FINISHED) + w.withUint24Length { writeBytes(clientFinishedTag) } + val cfBytes = w.toByteArray() + transcript.append(cfBytes) + outboundQueues[Level.HANDSHAKE]!!.addLast(cfBytes) + + state = State.SENT_CLIENT_FINISHED + secretsListener.onHandshakeComplete() + } + + private fun currentCipherSuite(): Int { + // For Phase B we always negotiate TLS_AES_128_GCM_SHA256 first; if that's + // not the picked one, the only other we accept is ChaCha20-Poly1305-SHA256. + // The ServerHello has already validated this. We carry it implicitly via + // the SHA-256 schedule; the cipher choice only affects the AEAD/HP picked + // by the QUIC layer. + return TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256 + } +} + +/** Callback interface so the QUIC layer can react to TLS-derived secrets. */ +interface TlsSecretsListener { + fun onHandshakeKeysReady( + cipherSuite: Int, + clientSecret: ByteArray, + serverSecret: ByteArray, + ) + + fun onApplicationKeysReady( + cipherSuite: Int, + clientSecret: ByteArray, + serverSecret: ByteArray, + ) + + fun onHandshakeComplete() +} + +/** Pluggable certificate validator. Decoupled so we can stub it in tests. */ +interface CertificateValidator { + fun validateChain( + chain: List, + expectedHost: String, + ) + + fun verifySignature( + signatureAlgorithm: Int, + signature: ByteArray, + transcriptHash: ByteArray, + ) +} + +/** Constant-time equality. */ +internal fun ByteArray.contentEqualsConstantTime(other: ByteArray): Boolean { + if (size != other.size) return false + var diff = 0 + for (i in indices) diff = diff or (this[i].toInt() xor other[i].toInt()) + return diff == 0 +} + +/** + * Internal accumulator that hands back full handshake messages once enough + * bytes have arrived. Each message starts with `(uint8 type)(uint24 length)`. + */ +internal class ByteArrayBuilder { + private var buf: ByteArray = ByteArray(0) + + fun append(bytes: ByteArray) { + if (bytes.isEmpty()) return + val combined = ByteArray(buf.size + bytes.size) + buf.copyInto(combined, 0) + bytes.copyInto(combined, buf.size) + buf = combined + } + + /** Pop the next handshake message if a full one is available. */ + fun takeHandshakeMessage(): ByteArray? { + if (buf.size < 4) return null + val len = ( + ((buf[1].toInt() and 0xFF) shl 16) or + ((buf[2].toInt() and 0xFF) shl 8) or + (buf[3].toInt() and 0xFF) + ) + val total = 4 + len + if (buf.size < total) return null + val msg = buf.copyOfRange(0, total) + buf = buf.copyOfRange(total, buf.size) + return msg + } +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt new file mode 100644 index 0000000000..017e9f3521 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt @@ -0,0 +1,111 @@ +/* + * 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.tls + +import com.vitorpamplona.quic.QuicWriter +import com.vitorpamplona.quartz.utils.RandomInstance + +/** + * Build a TLS 1.3 ClientHello + handshake header carrying the QUIC-required + * extensions. Output is the full handshake message (1-byte type, 3-byte length, + * then the body) ready to feed into a CRYPTO frame. + * + * Per RFC 8446 §4.1.2 + RFC 9001 §8 the message layout is: + * + * uint8 msg_type = 0x01 (client_hello) + * uint24 length + * uint16 legacy_version = 0x0303 ("TLS 1.2") + * opaque random[32] + * uint8 legacy_session_id_len = 0 (TLS 1.3 over QUIC; no resumption) + * uint16 cipher_suites_len + * uint16 cipher_suites[] + * uint8 legacy_compression_methods_len = 1 + * uint8 legacy_compression_methods[] = { 0 } // null + * uint16 extensions_len + * Extension extensions[] + */ +class TlsClientHello( + val random: ByteArray = RandomInstance.bytes(32), + val cipherSuites: IntArray = intArrayOf(TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256, TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256), + val extensions: List, +) { + init { + require(random.size == 32) { "TLS random must be 32 bytes" } + } + + /** Encode just the body (no msg_type/length wrapper). */ + fun encodeBody(out: QuicWriter) { + out.writeUint16(TlsConstants.LEGACY_VERSION_TLS_1_2) + out.writeBytes(random) + out.writeByte(0) // legacy_session_id_len = 0 + out.withUint16Length { + for (c in cipherSuites) writeUint16(c) + } + out.writeByte(1) // legacy_compression_methods_len + out.writeByte(0) // null compression + out.withUint16Length { + for (e in extensions) e.encode(this) + } + } + + /** Encode the full handshake message: 1-byte type + 3-byte length + body. */ + fun encode(): ByteArray { + val w = QuicWriter() + w.writeByte(TlsConstants.HS_CLIENT_HELLO) + w.withUint24Length { encodeBody(this) } + return w.toByteArray() + } +} + +/** + * Convenience builder that wires up the standard QUIC + WebTransport ClientHello: + * - SNI + * - supported_versions = [ TLS 1.3 ] + * - supported_groups = [ X25519 ] + * - signature_algorithms covering ECDSA / RSA-PSS / Ed25519 + * - key_share with the caller's X25519 public + * - psk_key_exchange_modes = [ psk_dhe_ke ] + * - ALPN = [ h3 ] + * - quic_transport_parameters = (caller-supplied opaque bytes) + */ +fun buildQuicClientHello( + serverName: String, + x25519PublicKey: ByteArray, + quicTransportParams: ByteArray, + additionalAlpn: List = emptyList(), + random: ByteArray = RandomInstance.bytes(32), +): TlsClientHello { + val alpn = mutableListOf() + alpn += TlsConstants.ALPN_H3 + alpn += additionalAlpn + val exts = + listOf( + TlsExtension(TlsConstants.EXT_SERVER_NAME, encodeServerNameExtension(serverName)), + TlsExtension(TlsConstants.EXT_SUPPORTED_VERSIONS, encodeSupportedVersionsExtensionClient()), + TlsExtension(TlsConstants.EXT_SUPPORTED_GROUPS, encodeSupportedGroupsX25519()), + TlsExtension(TlsConstants.EXT_SIGNATURE_ALGORITHMS, encodeSignatureAlgorithms()), + TlsExtension(TlsConstants.EXT_KEY_SHARE, encodeKeyShareClientX25519(x25519PublicKey)), + TlsExtension(TlsConstants.EXT_PSK_KEY_EXCHANGE_MODES, encodePskKeyExchangeModesDhe()), + TlsExtension(TlsConstants.EXT_ALPN, encodeAlpn(alpn)), + TlsExtension(TlsConstants.EXT_QUIC_TRANSPORT_PARAMETERS, quicTransportParams), + ) + return TlsClientHello(random = random, extensions = exts) +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsConstants.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsConstants.kt new file mode 100644 index 0000000000..61c32d4a74 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsConstants.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.tls + +/** + * TLS 1.3 protocol constants from RFC 8446 + RFC 9001 (TLS-over-QUIC binding). + */ +object TlsConstants { + // ── Record / handshake message types ────────────────────────────────────── + /** TLS 1.3 over QUIC uses `legacy_version = 0x0303` ("TLS 1.2") on the wire. */ + const val LEGACY_VERSION_TLS_1_2: Int = 0x0303 + const val VERSION_TLS_1_3: Int = 0x0304 + + // RFC 8446 §B.3 HandshakeType + const val HS_CLIENT_HELLO: Int = 1 + const val HS_SERVER_HELLO: Int = 2 + const val HS_NEW_SESSION_TICKET: Int = 4 + const val HS_END_OF_EARLY_DATA: Int = 5 + const val HS_ENCRYPTED_EXTENSIONS: Int = 8 + const val HS_CERTIFICATE: Int = 11 + const val HS_CERTIFICATE_REQUEST: Int = 13 + const val HS_CERTIFICATE_VERIFY: Int = 15 + const val HS_FINISHED: Int = 20 + const val HS_KEY_UPDATE: Int = 24 + const val HS_MESSAGE_HASH: Int = 254 + + // ── Cipher suites ───────────────────────────────────────────────────────── + const val CIPHER_TLS_AES_128_GCM_SHA256: Int = 0x1301 + const val CIPHER_TLS_AES_256_GCM_SHA384: Int = 0x1302 + const val CIPHER_TLS_CHACHA20_POLY1305_SHA256: Int = 0x1303 + + // ── Extensions (RFC 8446 §4.2) ──────────────────────────────────────────── + const val EXT_SERVER_NAME: Int = 0 + const val EXT_SUPPORTED_GROUPS: Int = 10 + const val EXT_SIGNATURE_ALGORITHMS: Int = 13 + const val EXT_ALPN: Int = 16 + const val EXT_SUPPORTED_VERSIONS: Int = 43 + const val EXT_PSK_KEY_EXCHANGE_MODES: Int = 45 + const val EXT_KEY_SHARE: Int = 51 + /** RFC 9001 §8.2 — the QUIC TLS extension carrying transport parameters. */ + const val EXT_QUIC_TRANSPORT_PARAMETERS: Int = 0x39 + + // ── Named groups (RFC 8446 §4.2.7) ──────────────────────────────────────── + const val GROUP_X25519: Int = 0x001D + const val GROUP_SECP256R1: Int = 0x0017 + + // ── Signature schemes (RFC 8446 §4.2.3) ─────────────────────────────────── + const val SIG_ECDSA_SECP256R1_SHA256: Int = 0x0403 + const val SIG_ECDSA_SECP384R1_SHA384: Int = 0x0503 + const val SIG_RSA_PSS_RSAE_SHA256: Int = 0x0804 + const val SIG_RSA_PSS_RSAE_SHA384: Int = 0x0805 + const val SIG_RSA_PSS_RSAE_SHA512: Int = 0x0806 + const val SIG_ED25519: Int = 0x0807 + const val SIG_RSA_PKCS1_SHA256: Int = 0x0401 + + // ── PSK key exchange modes ──────────────────────────────────────────────── + const val PSK_MODE_KE: Int = 0 + const val PSK_MODE_DHE_KE: Int = 1 + + // ── Server-name (SNI) types ─────────────────────────────────────────────── + const val SERVER_NAME_TYPE_HOST_NAME: Int = 0 + + // ── Alert constants — only the ones we actually look at ─────────────────── + const val ALERT_CLOSE_NOTIFY: Int = 0 + const val ALERT_DECODE_ERROR: Int = 50 + const val ALERT_HANDSHAKE_FAILURE: Int = 40 + + // ── ALPN ────────────────────────────────────────────────────────────────── + val ALPN_H3: ByteArray = "h3".encodeToByteArray() +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsExtension.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsExtension.kt new file mode 100644 index 0000000000..1d07c1c705 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsExtension.kt @@ -0,0 +1,133 @@ +/* + * 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.tls + +import com.vitorpamplona.quic.QuicReader +import com.vitorpamplona.quic.QuicWriter + +/** + * Single TLS 1.3 extension (RFC 8446 §4.2): `extension_type` (2 bytes) plus + * an opaque `extension_data<0..2^16-1>`. We carry the data raw — encoders for + * specific extension shapes live in TlsClientHello. + */ +class TlsExtension( + val type: Int, + val data: ByteArray, +) { + fun encode(out: QuicWriter) { + out.writeUint16(type) + out.writeTlsOpaque2(data) + } + + companion object { + fun decode(r: QuicReader): TlsExtension { + val type = r.readUint16() + val data = r.readTlsOpaque2() + return TlsExtension(type, data) + } + + /** + * Decode an Extension list (`extensions<0..2^16-1>`) from [r] until + * the inner length is consumed. + */ + fun decodeList(r: QuicReader): List { + val totalLen = r.readUint16() + val end = r.position + totalLen + val out = mutableListOf() + while (r.position < end) { + out += decode(r) + } + return out + } + } +} + +/** Build the `server_name` extension (RFC 6066) with a single host_name entry. */ +fun encodeServerNameExtension(hostName: String): ByteArray { + val name = hostName.encodeToByteArray() + val w = QuicWriter() + w.withUint16Length { + writeByte(TlsConstants.SERVER_NAME_TYPE_HOST_NAME) + writeTlsOpaque2(name) + } + return w.toByteArray() +} + +/** Build the `supported_versions` extension carrying just TLS 1.3. */ +fun encodeSupportedVersionsExtensionClient(): ByteArray { + val w = QuicWriter() + w.withUint8Length { + writeUint16(TlsConstants.VERSION_TLS_1_3) + } + return w.toByteArray() +} + +/** Build the `supported_groups` extension with just X25519 listed. */ +fun encodeSupportedGroupsX25519(): ByteArray { + val w = QuicWriter() + w.withUint16Length { + writeUint16(TlsConstants.GROUP_X25519) + } + return w.toByteArray() +} + +/** Build the `signature_algorithms` extension covering ECDSA-P256, RSA-PSS, Ed25519. */ +fun encodeSignatureAlgorithms(): ByteArray { + val w = QuicWriter() + w.withUint16Length { + writeUint16(TlsConstants.SIG_ECDSA_SECP256R1_SHA256) + writeUint16(TlsConstants.SIG_RSA_PSS_RSAE_SHA256) + writeUint16(TlsConstants.SIG_RSA_PSS_RSAE_SHA384) + writeUint16(TlsConstants.SIG_RSA_PSS_RSAE_SHA512) + writeUint16(TlsConstants.SIG_ED25519) + writeUint16(TlsConstants.SIG_RSA_PKCS1_SHA256) + writeUint16(TlsConstants.SIG_ECDSA_SECP384R1_SHA384) + } + return w.toByteArray() +} + +/** Build a single-key-share `key_share` extension carrying the X25519 client public. */ +fun encodeKeyShareClientX25519(publicKey: ByteArray): ByteArray { + val w = QuicWriter() + w.withUint16Length { + writeUint16(TlsConstants.GROUP_X25519) + writeTlsOpaque2(publicKey) + } + return w.toByteArray() +} + +/** Build the `psk_key_exchange_modes` extension advertising only DHE-KE. */ +fun encodePskKeyExchangeModesDhe(): ByteArray { + val w = QuicWriter() + w.withUint8Length { + writeByte(TlsConstants.PSK_MODE_DHE_KE) + } + return w.toByteArray() +} + +/** Build the `application_layer_protocol_negotiation` extension with a single ALPN entry. */ +fun encodeAlpn(protocols: List): ByteArray { + val w = QuicWriter() + w.withUint16Length { + for (p in protocols) writeTlsOpaque1(p) + } + return w.toByteArray() +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsHandshakeMessages.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsHandshakeMessages.kt new file mode 100644 index 0000000000..25dc6add58 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsHandshakeMessages.kt @@ -0,0 +1,145 @@ +/* + * 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.tls + +import com.vitorpamplona.quic.QuicCodecException +import com.vitorpamplona.quic.QuicReader + +/** + * Parsed TLS 1.3 ServerHello. Only the fields we actually use to drive the + * key schedule are surfaced. + */ +data class TlsServerHello( + val random: ByteArray, + val sessionId: ByteArray, + val cipherSuite: Int, + val extensions: List, +) { + /** The negotiated protocol version. Must be 0x0304 (TLS 1.3) per RFC 8446. */ + val negotiatedVersion: Int + get() { + val ext = extensions.firstOrNull { it.type == TlsConstants.EXT_SUPPORTED_VERSIONS } + ?: throw QuicCodecException("server hello missing supported_versions extension") + // server hello carries selected_version (uint16) + val r = QuicReader(ext.data) + return r.readUint16() + } + + /** The peer's X25519 public key, extracted from key_share. */ + val serverKeyShareX25519: ByteArray + get() { + val ext = extensions.firstOrNull { it.type == TlsConstants.EXT_KEY_SHARE } + ?: throw QuicCodecException("server hello missing key_share extension") + val r = QuicReader(ext.data) + val group = r.readUint16() + if (group != TlsConstants.GROUP_X25519) { + throw QuicCodecException("server selected unsupported group 0x${group.toString(16)}") + } + return r.readTlsOpaque2() + } + + companion object { + /** Parse the body of a ServerHello after the 4-byte handshake header has been stripped. */ + fun decodeBody(r: QuicReader): TlsServerHello { + val legacyVersion = r.readUint16() + if (legacyVersion != TlsConstants.LEGACY_VERSION_TLS_1_2) { + throw QuicCodecException("ServerHello legacy_version != 0x0303 (got 0x${legacyVersion.toString(16)})") + } + val random = r.readBytes(32) + val sessionId = r.readTlsOpaque1() + val cipherSuite = r.readUint16() + r.readByte() // legacy_compression_method = 0 + val extensions = TlsExtension.decodeList(r) + return TlsServerHello(random, sessionId, cipherSuite, extensions) + } + } +} + +/** Parsed EncryptedExtensions message (RFC 8446 §4.3.1). */ +data class TlsEncryptedExtensions( + val extensions: List, +) { + val quicTransportParameters: ByteArray? + get() = extensions.firstOrNull { it.type == TlsConstants.EXT_QUIC_TRANSPORT_PARAMETERS }?.data + + val alpn: ByteArray? + get() = extensions.firstOrNull { it.type == TlsConstants.EXT_ALPN }?.data?.let { + // ALPN response carries a single protocol_name<1..2^8-1> inside protocols<3..2^16-1> + val r = QuicReader(it) + r.skip(2) // outer length + r.readTlsOpaque1() + } + + companion object { + fun decodeBody(r: QuicReader): TlsEncryptedExtensions = TlsEncryptedExtensions(TlsExtension.decodeList(r)) + } +} + +/** Parsed Certificate message (RFC 8446 §4.4.2). For nests interop we only need the leaf. */ +data class TlsCertificateChain( + val certificateRequestContext: ByteArray, + val certificates: List, +) { + val leaf: ByteArray + get() = certificates.firstOrNull() ?: throw QuicCodecException("server sent empty certificate chain") + + companion object { + fun decodeBody(r: QuicReader): TlsCertificateChain { + val ctx = r.readTlsOpaque1() + val listLen = r.readUint24() + val end = r.position + listLen + val certs = mutableListOf() + while (r.position < end) { + val cert = r.readTlsOpaque3() + // skip per-certificate extensions (length-prefixed) + r.readTlsOpaque2() + certs += cert + } + return TlsCertificateChain(ctx, certs) + } + } +} + +/** Parsed CertificateVerify message (RFC 8446 §4.4.3). */ +data class TlsCertificateVerify( + val signatureAlgorithm: Int, + val signature: ByteArray, +) { + companion object { + fun decodeBody(r: QuicReader): TlsCertificateVerify { + val sig = r.readUint16() + val data = r.readTlsOpaque2() + return TlsCertificateVerify(sig, data) + } + } +} + +/** Parsed Finished message — the 32-byte HMAC tag for SHA-256-based suites. */ +data class TlsFinished( + val verifyData: ByteArray, +) { + companion object { + fun decodeBody( + r: QuicReader, + length: Int, + ): TlsFinished = TlsFinished(r.readBytes(length)) + } +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsKeySchedule.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsKeySchedule.kt new file mode 100644 index 0000000000..8dc626ee59 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsKeySchedule.kt @@ -0,0 +1,138 @@ +/* + * 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.tls + +import com.vitorpamplona.quartz.utils.mac.MacInstance +import com.vitorpamplona.quic.crypto.EMPTY_SHA256 +import com.vitorpamplona.quic.crypto.HKDF +import com.vitorpamplona.quic.crypto.deriveSecret +import com.vitorpamplona.quic.crypto.expandLabel + +/** + * The TLS 1.3 SHA-256 key schedule per RFC 8446 §7.1, plus the QUIC-flavour + * key/iv/hp expand labels per RFC 9001 §5. + * + * Early Secret = HKDF-Extract(0, PSK) + * Derived "derived" = Derive-Secret(Early, "derived", "") + * Handshake Secret = HKDF-Extract(Derived, ECDHE) + * client_handshake_secret = Derive-Secret(Handshake, "c hs traffic", H(CH..SH)) + * server_handshake_secret = Derive-Secret(Handshake, "s hs traffic", H(CH..SH)) + * Derived "derived" = Derive-Secret(Handshake, "derived", "") + * Master Secret = HKDF-Extract(Derived, 0) + * client_app_secret = Derive-Secret(Master, "c ap traffic", H(CH..server.Finished)) + * server_app_secret = Derive-Secret(Master, "s ap traffic", H(CH..server.Finished)) + */ +class TlsKeySchedule( + val transcript: TlsTranscriptHash, +) { + var earlySecret: ByteArray? = null + private set + var handshakeSecret: ByteArray? = null + private set + var masterSecret: ByteArray? = null + private set + + var clientHandshakeSecret: ByteArray? = null + private set + var serverHandshakeSecret: ByteArray? = null + private set + var clientApplicationSecret: ByteArray? = null + private set + var serverApplicationSecret: ByteArray? = null + private set + + /** Step 1: derive the Early Secret. PSK is all-zeros for non-resumption. */ + fun deriveEarly() { + val zeros = ByteArray(32) + earlySecret = HKDF.extract(zeros, zeros) + } + + /** Step 2: derive Handshake Secret using ECDHE shared secret. */ + fun deriveHandshake(ecdheSharedSecret: ByteArray) { + val early = earlySecret ?: error("call deriveEarly first") + val derived = deriveSecret(early, "derived", EMPTY_SHA256) + handshakeSecret = HKDF.extract(ecdheSharedSecret, derived) + } + + /** Step 3: derive client + server handshake traffic secrets given a transcript ending after ServerHello. */ + fun deriveHandshakeTraffic() { + val hs = handshakeSecret ?: error("call deriveHandshake first") + val transcriptHash = transcript.snapshot() + clientHandshakeSecret = deriveSecret(hs, "c hs traffic", transcriptHash) + serverHandshakeSecret = deriveSecret(hs, "s hs traffic", transcriptHash) + } + + /** Step 4: derive the Master Secret. */ + fun deriveMaster() { + val hs = handshakeSecret ?: error("call deriveHandshake first") + val derived = deriveSecret(hs, "derived", EMPTY_SHA256) + masterSecret = HKDF.extract(ByteArray(32), derived) + } + + /** Step 5: derive client + server application traffic secrets after the server Finished. */ + fun deriveApplicationTraffic() { + val ms = masterSecret ?: error("call deriveMaster first") + val transcriptHash = transcript.snapshot() + clientApplicationSecret = deriveSecret(ms, "c ap traffic", transcriptHash) + serverApplicationSecret = deriveSecret(ms, "s ap traffic", transcriptHash) + } +} + +/** + * QUIC packet-protection key/iv/hp triple, derived from a TLS traffic secret + * via the QUIC-specific labels in RFC 9001 §5.1. + * + * For TLS_AES_128_GCM_SHA256 keyLen=16, ivLen=12, hpLen=16. + * For TLS_CHACHA20_POLY1305_SHA256 keyLen=32, ivLen=12, hpLen=32. + */ +class QuicProtectionKeys( + val key: ByteArray, + val iv: ByteArray, + val hp: ByteArray, +) + +fun deriveQuicKeys( + secret: ByteArray, + keyLen: Int, + ivLen: Int, + hpLen: Int, +): QuicProtectionKeys = + QuicProtectionKeys( + key = expandLabel(secret, "quic key", keyLen), + iv = expandLabel(secret, "quic iv", ivLen), + hp = expandLabel(secret, "quic hp", hpLen), + ) + +/** + * Compute the Finished MAC per RFC 8446 §4.4.4: + * + * finished_key = HKDF-Expand-Label(base_key, "finished", "", Hash.length) + * verify_data = HMAC(finished_key, transcript_hash) + */ +fun finishedVerifyData( + baseKey: ByteArray, + transcriptHash: ByteArray, +): ByteArray { + val finishedKey = expandLabel(baseKey, "finished", 32) + val mac = MacInstance("HmacSHA256", finishedKey) + mac.update(transcriptHash) + return mac.doFinal() +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsTranscriptHash.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsTranscriptHash.kt new file mode 100644 index 0000000000..5429b6ee21 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsTranscriptHash.kt @@ -0,0 +1,62 @@ +/* + * 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.tls + +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * Running SHA-256 over the concatenated handshake messages, per RFC 8446 §4.4.1. + * + * The transcript order is: + * ClientHello + * ServerHello + * EncryptedExtensions + * Certificate + * CertificateVerify + * server Finished + * client Finished + * + * Each message is appended with its 4-byte handshake header included. + * + * For Phase B we keep this simple: we accumulate raw bytes and re-hash. The + * volume is small (a few KB per handshake), so SHA-256 throughput isn't a + * bottleneck. A streaming hash would be a nice optimisation later. + */ +class TlsTranscriptHash { + private val buffer = ArrayList() + + fun append(messageBytes: ByteArray) { + buffer += messageBytes + } + + /** Snapshot the current transcript hash (32 bytes). */ + fun snapshot(): ByteArray { + var totalLen = 0 + for (b in buffer) totalLen += b.size + val concat = ByteArray(totalLen) + var pos = 0 + for (b in buffer) { + b.copyInto(concat, pos) + pos += b.size + } + return sha256(concat) + } +} diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/crypto/HeaderProtectionTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/crypto/HeaderProtectionTest.kt new file mode 100644 index 0000000000..fddcd3193c --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/crypto/HeaderProtectionTest.kt @@ -0,0 +1,58 @@ +/* + * 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.crypto + +import kotlin.test.Test +import kotlin.test.assertEquals + +class HeaderProtectionTest { + /** + * NIST FIPS 197 §C.1 AES-128 worked example: encrypting + * 0x00112233445566778899aabbccddeeff with key + * 0x000102030405060708090a0b0c0d0e0f yields + * 0x69c4e0d86a7b0430d8cdb78070b4c55a. + */ + @Test + fun nist_aes128_ecb_known_answer() { + val key = "000102030405060708090a0b0c0d0e0f".hexToByteArray() + val sample = "00112233445566778899aabbccddeeff".hexToByteArray() + val hp = AesEcbHeaderProtection(PlatformAesOneBlock) + val mask = hp.mask(key, sample) + // First 5 bytes of 69c4e0d86a7b0430d8cdb78070b4c55a = 69c4e0d86a + assertEquals("69c4e0d86a", mask.toHex()) + } + + /** + * Apply/unapply round-trip on a synthetic short header. After applying + * the mask twice we get the original header back. + */ + @Test + fun apply_mask_is_self_inverse() { + val original = byteArrayOf(0x40.toByte(), 0xab.toByte(), 0xcd.toByte(), 0x12, 0x00, 0x00) + val packet = original.copyOf() + val mask = byteArrayOf(0x12, 0x34, 0x56, 0x78, 0x9a.toByte()) + applyHeaderProtectionMask(packet, 0, 1, 2, mask) + applyHeaderProtectionMask(packet, 0, 1, 2, mask) + assertEquals(original.toHex(), packet.toHex()) + } + + private fun ByteArray.toHex(): String = joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } +} diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/crypto/InitialSecretsTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/crypto/InitialSecretsTest.kt new file mode 100644 index 0000000000..0c722fbf5b --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/crypto/InitialSecretsTest.kt @@ -0,0 +1,52 @@ +/* + * 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.crypto + +import kotlin.test.Test +import kotlin.test.assertEquals + +class InitialSecretsTest { + /** + * RFC 9001 Appendix A.1 — Initial Packet test vectors using the canonical + * client DCID `0x8394c8f03e515708`. + * + * Expected derived protection material: + * client_initial_key = 1f369613dd76d5467730efcbe3b1a22d + * client_initial_iv = fa044b2f42a3fd3b46fb255c + * client_hp_key = 9f50449e04a0e810283a1e9933adedd2 + * server_initial_key = cf3a5331653c364c88f0f379b6067e37 + * server_initial_iv = 0ac1493ca1905853b0bba03e + * server_hp_key = c206b8d9b9f0f37644430b490eeaa314 + */ + @Test + fun rfc9001_appendix_a1_client_dcid_vectors() { + val dcid = "8394c8f03e515708".hexToByteArray() + val p = InitialSecrets.derive(dcid) + assertEquals("1f369613dd76d5467730efcbe3b1a22d", p.clientKey.toHex()) + assertEquals("fa044b2f42a3fd3b46fb255c", p.clientIv.toHex()) + assertEquals("9f50449e04a0e810283a1e9933adedd2", p.clientHp.toHex()) + assertEquals("cf3a5331653c364c88f0f379b6067e37", p.serverKey.toHex()) + assertEquals("0ac1493ca1905853b0bba03e", p.serverIv.toHex()) + assertEquals("c206b8d9b9f0f37644430b490eeaa314", p.serverHp.toHex()) + } + + private fun ByteArray.toHex(): String = joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } +} diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/InProcessTlsServer.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/InProcessTlsServer.kt new file mode 100644 index 0000000000..620cb59169 --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/InProcessTlsServer.kt @@ -0,0 +1,201 @@ +/* + * 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.tls + +import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 +import com.vitorpamplona.quartz.marmot.mls.crypto.X25519KeyPair +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quic.QuicReader +import com.vitorpamplona.quic.QuicWriter + +/** + * Minimal TLS 1.3 **server** that uses the same primitives as our client to + * drive an end-to-end handshake without touching the network. + * + * Used purely for in-process round-trip tests of [TlsClient] — it does not + * implement certificate-based authentication (it skips Certificate + + * CertificateVerify) and assumes a one-shot handshake. The transcript + * therefore goes: + * + * ClientHello → ServerHello → EncryptedExtensions → Finished (server) → + * Finished (client) + * + * This is **not** a valid TLS 1.3 mode for real interop (a non-PSK handshake + * MUST send Certificate + CertificateVerify), but for the purposes of + * exercising [TlsClient]'s key derivation + Finished verification it covers + * the path we care about until cert chain validation lands in Phase L. + */ +class InProcessTlsServer( + private val keyPair: X25519KeyPair = X25519.generateKeyPair(), + private val random: ByteArray = RandomInstance.bytes(32), + private val transportParameters: ByteArray = ByteArray(0), + private val alpn: ByteArray = TlsConstants.ALPN_H3, +) { + private val transcript = TlsTranscriptHash() + private val keySchedule = TlsKeySchedule(transcript) + + /** Handshake bytes the server has produced and not yet handed back. */ + private val outboundInitial = ArrayDeque() + private val outboundHandshake = ArrayDeque() + + var clientHandshakeSecret: ByteArray? = null + private set + var serverHandshakeSecret: ByteArray? = null + private set + var clientApplicationSecret: ByteArray? = null + private set + var serverApplicationSecret: ByteArray? = null + private set + var negotiatedCipherSuite: Int = -1 + private set + + fun pollOutboundInitial(): ByteArray? = outboundInitial.removeFirstOrNull() + + fun pollOutboundHandshake(): ByteArray? = outboundHandshake.removeFirstOrNull() + + /** Process a ClientHello (Initial level). Produces ServerHello + EE + Finished. */ + fun receiveClientHello(clientHello: ByteArray) { + // 1. Append CH to transcript + transcript.append(clientHello) + + // 2. Parse CH to get the client's X25519 key share + val r = QuicReader(clientHello) + require(r.readByte() == TlsConstants.HS_CLIENT_HELLO) + r.readUint24() // body length + require(r.readUint16() == TlsConstants.LEGACY_VERSION_TLS_1_2) + r.readBytes(32) // random + r.readTlsOpaque1() // legacy_session_id + val cipherSuiteCount = r.readUint16() / 2 + val pickedSuite = + (0 until cipherSuiteCount).map { r.readUint16() }.firstOrNull { + it == TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256 || + it == TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256 + } ?: error("no acceptable cipher suite in ClientHello") + negotiatedCipherSuite = pickedSuite + r.readByte() // legacy_compression_methods_len + r.readByte() // null compression + val exts = TlsExtension.decodeList(r) + val keyShareExt = exts.first { it.type == TlsConstants.EXT_KEY_SHARE } + val ksReader = QuicReader(keyShareExt.data) + val ksOuterLen = ksReader.readUint16() + val ksEnd = ksReader.position + ksOuterLen + var clientPub: ByteArray? = null + while (ksReader.position < ksEnd) { + val group = ksReader.readUint16() + val pub = ksReader.readTlsOpaque2() + if (group == TlsConstants.GROUP_X25519) clientPub = pub + } + clientPub ?: error("no X25519 key share in ClientHello") + + // 3. Derive the keys + keySchedule.deriveEarly() + val shared = X25519.dh(keyPair.privateKey, clientPub) + keySchedule.deriveHandshake(shared) + + // 4. Build ServerHello + val sh = buildServerHello(pickedSuite) + transcript.append(sh) + outboundInitial.addLast(sh) + + // 5. Now we have CH..SH transcript → derive handshake traffic + keySchedule.deriveHandshakeTraffic() + clientHandshakeSecret = keySchedule.clientHandshakeSecret + serverHandshakeSecret = keySchedule.serverHandshakeSecret + keySchedule.deriveMaster() + + // 6. Build EncryptedExtensions + val ee = buildEncryptedExtensions() + transcript.append(ee) + outboundHandshake.addLast(ee) + + // 7. Build server Finished + val sf = buildFinished(serverHandshakeSecret!!) + transcript.append(sf) + outboundHandshake.addLast(sf) + + // 8. Derive application traffic now (after server Finished) + keySchedule.deriveApplicationTraffic() + clientApplicationSecret = keySchedule.clientApplicationSecret + serverApplicationSecret = keySchedule.serverApplicationSecret + } + + /** Process the client Finished — verifies its MAC. */ + fun receiveClientFinished(clientFinished: ByteArray) { + val r = QuicReader(clientFinished) + require(r.readByte() == TlsConstants.HS_FINISHED) + val len = r.readUint24() + val tag = r.readBytes(len) + val expected = finishedVerifyData(clientHandshakeSecret!!, transcript.snapshot()) + check(expected.contentEquals(tag)) { "client Finished MAC mismatch" } + transcript.append(clientFinished) + } + + private fun buildServerHello(pickedSuite: Int): ByteArray { + val w = QuicWriter() + w.writeByte(TlsConstants.HS_SERVER_HELLO) + w.withUint24Length { + writeUint16(TlsConstants.LEGACY_VERSION_TLS_1_2) + writeBytes(random) + writeByte(0) // legacy_session_id_len + writeUint16(pickedSuite) + writeByte(0) // null compression + // Extensions: supported_versions (selected), key_share + withUint16Length { + // supported_versions = TLS 1.3 + writeUint16(TlsConstants.EXT_SUPPORTED_VERSIONS) + withUint16Length { writeUint16(TlsConstants.VERSION_TLS_1_3) } + // key_share: group + key + writeUint16(TlsConstants.EXT_KEY_SHARE) + withUint16Length { + writeUint16(TlsConstants.GROUP_X25519) + writeTlsOpaque2(keyPair.publicKey) + } + } + } + return w.toByteArray() + } + + private fun buildEncryptedExtensions(): ByteArray { + val w = QuicWriter() + w.writeByte(TlsConstants.HS_ENCRYPTED_EXTENSIONS) + w.withUint24Length { + withUint16Length { + // ALPN with the single negotiated protocol + writeUint16(TlsConstants.EXT_ALPN) + withUint16Length { + withUint16Length { writeTlsOpaque1(alpn) } + } + // QUIC transport parameters + writeUint16(TlsConstants.EXT_QUIC_TRANSPORT_PARAMETERS) + writeTlsOpaque2(transportParameters) + } + } + return w.toByteArray() + } + + private fun buildFinished(secret: ByteArray): ByteArray { + val tag = finishedVerifyData(secret, transcript.snapshot()) + val w = QuicWriter() + w.writeByte(TlsConstants.HS_FINISHED) + w.withUint24Length { writeBytes(tag) } + return w.toByteArray() + } +} diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/TlsRoundTripTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/TlsRoundTripTest.kt new file mode 100644 index 0000000000..dddb58cb10 --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/TlsRoundTripTest.kt @@ -0,0 +1,122 @@ +/* + * 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.tls + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class TlsRoundTripTest { + /** + * End-to-end TLS 1.3 handshake driven entirely on Quartz primitives. + * + * Asserts that: + * - both sides reach handshake-complete + * - the negotiated handshake & application traffic secrets match + * bit-for-bit on both sides + * - the client decodes the server's ALPN + transport parameters + */ + @Test + fun handshake_completes_and_secrets_match() { + val capturedSecrets = CapturedSecrets() + val tps = byteArrayOf(0x00, 0x01, 0x02, 0x03) + val server = + InProcessTlsServer( + transportParameters = tps, + ) + val client = + TlsClient( + serverName = "example.test", + transportParameters = ByteArray(0), + secretsListener = capturedSecrets, + ) + client.start() + + // 1) Drain ClientHello → server + val ch = client.pollOutbound(TlsClient.Level.INITIAL) + assertNotNull(ch, "client should produce ClientHello at Initial level") + server.receiveClientHello(ch) + + // 2) Drain ServerHello (Initial level) → client + val sh = server.pollOutboundInitial() + assertNotNull(sh, "server should produce ServerHello at Initial level") + client.pushHandshakeBytes(TlsClient.Level.INITIAL, sh) + + // 3) Drain EncryptedExtensions + Finished (Handshake level) → client + val ee = server.pollOutboundHandshake() + assertNotNull(ee, "server should produce EncryptedExtensions") + client.pushHandshakeBytes(TlsClient.Level.HANDSHAKE, ee) + + val sf = server.pollOutboundHandshake() + assertNotNull(sf, "server should produce server Finished") + client.pushHandshakeBytes(TlsClient.Level.HANDSHAKE, sf) + + // 4) Drain client Finished → server + val cf = client.pollOutbound(TlsClient.Level.HANDSHAKE) + assertNotNull(cf, "client should produce Finished") + server.receiveClientFinished(cf) + + // 5) Both sides should agree on traffic secrets + assertContentEquals(server.clientHandshakeSecret, capturedSecrets.handshakeClient, "client handshake secret matches") + assertContentEquals(server.serverHandshakeSecret, capturedSecrets.handshakeServer, "server handshake secret matches") + assertContentEquals(server.clientApplicationSecret, capturedSecrets.applicationClient, "client app secret matches") + assertContentEquals(server.serverApplicationSecret, capturedSecrets.applicationServer, "server app secret matches") + + assertTrue(capturedSecrets.handshakeComplete, "handshake-complete callback fired") + assertEquals(TlsClient.State.SENT_CLIENT_FINISHED, client.state) + + // 6) Client should have surfaced ALPN and peer transport parameters + assertContentEquals(TlsConstants.ALPN_H3, client.negotiatedAlpn) + assertContentEquals(tps, client.peerTransportParameters) + } + + private class CapturedSecrets : TlsSecretsListener { + var handshakeClient: ByteArray? = null + var handshakeServer: ByteArray? = null + var applicationClient: ByteArray? = null + var applicationServer: ByteArray? = null + var handshakeComplete = false + + override fun onHandshakeKeysReady( + cipherSuite: Int, + clientSecret: ByteArray, + serverSecret: ByteArray, + ) { + handshakeClient = clientSecret + handshakeServer = serverSecret + } + + override fun onApplicationKeysReady( + cipherSuite: Int, + clientSecret: ByteArray, + serverSecret: ByteArray, + ) { + applicationClient = clientSecret + applicationServer = serverSecret + } + + override fun onHandshakeComplete() { + handshakeComplete = true + } + } +} diff --git a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/PlatformCrypto.kt b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/PlatformCrypto.kt new file mode 100644 index 0000000000..ac2dab394b --- /dev/null +++ b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/PlatformCrypto.kt @@ -0,0 +1,45 @@ +/* + * 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.crypto + +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Core +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec + +/** + * One-block AES-ECB encryption via JCA. Used only by QUIC header protection + * (one block per packet, so no need for a more elaborate API). + */ +actual val PlatformAesOneBlock: AesOneBlockEncrypt = + AesOneBlockEncrypt { key, block -> + val cipher = Cipher.getInstance("AES/ECB/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES")) + cipher.doFinal(block) + } + +/** + * ChaCha20 block encryption (RFC 8439 IETF variant) for header protection. + * Reuses Quartz's pure-Kotlin ChaCha20Core.chaCha20Xor. + */ +actual val PlatformChaCha20Block: ChaCha20BlockEncrypt = + ChaCha20BlockEncrypt { key, nonce, counter, plaintext -> + ChaCha20Core.chaCha20Xor(plaintext, key, nonce, counter) + }