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:
Claude
2026-04-25 17:33:54 +00:00
parent 2d541c6fd4
commit 692b034566
22 changed files with 2537 additions and 0 deletions
@@ -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
}
}
}