From afe11ac6510239c65c05b23e19aa733365bbcde3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 21:32:44 +0000 Subject: [PATCH] feat(quic-interop): wire SSLKEYLOGFILE + add chacha20 testcase (Phase 1a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two debugging hooks to :quic so the interop endpoint can produce artifacts that make the runner actually useful for finding bugs: - TlsClient.clientRandom: capture and expose the 32-byte ClientHello random so a SSLKEYLOG line can correlate to this connection. - QuicConnection.extraSecretsListener: optional chained secrets listener (default null, no-op for production callers). Fires alongside the connection's own key-installation listener at every encryption-level transition. - QuicConnection.cipherSuites: knob to override the offered TLS cipher suites in ClientHello. InteropClient now: - Writes NSS Key Log Format lines to $SSLKEYLOGFILE when set, so Wireshark can decrypt the sim's pcap captures. - Implements the `chacha20` testcase by offering only TLS_CHACHA20_POLY1305_SHA256 — exercises the ChaCha20 AEAD path end-to-end against a peer. Defers QLOGDIR (needs qlog observer infrastructure across packet/ frame/recovery layers — own design doc) and `versionnegotiation` (needs the writer to accept a configurable initial QUIC version). https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT --- .../plans/2026-05-06-interop-runner.md | 26 +++- .../quic/interop/runner/InteropClient.kt | 111 +++++++++++++++++- .../quic/connection/QuicConnection.kt | 21 ++++ .../com/vitorpamplona/quic/tls/TlsClient.kt | 27 ++++- .../vitorpamplona/quic/tls/TlsClientHello.kt | 7 +- 5 files changed, 179 insertions(+), 13 deletions(-) diff --git a/quic/interop/plans/2026-05-06-interop-runner.md b/quic/interop/plans/2026-05-06-interop-runner.md index 0dd0ac2f9..d96e7cb0f 100644 --- a/quic/interop/plans/2026-05-06-interop-runner.md +++ b/quic/interop/plans/2026-05-06-interop-runner.md @@ -52,12 +52,28 @@ Inspect `./logs//client_qlog/*.qlog` in qvis when something breaks. | 3 | Edge cases | `retry`, `resumption`, `zerortt`, `keyupdate`, `rebinding-*`, `blackhole`, `amplificationlimit` | every test green or unsupported-127 with a written reason | | 4 | CI gate | nightly Phases 1–2; PR-blocking subset on every push | qlogs uploaded as artifacts on red | -## Known follow-ups (NOT in Phase 0) +## Phase 1a — landed 2026-05-06 -- `SSLKEYLOGFILE` and `QLOGDIR` are not yet wired through to `:quic` — the - runner will set them but our endpoint ignores them. Phase 1 must surface - TLS keys (so Wireshark can decrypt the sim's pcap captures) and qlog - output (so qvis is useful). +- `SSLKEYLOGFILE` writer in `InteropClient` (NSS Key Log Format) so Wireshark + decrypts the sim's pcap captures. Backed by: + - `TlsClient.clientRandom` (new public read-only property, captured in + `start()` before sending ClientHello). + - `QuicConnection.extraSecretsListener` (new optional constructor param, + chained after the connection's own key-installation listener; no-op + default, so production callers are unaffected). +- `chacha20` testcase: forces ChaCha20-Poly1305 only via new + `QuicConnection.cipherSuites` knob (threaded through `TlsClient` → + `buildQuicClientHello` → `TlsClientHello`). + +## Phase 1b — open + +- `QLOGDIR`: `:quic` has no qlog observer infrastructure yet. Wiring needs + hooks at packet send/recv, frame dispatch, recovery, and TLS state + transitions, plus the qlog JSON-NDJSON schema. Sized as its own design + doc before implementation. +- `versionnegotiation`: `QuicConnectionWriter` hard-codes `QuicVersion.V1` + in two call sites; threading a configurable initial-version through and + wiring the response-handling path is non-trivial. Defer. - Server role: we are client-first. Reassess after Phase 3. - WebTransport is **not** part of the standard interop matrix; it needs a separate harness against `moq-rs` / chrome-headless. diff --git a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt index b9b1a0504..4aff75346 100644 --- a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt +++ b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.quic.connection.QuicConnection import com.vitorpamplona.quic.connection.QuicConnectionConfig import com.vitorpamplona.quic.connection.QuicConnectionDriver import com.vitorpamplona.quic.tls.PermissiveCertificateValidator +import com.vitorpamplona.quic.tls.TlsConstants +import com.vitorpamplona.quic.tls.TlsSecretsListener import com.vitorpamplona.quic.transport.UdpSocket import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -32,6 +34,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull +import java.io.File import java.net.URI import kotlin.system.exitProcess @@ -60,20 +63,37 @@ fun main() { val testcase = System.getenv("TESTCASE")?.trim().orEmpty() val requests = System.getenv("REQUESTS")?.trim().orEmpty() + val keyLogPath = System.getenv("SSLKEYLOGFILE")?.takeIf { it.isNotBlank() } System.err.println("== quic-interop client ==") - System.err.println("testcase: $testcase") - System.err.println("requests: $requests") + System.err.println("testcase: $testcase") + System.err.println("requests: $requests") + System.err.println("sslkeylogfile: ${keyLogPath ?: "(unset)"}") + + val cipherSuites = + when (testcase) { + "chacha20" -> intArrayOf(TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256) + else -> null + } val code = when (testcase) { - "handshake" -> runHandshakeTest(requests) + // Both `handshake` and `chacha20` only require the handshake to + // complete; `chacha20` adds the constraint that we offered only + // ChaCha20-Poly1305 (verified by the runner via tshark on the + // sim's pcap, decrypted using SSLKEYLOGFILE). + "handshake", "chacha20" -> runHandshakeTest(requests, cipherSuites, keyLogPath) + else -> EXIT_UNSUPPORTED } exitProcess(code) } -private fun runHandshakeTest(requests: String): Int { +private fun runHandshakeTest( + requests: String, + cipherSuites: IntArray?, + keyLogPath: String?, +): Int { val target = parseFirstTarget(requests) ?: run { System.err.println("no parseable target in REQUESTS") @@ -91,12 +111,23 @@ private fun runHandshakeTest(requests: String): Int { } catch (t: Throwable) { return@runBlocking "udp_failed: ${t.message ?: t::class.simpleName}" } + val keyLogger = keyLogPath?.let { SslKeyLogger(File(it)) } val conn = QuicConnection( serverName = host, config = QuicConnectionConfig(), tlsCertificateValidator = PermissiveCertificateValidator(), + cipherSuites = + cipherSuites + ?: intArrayOf( + TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256, + TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256, + ), + extraSecretsListener = keyLogger?.listener, ) + // clientRandom is null until QuicConnection.start() runs the TLS + // state machine, so the logger reads it lazily during flush(). + keyLogger?.bindConnection(conn) val driver = QuicConnectionDriver(conn, socket, scope) driver.start() @@ -111,6 +142,7 @@ private fun runHandshakeTest(requests: String): Int { else -> "failed: ${handshake.exceptionOrNull()?.message ?: "?"}" } runCatching { driver.close() } + keyLogger?.flush() delay(50) result } @@ -137,3 +169,74 @@ private fun parseFirstTarget(requests: String): Pair? { val port = uri.port.takeIf { it > 0 } ?: 443 return host to port } + +/** + * Writes [NSS Key Log Format](https://firefox-source-docs.mozilla.org/security/nss/legacy/key_log_format/index.html) + * lines so Wireshark can decrypt the sim's captured pcap. + * + * CLIENT_HANDSHAKE_TRAFFIC_SECRET + * SERVER_HANDSHAKE_TRAFFIC_SECRET + * CLIENT_TRAFFIC_SECRET_0 + * SERVER_TRAFFIC_SECRET_0 + */ +private class SslKeyLogger( + private val file: File, +) { + private var clientRandomLookup: (() -> ByteArray?)? = null + private val pending = mutableListOf>() + + val listener: TlsSecretsListener = + object : TlsSecretsListener { + override fun onHandshakeKeysReady( + cipherSuite: Int, + clientSecret: ByteArray, + serverSecret: ByteArray, + ) { + pending += "CLIENT_HANDSHAKE_TRAFFIC_SECRET" to clientSecret + pending += "SERVER_HANDSHAKE_TRAFFIC_SECRET" to serverSecret + } + + override fun onApplicationKeysReady( + cipherSuite: Int, + clientSecret: ByteArray, + serverSecret: ByteArray, + ) { + pending += "CLIENT_TRAFFIC_SECRET_0" to clientSecret + pending += "SERVER_TRAFFIC_SECRET_0" to serverSecret + } + + override fun onHandshakeComplete() = Unit + } + + fun bindConnection(conn: QuicConnection) { + clientRandomLookup = { conn.tls.clientRandom } + } + + fun flush() { + val random = clientRandomLookup?.invoke() ?: return + val randomHex = random.toHex() + file.parentFile?.mkdirs() + file.appendText( + buildString { + for ((label, secret) in pending) { + append(label) + .append(' ') + .append(randomHex) + .append(' ') + .append(secret.toHex()) + .append('\n') + } + }, + ) + pending.clear() + } +} + +private fun ByteArray.toHex(): String = + buildString(size * 2) { + for (b in this@toHex) { + val v = b.toInt() and 0xff + append("0123456789abcdef"[v ushr 4]) + append("0123456789abcdef"[v and 0xf]) + } + } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt index cbdbe2e9d..4ba2ef011 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -73,6 +73,23 @@ class QuicConnection( .toEpochMilliseconds() }, val alpnList: List = listOf(TlsConstants.ALPN_H3), + /** + * Optional second listener invoked after the connection's own + * key-installation listener. Used by the interop runner endpoint to + * dump SSLKEYLOG lines so Wireshark can decrypt captured pcaps. + * Default `null` keeps production callers unaffected. + */ + val extraSecretsListener: TlsSecretsListener? = null, + /** + * TLS cipher suites to offer in the ClientHello. Override to e.g. + * `intArrayOf(TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256)` for the + * `chacha20` interop testcase. Default matches [TlsClient]'s default. + */ + val cipherSuites: IntArray = + intArrayOf( + TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256, + TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256, + ), ) { val sourceConnectionId: ConnectionId = ConnectionId.random(8) var destinationConnectionId: ConnectionId = ConnectionId.random(8) @@ -317,6 +334,7 @@ class QuicConnection( ) { handshake.sendProtection = packetProtectionFromSecret(cipherSuite, clientSecret) handshake.receiveProtection = packetProtectionFromSecret(cipherSuite, serverSecret) + extraSecretsListener?.onHandshakeKeysReady(cipherSuite, clientSecret, serverSecret) } override fun onApplicationKeysReady( @@ -326,6 +344,7 @@ class QuicConnection( ) { application.sendProtection = packetProtectionFromSecret(cipherSuite, clientSecret) application.receiveProtection = packetProtectionFromSecret(cipherSuite, serverSecret) + extraSecretsListener?.onApplicationKeysReady(cipherSuite, clientSecret, serverSecret) } override fun onHandshakeComplete() { @@ -333,6 +352,7 @@ class QuicConnection( if (status == Status.HANDSHAKING) status = Status.CONNECTED applyPeerTransportParameters() handshakeDoneSignal.complete(Unit) + extraSecretsListener?.onHandshakeComplete() } } @@ -358,6 +378,7 @@ class QuicConnection( secretsListener = tlsListener, certificateValidator = tlsCertificateValidator, offeredAlpns = alpnList, + cipherSuites = cipherSuites, ) init { diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt index 330912a53..38f37b159 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt @@ -70,6 +70,17 @@ class TlsClient( val fixedKeyPair: X25519KeyPair? = null, /** When non-null, used as the ClientHello random (for deterministic tests). */ val fixedRandom: ByteArray? = null, + /** + * Cipher suites offered in the ClientHello, in preference order. The + * default offers AES-128-GCM first then ChaCha20-Poly1305. Override to + * force a specific negotiation (e.g. `chacha20`-only for the matching + * quic-interop-runner testcase). + */ + val cipherSuites: IntArray = + intArrayOf( + TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256, + TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256, + ), ) { enum class State { INITIAL, @@ -120,6 +131,12 @@ class TlsClient( private var sharedSecret: ByteArray? = null private var negotiatedCipherSuite: Int = -1 + /** The 32-byte ClientHello random, available after [start]. Exposed so + * observers (e.g. SSLKEYLOGFILE writer) can correlate secrets with + * this connection. */ + var clientRandom: ByteArray? = null + private set + /** Begin the handshake by emitting a ClientHello at Initial level. */ fun start() { check(state == State.INITIAL) { "TlsClient already started" } @@ -127,14 +144,18 @@ class TlsClient( keySchedule.deriveEarly() + val random = + fixedRandom ?: com.vitorpamplona.quartz.utils.RandomInstance + .bytes(32) + clientRandom = random + val ch = buildQuicClientHello( serverName = serverName, x25519PublicKey = keyPair!!.publicKey, quicTransportParams = transportParameters, - random = - fixedRandom ?: com.vitorpamplona.quartz.utils.RandomInstance - .bytes(32), + random = random, + cipherSuites = cipherSuites, ) val chBytes = ch.encode() diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt index d238ed086..3971a9b73 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt @@ -92,6 +92,11 @@ fun buildQuicClientHello( quicTransportParams: ByteArray, additionalAlpn: List = emptyList(), random: ByteArray = RandomInstance.bytes(32), + cipherSuites: IntArray = + intArrayOf( + TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256, + TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256, + ), ): TlsClientHello { val alpn = mutableListOf() alpn += TlsConstants.ALPN_H3 @@ -107,5 +112,5 @@ fun buildQuicClientHello( TlsExtension(TlsConstants.EXT_ALPN, encodeAlpn(alpn)), TlsExtension(TlsConstants.EXT_QUIC_TRANSPORT_PARAMETERS, quicTransportParams), ) - return TlsClientHello(random = random, extensions = exts) + return TlsClientHello(random = random, cipherSuites = cipherSuites, extensions = exts) }