feat(quic): Phase B — TLS 1.3 client on Quartz primitives
Implement a TLS 1.3 client state machine that drives the QUIC handshake using only Quartz's existing crypto. No BouncyCastle dependency. - HKDF-Expand and HKDF-Expand-Label upstreamed to Quartz's Hkdf class with RFC 5869 + RFC 8448 test vectors covering them. - :quic crypto stack: AEAD (AES-128-GCM via Quartz's AESGCM, ChaCha20-Poly1305 via Quartz's pure-Kotlin impl), header protection (AES-ECB via JCA single block + ChaCha20 keystream), QUIC Initial-secret derivation matching RFC 9001 Appendix A.1 bit-for-bit. - TLS 1.3 transcript hash, key schedule (early/handshake/master + per-direction client/server traffic secrets), Finished MAC. - ClientHello + extension encoders carrying SNI, supported_versions=[TLS 1.3], supported_groups=[X25519], signature_algorithms covering ECDSA/RSA-PSS/Ed25519, X25519 key_share, psk_dhe_ke, ALPN=[h3], and the QUIC transport_parameters extension. - ServerHello + EncryptedExtensions + Certificate + CertificateVerify + Finished parsers. The state machine handles the certificate path and the PSK-style no-cert path; certificate validation is wired through a CertificateValidator SPI (real impl lands in Phase L). - Transport parameters codec covering all RFC 9000 §18.2 + RFC 9221 fields. - QuicWriter/QuicReader buffer helpers shared across the rest of the stack. Round-trip test: a minimal in-process TLS server built from the same primitives drives a full ClientHello → ServerHello → EE → Finished → client Finished exchange. Both sides reach handshake-complete and agree bit-for-bit on the handshake & application traffic secrets. ALPN + transport parameters round-trip through EncryptedExtensions cleanly. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
@@ -26,6 +26,9 @@ plugins {
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
freeCompilerArgs.add("-Xexpect-actual-classes")
|
||||
}
|
||||
jvm {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
|
||||
@@ -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)
|
||||
@@ -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<Long, ByteArray> = 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<Long, ByteArray>()
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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<ByteArray>(),
|
||||
Level.HANDSHAKE to ArrayDeque<ByteArray>(),
|
||||
Level.APPLICATION to ArrayDeque<ByteArray>(),
|
||||
)
|
||||
|
||||
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<ByteArray>,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<TlsExtension>,
|
||||
) {
|
||||
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<ByteArray> = emptyList(),
|
||||
random: ByteArray = RandomInstance.bytes(32),
|
||||
): TlsClientHello {
|
||||
val alpn = mutableListOf<ByteArray>()
|
||||
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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<TlsExtension> {
|
||||
val totalLen = r.readUint16()
|
||||
val end = r.position + totalLen
|
||||
val out = mutableListOf<TlsExtension>()
|
||||
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>): ByteArray {
|
||||
val w = QuicWriter()
|
||||
w.withUint16Length {
|
||||
for (p in protocols) writeTlsOpaque1(p)
|
||||
}
|
||||
return w.toByteArray()
|
||||
}
|
||||
@@ -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<TlsExtension>,
|
||||
) {
|
||||
/** 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<TlsExtension>,
|
||||
) {
|
||||
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<ByteArray>,
|
||||
) {
|
||||
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<ByteArray>()
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<ByteArray>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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') }
|
||||
}
|
||||
@@ -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') }
|
||||
}
|
||||
@@ -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<ByteArray>()
|
||||
private val outboundHandshake = ArrayDeque<ByteArray>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user