feat(quic): TLS 1.3 session resumption (PSK)
Closes the gap that left the runner's `resumption` testcase as the last unsupported standard test. The TLS layer now: - Derives `resumption_master_secret` (RFC 8446 §7.1) right after appending client Finished to the transcript. Cached on the key schedule so it can seed PSK derivations from any subsequent NewSessionTicket the server emits. - Parses NewSessionTicket bodies (RFC 8446 §4.6.1) when they arrive post-handshake at Application level. For each ticket: derive the per-ticket PSK via `HKDF-Expand-Label(resumption_master_secret, "resumption", ticket_nonce, 32)` and surface a self-contained TlsResumptionState (ticket bytes + PSK + cipher suite + age-add + issued-at) through a new TlsSecretsListener.onNewSessionTicket callback. QuicConnection's tlsListener forwards to a public onResumptionTicket lambda the application sets. - On a fresh TlsClient construction with a non-null `resumption` argument: seed the early secret from the cached PSK (`HKDF-Extract(IKM=PSK, salt=0)` — the Quartz Hkdf.extract signature is `(IKM, salt)` despite the misleading first-parameter name; non-PSK deriveEarly passes zeros for both so the order didn't matter and the bug only surfaced now), build the resumption ClientHello with `pre_shared_key` as the LAST extension carrying a single identity (the cached ticket) and a binder over the PartialClientHello, splice the binder bytes into the encoded message after a one-shot SHA-256 hash of bytes 0..len-35. - State machine: when ServerHello carries `pre_shared_key` with the selected_identity we offered (we only ever send identity index 0, any other value is a hard fail), latch `pskAccepted = true`. WAITING_CERTIFICATE_OR_FINISHED then accepts Finished without the Certificate/CertificateVerify pair the full-handshake path requires — the PSK itself transitively authenticates the server via the prior issuing connection. - If we offered PSK but the server didn't pick it (full-handshake fallback), hard-fail. The fallback path needs to clear the PSK-seeded early secret and re-run derivation against zeros, which is real work; the runner's resumption testcase requires server acceptance anyway, so this gate isn't load-bearing for matrix green. Production callers that care about the fallback can wire it later. InteropClient adds a `runResumptionTest` that splits the runner's URL list in half across two sequential connections — first runs a full handshake and captures the NewSessionTicket via onResumptionTicket, second runs the PSK handshake with the cached state. ✓ R against aioquic, picoquic, quic-go. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -268,6 +268,25 @@ fun main() {
|
||||
)
|
||||
}
|
||||
|
||||
// resumption: two sequential connections — the second
|
||||
// resumes via PSK from the NewSessionTicket the first
|
||||
// captured. Runner verifies the second handshake's pcap
|
||||
// doesn't carry a Certificate (server skipped it because
|
||||
// the PSK already authenticated us). Different shape from
|
||||
// multiconnect (which never resumes) so it has its own
|
||||
// dispatch.
|
||||
"resumption" -> {
|
||||
runResumptionTest(
|
||||
requests = requests,
|
||||
downloadsDir = downloadsDir,
|
||||
cipherSuites = cipherSuites,
|
||||
offeredAlpns = offeredAlpns,
|
||||
initialVersion = initialVersion,
|
||||
keyLogPath = keyLogPath,
|
||||
qlogDir = qlogDir,
|
||||
)
|
||||
}
|
||||
|
||||
// keyupdate: same transfer flow but the client initiates a
|
||||
// RFC 9001 §6 1-RTT key update once the handshake is
|
||||
// confirmed. Runner verifies pcap shows BOTH client and
|
||||
@@ -640,6 +659,223 @@ private fun runTransferTest(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two QUIC connections, second resumed via PSK (resumption testcase).
|
||||
*
|
||||
* The runner's `resumption` testcase verifies the pcap contains exactly
|
||||
* 2 handshakes — the first carrying the server's Certificate, the second
|
||||
* NOT. We split the request URLs in half, fetch the first half on a
|
||||
* fresh connection (capturing the NewSessionTicket the server issues
|
||||
* post-handshake), then open a second connection with the cached
|
||||
* [TlsResumptionState]; the second handshake is PSK-bound so the server
|
||||
* skips Certificate / CertificateVerify entirely.
|
||||
*
|
||||
* Per-iteration qlog files at `$QLOGDIR/client-N.sqlog` so a stuck
|
||||
* connection leaves a focused trace.
|
||||
*/
|
||||
private fun runResumptionTest(
|
||||
requests: String,
|
||||
downloadsDir: File,
|
||||
cipherSuites: IntArray?,
|
||||
offeredAlpns: List<Alpn>,
|
||||
initialVersion: Int,
|
||||
keyLogPath: String?,
|
||||
qlogDir: File?,
|
||||
): Int {
|
||||
val urls =
|
||||
requests
|
||||
.split(Regex("\\s+"))
|
||||
.filter { it.isNotBlank() }
|
||||
.map { runCatching { URI(it) }.getOrNull() }
|
||||
.filter { it != null && it.host != null }
|
||||
.map { it!! }
|
||||
if (urls.size < 2) {
|
||||
System.err.println("resumption needs at least 2 URLs (got ${urls.size})")
|
||||
return EXIT_FAIL
|
||||
}
|
||||
|
||||
downloadsDir.mkdirs()
|
||||
qlogDir?.mkdirs()
|
||||
val keyLogger = keyLogPath?.let { SslKeyLogger(File(it)) }
|
||||
|
||||
// Split request list in half: first half on the full-handshake
|
||||
// connection, second half on the resumed PSK connection.
|
||||
val splitAt = urls.size / 2
|
||||
val firstUrls = urls.subList(0, splitAt.coerceAtLeast(1))
|
||||
val secondUrls = urls.subList(splitAt.coerceAtLeast(1), urls.size)
|
||||
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val outcome =
|
||||
runBlocking {
|
||||
// Connection 1 — full handshake, capture session ticket.
|
||||
var capturedTicket: com.vitorpamplona.quic.tls.TlsResumptionState? = null
|
||||
val outcome1 =
|
||||
runOneResumptionConnection(
|
||||
iterIdx = 0,
|
||||
urls = firstUrls,
|
||||
downloadsDir = downloadsDir,
|
||||
cipherSuites = cipherSuites,
|
||||
offeredAlpns = offeredAlpns,
|
||||
initialVersion = initialVersion,
|
||||
keyLogger = keyLogger,
|
||||
qlogDir = qlogDir,
|
||||
scope = scope,
|
||||
resumption = null,
|
||||
onTicket = { capturedTicket = it },
|
||||
)
|
||||
if (outcome1 != "ok") return@runBlocking "resumption[0] $outcome1"
|
||||
if (capturedTicket == null) return@runBlocking "resumption[0] no_ticket"
|
||||
|
||||
// Brief pause so the server fully tears down its connection
|
||||
// state before we try to resume — picoquic in particular
|
||||
// races the close-flush against the next ClientHello.
|
||||
delay(50)
|
||||
|
||||
// Connection 2 — PSK-resumed handshake.
|
||||
val outcome2 =
|
||||
runOneResumptionConnection(
|
||||
iterIdx = 1,
|
||||
urls = secondUrls,
|
||||
downloadsDir = downloadsDir,
|
||||
cipherSuites = cipherSuites,
|
||||
offeredAlpns = offeredAlpns,
|
||||
initialVersion = initialVersion,
|
||||
keyLogger = keyLogger,
|
||||
qlogDir = qlogDir,
|
||||
scope = scope,
|
||||
resumption = capturedTicket,
|
||||
onTicket = { /* could refresh, but the test is done */ },
|
||||
)
|
||||
if (outcome2 != "ok") return@runBlocking "resumption[1] $outcome2"
|
||||
"ok"
|
||||
}
|
||||
scope.cancel()
|
||||
|
||||
return if (outcome == "ok") {
|
||||
EXIT_OK
|
||||
} else {
|
||||
System.err.println("resumption $outcome")
|
||||
EXIT_FAIL
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runOneResumptionConnection(
|
||||
iterIdx: Int,
|
||||
urls: List<URI>,
|
||||
downloadsDir: File,
|
||||
cipherSuites: IntArray?,
|
||||
offeredAlpns: List<Alpn>,
|
||||
initialVersion: Int,
|
||||
keyLogger: SslKeyLogger?,
|
||||
qlogDir: File?,
|
||||
scope: CoroutineScope,
|
||||
resumption: com.vitorpamplona.quic.tls.TlsResumptionState?,
|
||||
onTicket: (com.vitorpamplona.quic.tls.TlsResumptionState) -> Unit,
|
||||
): String {
|
||||
val first = urls[0]
|
||||
val host = first.host
|
||||
val port = first.port.takeIf { it > 0 } ?: 443
|
||||
val authority = if (port == 443) host else "$host:$port"
|
||||
|
||||
val socket =
|
||||
try {
|
||||
UdpSocket.connect(host, port)
|
||||
} catch (t: Throwable) {
|
||||
return "udp_failed: ${t.message ?: t::class.simpleName}"
|
||||
}
|
||||
val qlogWriter =
|
||||
qlogDir?.let { dir ->
|
||||
QlogWriter(file = File(dir, "client-$iterIdx.sqlog"), odcidHex = "client$iterIdx")
|
||||
}
|
||||
val conn =
|
||||
QuicConnection(
|
||||
serverName = host,
|
||||
config =
|
||||
QuicConnectionConfig(
|
||||
initialMaxData = 32L * 1024 * 1024,
|
||||
initialMaxStreamDataBidiLocal = 32L * 1024 * 1024,
|
||||
initialMaxStreamDataBidiRemote = 32L * 1024 * 1024,
|
||||
initialMaxStreamDataUni = 32L * 1024 * 1024,
|
||||
),
|
||||
tlsCertificateValidator = PermissiveCertificateValidator(),
|
||||
alpnList = offeredAlpns.map { it.wireBytes },
|
||||
initialVersion = initialVersion,
|
||||
cipherSuites =
|
||||
cipherSuites
|
||||
?: intArrayOf(
|
||||
TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256,
|
||||
TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256,
|
||||
),
|
||||
extraSecretsListener = keyLogger?.listener,
|
||||
qlogObserver = qlogWriter ?: com.vitorpamplona.quic.observability.QlogObserver.NoOp,
|
||||
resumption = resumption,
|
||||
onResumptionTicket = onTicket,
|
||||
)
|
||||
val driver = QuicConnectionDriver(conn, socket, scope)
|
||||
driver.start()
|
||||
|
||||
val handshake =
|
||||
withTimeoutOrNull(HANDSHAKE_TIMEOUT_SEC * 1_000L) {
|
||||
runCatching { conn.awaitHandshake() }
|
||||
}
|
||||
if (handshake == null || handshake.isFailure) {
|
||||
runCatching { driver.close() }
|
||||
conn.tls.clientRandom?.let { keyLogger?.flush(it) }
|
||||
runCatching { qlogWriter?.close() }
|
||||
return "handshake_failed"
|
||||
}
|
||||
|
||||
val negotiated =
|
||||
conn.tls.negotiatedAlpn
|
||||
?.decodeToString()
|
||||
.orEmpty()
|
||||
val client: GetClient =
|
||||
when (negotiated) {
|
||||
"h3" -> {
|
||||
Http3GetClient(conn, driver).also { it.init(scope) }
|
||||
}
|
||||
|
||||
"hq-interop" -> {
|
||||
HqInteropGetClient(conn, driver)
|
||||
}
|
||||
|
||||
else -> {
|
||||
System.err.println("[resumption:$iterIdx] unrecognized ALPN '$negotiated'; defaulting to hq-interop")
|
||||
HqInteropGetClient(conn, driver)
|
||||
}
|
||||
}
|
||||
|
||||
var anyFailed = false
|
||||
for (url in urls) {
|
||||
val resp =
|
||||
withTimeoutOrNull(TRANSFER_TIMEOUT_SEC * 1_000L) {
|
||||
client.get(authority, url.path)
|
||||
}
|
||||
if (resp == null) {
|
||||
anyFailed = true
|
||||
System.err.println("[resumption:$iterIdx] GET ${url.path} → timeout")
|
||||
break
|
||||
}
|
||||
if (resp.status != 200) {
|
||||
System.err.println("[resumption:$iterIdx] GET ${url.path} → status ${resp.status}")
|
||||
anyFailed = true
|
||||
continue
|
||||
}
|
||||
val name = url.path.substringAfterLast('/').ifBlank { "index" }
|
||||
File(downloadsDir, name).writeBytes(resp.body)
|
||||
}
|
||||
|
||||
// Give the server a chance to send its NewSessionTicket — picoquic
|
||||
// in particular emits the ticket a few hundred ms post-handshake,
|
||||
// sometimes alongside the response stream's FIN.
|
||||
delay(200)
|
||||
|
||||
runCatching { driver.close() }
|
||||
conn.tls.clientRandom?.let { keyLogger?.flush(it) }
|
||||
runCatching { qlogWriter?.close() }
|
||||
return if (anyFailed) "request_failed" else "ok"
|
||||
}
|
||||
|
||||
/**
|
||||
* One QUIC connection per URL (handshakeloss / handshakecorruption).
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user