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