feat(quic-interop): wire SSLKEYLOGFILE + add chacha20 testcase (Phase 1a)

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
This commit is contained in:
Claude
2026-05-06 21:32:44 +00:00
parent 1586c0cced
commit afe11ac651
5 changed files with 179 additions and 13 deletions
@@ -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<String, Int>? {
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 <client_random_hex> <secret_hex>
* SERVER_HANDSHAKE_TRAFFIC_SECRET <client_random_hex> <secret_hex>
* CLIENT_TRAFFIC_SECRET_0 <client_random_hex> <secret_hex>
* SERVER_TRAFFIC_SECRET_0 <client_random_hex> <secret_hex>
*/
private class SslKeyLogger(
private val file: File,
) {
private var clientRandomLookup: (() -> ByteArray?)? = null
private val pending = mutableListOf<Pair<String, ByteArray>>()
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])
}
}