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:
@@ -52,12 +52,28 @@ Inspect `./logs/<run>/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 |
|
| 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 |
|
| 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
|
- `SSLKEYLOGFILE` writer in `InteropClient` (NSS Key Log Format) so Wireshark
|
||||||
runner will set them but our endpoint ignores them. Phase 1 must surface
|
decrypts the sim's pcap captures. Backed by:
|
||||||
TLS keys (so Wireshark can decrypt the sim's pcap captures) and qlog
|
- `TlsClient.clientRandom` (new public read-only property, captured in
|
||||||
output (so qvis is useful).
|
`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.
|
- Server role: we are client-first. Reassess after Phase 3.
|
||||||
- WebTransport is **not** part of the standard interop matrix; it needs a
|
- WebTransport is **not** part of the standard interop matrix; it needs a
|
||||||
separate harness against `moq-rs` / chrome-headless.
|
separate harness against `moq-rs` / chrome-headless.
|
||||||
|
|||||||
+105
-2
@@ -24,6 +24,8 @@ import com.vitorpamplona.quic.connection.QuicConnection
|
|||||||
import com.vitorpamplona.quic.connection.QuicConnectionConfig
|
import com.vitorpamplona.quic.connection.QuicConnectionConfig
|
||||||
import com.vitorpamplona.quic.connection.QuicConnectionDriver
|
import com.vitorpamplona.quic.connection.QuicConnectionDriver
|
||||||
import com.vitorpamplona.quic.tls.PermissiveCertificateValidator
|
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 com.vitorpamplona.quic.transport.UdpSocket
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -32,6 +34,7 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import java.io.File
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
@@ -60,20 +63,37 @@ fun main() {
|
|||||||
|
|
||||||
val testcase = System.getenv("TESTCASE")?.trim().orEmpty()
|
val testcase = System.getenv("TESTCASE")?.trim().orEmpty()
|
||||||
val requests = System.getenv("REQUESTS")?.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("== quic-interop client ==")
|
||||||
System.err.println("testcase: $testcase")
|
System.err.println("testcase: $testcase")
|
||||||
System.err.println("requests: $requests")
|
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 =
|
val code =
|
||||||
when (testcase) {
|
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
|
else -> EXIT_UNSUPPORTED
|
||||||
}
|
}
|
||||||
exitProcess(code)
|
exitProcess(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runHandshakeTest(requests: String): Int {
|
private fun runHandshakeTest(
|
||||||
|
requests: String,
|
||||||
|
cipherSuites: IntArray?,
|
||||||
|
keyLogPath: String?,
|
||||||
|
): Int {
|
||||||
val target =
|
val target =
|
||||||
parseFirstTarget(requests) ?: run {
|
parseFirstTarget(requests) ?: run {
|
||||||
System.err.println("no parseable target in REQUESTS")
|
System.err.println("no parseable target in REQUESTS")
|
||||||
@@ -91,12 +111,23 @@ private fun runHandshakeTest(requests: String): Int {
|
|||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
return@runBlocking "udp_failed: ${t.message ?: t::class.simpleName}"
|
return@runBlocking "udp_failed: ${t.message ?: t::class.simpleName}"
|
||||||
}
|
}
|
||||||
|
val keyLogger = keyLogPath?.let { SslKeyLogger(File(it)) }
|
||||||
val conn =
|
val conn =
|
||||||
QuicConnection(
|
QuicConnection(
|
||||||
serverName = host,
|
serverName = host,
|
||||||
config = QuicConnectionConfig(),
|
config = QuicConnectionConfig(),
|
||||||
tlsCertificateValidator = PermissiveCertificateValidator(),
|
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)
|
val driver = QuicConnectionDriver(conn, socket, scope)
|
||||||
driver.start()
|
driver.start()
|
||||||
|
|
||||||
@@ -111,6 +142,7 @@ private fun runHandshakeTest(requests: String): Int {
|
|||||||
else -> "failed: ${handshake.exceptionOrNull()?.message ?: "?"}"
|
else -> "failed: ${handshake.exceptionOrNull()?.message ?: "?"}"
|
||||||
}
|
}
|
||||||
runCatching { driver.close() }
|
runCatching { driver.close() }
|
||||||
|
keyLogger?.flush()
|
||||||
delay(50)
|
delay(50)
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -137,3 +169,74 @@ private fun parseFirstTarget(requests: String): Pair<String, Int>? {
|
|||||||
val port = uri.port.takeIf { it > 0 } ?: 443
|
val port = uri.port.takeIf { it > 0 } ?: 443
|
||||||
return host to port
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,6 +73,23 @@ class QuicConnection(
|
|||||||
.toEpochMilliseconds()
|
.toEpochMilliseconds()
|
||||||
},
|
},
|
||||||
val alpnList: List<ByteArray> = listOf(TlsConstants.ALPN_H3),
|
val alpnList: List<ByteArray> = 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)
|
val sourceConnectionId: ConnectionId = ConnectionId.random(8)
|
||||||
var destinationConnectionId: ConnectionId = ConnectionId.random(8)
|
var destinationConnectionId: ConnectionId = ConnectionId.random(8)
|
||||||
@@ -317,6 +334,7 @@ class QuicConnection(
|
|||||||
) {
|
) {
|
||||||
handshake.sendProtection = packetProtectionFromSecret(cipherSuite, clientSecret)
|
handshake.sendProtection = packetProtectionFromSecret(cipherSuite, clientSecret)
|
||||||
handshake.receiveProtection = packetProtectionFromSecret(cipherSuite, serverSecret)
|
handshake.receiveProtection = packetProtectionFromSecret(cipherSuite, serverSecret)
|
||||||
|
extraSecretsListener?.onHandshakeKeysReady(cipherSuite, clientSecret, serverSecret)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onApplicationKeysReady(
|
override fun onApplicationKeysReady(
|
||||||
@@ -326,6 +344,7 @@ class QuicConnection(
|
|||||||
) {
|
) {
|
||||||
application.sendProtection = packetProtectionFromSecret(cipherSuite, clientSecret)
|
application.sendProtection = packetProtectionFromSecret(cipherSuite, clientSecret)
|
||||||
application.receiveProtection = packetProtectionFromSecret(cipherSuite, serverSecret)
|
application.receiveProtection = packetProtectionFromSecret(cipherSuite, serverSecret)
|
||||||
|
extraSecretsListener?.onApplicationKeysReady(cipherSuite, clientSecret, serverSecret)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onHandshakeComplete() {
|
override fun onHandshakeComplete() {
|
||||||
@@ -333,6 +352,7 @@ class QuicConnection(
|
|||||||
if (status == Status.HANDSHAKING) status = Status.CONNECTED
|
if (status == Status.HANDSHAKING) status = Status.CONNECTED
|
||||||
applyPeerTransportParameters()
|
applyPeerTransportParameters()
|
||||||
handshakeDoneSignal.complete(Unit)
|
handshakeDoneSignal.complete(Unit)
|
||||||
|
extraSecretsListener?.onHandshakeComplete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +378,7 @@ class QuicConnection(
|
|||||||
secretsListener = tlsListener,
|
secretsListener = tlsListener,
|
||||||
certificateValidator = tlsCertificateValidator,
|
certificateValidator = tlsCertificateValidator,
|
||||||
offeredAlpns = alpnList,
|
offeredAlpns = alpnList,
|
||||||
|
cipherSuites = cipherSuites,
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|||||||
@@ -70,6 +70,17 @@ class TlsClient(
|
|||||||
val fixedKeyPair: X25519KeyPair? = null,
|
val fixedKeyPair: X25519KeyPair? = null,
|
||||||
/** When non-null, used as the ClientHello random (for deterministic tests). */
|
/** When non-null, used as the ClientHello random (for deterministic tests). */
|
||||||
val fixedRandom: ByteArray? = null,
|
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 {
|
enum class State {
|
||||||
INITIAL,
|
INITIAL,
|
||||||
@@ -120,6 +131,12 @@ class TlsClient(
|
|||||||
private var sharedSecret: ByteArray? = null
|
private var sharedSecret: ByteArray? = null
|
||||||
private var negotiatedCipherSuite: Int = -1
|
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. */
|
/** Begin the handshake by emitting a ClientHello at Initial level. */
|
||||||
fun start() {
|
fun start() {
|
||||||
check(state == State.INITIAL) { "TlsClient already started" }
|
check(state == State.INITIAL) { "TlsClient already started" }
|
||||||
@@ -127,14 +144,18 @@ class TlsClient(
|
|||||||
|
|
||||||
keySchedule.deriveEarly()
|
keySchedule.deriveEarly()
|
||||||
|
|
||||||
|
val random =
|
||||||
|
fixedRandom ?: com.vitorpamplona.quartz.utils.RandomInstance
|
||||||
|
.bytes(32)
|
||||||
|
clientRandom = random
|
||||||
|
|
||||||
val ch =
|
val ch =
|
||||||
buildQuicClientHello(
|
buildQuicClientHello(
|
||||||
serverName = serverName,
|
serverName = serverName,
|
||||||
x25519PublicKey = keyPair!!.publicKey,
|
x25519PublicKey = keyPair!!.publicKey,
|
||||||
quicTransportParams = transportParameters,
|
quicTransportParams = transportParameters,
|
||||||
random =
|
random = random,
|
||||||
fixedRandom ?: com.vitorpamplona.quartz.utils.RandomInstance
|
cipherSuites = cipherSuites,
|
||||||
.bytes(32),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val chBytes = ch.encode()
|
val chBytes = ch.encode()
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ fun buildQuicClientHello(
|
|||||||
quicTransportParams: ByteArray,
|
quicTransportParams: ByteArray,
|
||||||
additionalAlpn: List<ByteArray> = emptyList(),
|
additionalAlpn: List<ByteArray> = emptyList(),
|
||||||
random: ByteArray = RandomInstance.bytes(32),
|
random: ByteArray = RandomInstance.bytes(32),
|
||||||
|
cipherSuites: IntArray =
|
||||||
|
intArrayOf(
|
||||||
|
TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256,
|
||||||
|
TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256,
|
||||||
|
),
|
||||||
): TlsClientHello {
|
): TlsClientHello {
|
||||||
val alpn = mutableListOf<ByteArray>()
|
val alpn = mutableListOf<ByteArray>()
|
||||||
alpn += TlsConstants.ALPN_H3
|
alpn += TlsConstants.ALPN_H3
|
||||||
@@ -107,5 +112,5 @@ fun buildQuicClientHello(
|
|||||||
TlsExtension(TlsConstants.EXT_ALPN, encodeAlpn(alpn)),
|
TlsExtension(TlsConstants.EXT_ALPN, encodeAlpn(alpn)),
|
||||||
TlsExtension(TlsConstants.EXT_QUIC_TRANSPORT_PARAMETERS, quicTransportParams),
|
TlsExtension(TlsConstants.EXT_QUIC_TRANSPORT_PARAMETERS, quicTransportParams),
|
||||||
)
|
)
|
||||||
return TlsClientHello(random = random, extensions = exts)
|
return TlsClientHello(random = random, cipherSuites = cipherSuites, extensions = exts)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user