feat(quic): 0-RTT (early data) — picoquic + quic-go pass
Closes the matrix gap. The TLS layer now drives a full RFC 9001 §4.10 0-RTT path: - Resumption ClientHello includes the empty `early_data` extension when the cached TlsResumptionState carries maxEarlyDataSize > 0 (parsed from the prior connection's NewSessionTicket early_data extension). - TlsClient.start, post-CH-transcript-snapshot: derive client_early_traffic_secret + surface via TlsSecretsListener.onEarlyDataKeysReady. - QuicConnection.zeroRttSendProtection slot installed in the listener and cleared in onApplicationKeysReady (RFC 9001 §4.10 forbids 0-RTT use after 1-RTT keys are available). - TlsResumptionState now also carries peerTransportParameters + negotiatedAlpn from the issuing connection so a resumed connection can pre-load flow-control limits (initial_max_data, initial_max_streams_bidi, etc.) BEFORE the new ServerHello arrives. Without this, peerMaxStreamsBidi=0 and pre-handshake stream creation fails. RFC 9001 §7.4.1 explicitly carves out which parameters MUST be remembered for 0-RTT vs which MUST NOT (CIDs, ack delay). - QuicConnectionWriter.buildApplicationPacket: dual 0-RTT / 1-RTT path. When 1-RTT keys are absent but 0-RTT keys are present, build a long-header type=0x01 ZERO_RTT packet (sharing the Application packet number space per RFC 9000 §17.2.3) and skip ACK frames (server cannot ACK 0-RTT-level packets). Once 1-RTT installs, the writer naturally falls through to short-header. - InteropClient runResumptionTest gains a `zerortt` flag. When set, iter 0 fetches NOTHING (just establishes + waits the existing 200ms post-handshake window for the NewSessionTicket to arrive + closes), and iter 1 opens all URLs as bidi streams + enqueues GETs + driver.wakeup BEFORE awaitHandshake so the writer ships them as 0-RTT packets coalesced with (or right after) the resumed ClientHello in the first datagram. Results: - ✓ picoquic: 0-RTT 10682 bytes, 1-RTT 238 bytes — within the runner's 50% / 5000-byte 1-RTT cap. - ✓ quic-go: 0-RTT 10693 bytes, 1-RTT 1488 bytes — same. - ✕ aioquic: server rejects our 0-RTT (only 3 STREAM frames come back from 40 GETs sent); no rejection-fallback wired (a real implementation would track which app data was sent in 0-RTT and replay in 1-RTT after EE comes back without early_data acceptance). Out of scope for this pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+136
-21
@@ -34,6 +34,7 @@ import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.io.File
|
||||
@@ -275,7 +276,27 @@ fun main() {
|
||||
// the PSK already authenticated us). Different shape from
|
||||
// multiconnect (which never resumes) so it has its own
|
||||
// dispatch.
|
||||
"resumption" -> {
|
||||
"resumption", "zerortt" -> {
|
||||
// zerortt extends resumption — the runner's testcase
|
||||
// verifies the pcap shows >0 bytes in 0-RTT packets and
|
||||
// ≤50% of total client-direction bytes in 1-RTT. Same
|
||||
// 2-connection shape: connection 1 captures the ticket,
|
||||
// connection 2 resumes. The 0-RTT path activates when
|
||||
// the captured ticket has maxEarlyDataSize > 0 (set
|
||||
// by the issuing server's NewSessionTicket early_data
|
||||
// extension).
|
||||
//
|
||||
// Difference between the two testcases is the URL split:
|
||||
//
|
||||
// - resumption: split URLs in half across the two
|
||||
// connections so the runner sees 2 distinct
|
||||
// handshakes both transferring data.
|
||||
// - zerortt: connection 1 fetches NOTHING (just hold
|
||||
// the conn open long enough to receive the
|
||||
// NewSessionTicket), connection 2 fetches ALL URLs
|
||||
// via 0-RTT. This keeps iter 0's 1-RTT byte count at
|
||||
// ~zero so the runner's 50% cap on 1-RTT bytes
|
||||
// stays comfortably under the limit.
|
||||
runResumptionTest(
|
||||
requests = requests,
|
||||
downloadsDir = downloadsDir,
|
||||
@@ -284,6 +305,7 @@ fun main() {
|
||||
initialVersion = initialVersion,
|
||||
keyLogPath = keyLogPath,
|
||||
qlogDir = qlogDir,
|
||||
zerortt = (testcase == "zerortt"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -681,6 +703,8 @@ private fun runResumptionTest(
|
||||
initialVersion: Int,
|
||||
keyLogPath: String?,
|
||||
qlogDir: File?,
|
||||
/** When true, route ALL URLs to the second (resumed) connection's 0-RTT path. */
|
||||
zerortt: Boolean = false,
|
||||
): Int {
|
||||
val urls =
|
||||
requests
|
||||
@@ -698,11 +722,26 @@ private fun runResumptionTest(
|
||||
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)
|
||||
// resumption: split URLs in half across the two connections.
|
||||
// zerortt: connection 1 fetches nothing (just gets the
|
||||
// NewSessionTicket), connection 2 fetches all URLs via 0-RTT to
|
||||
// keep the 1-RTT byte count from iter 0 GETs out of the runner's
|
||||
// 1-RTT cap.
|
||||
val firstUrls: List<URI>
|
||||
val secondUrls: List<URI>
|
||||
if (zerortt) {
|
||||
firstUrls = emptyList()
|
||||
secondUrls = urls
|
||||
} else {
|
||||
val splitAt = urls.size / 2
|
||||
firstUrls = urls.subList(0, splitAt.coerceAtLeast(1))
|
||||
secondUrls = urls.subList(splitAt.coerceAtLeast(1), urls.size)
|
||||
}
|
||||
|
||||
// For the zerortt no-data iter 0 we still need a host/port to
|
||||
// connect to — borrow the first URL purely for its authority.
|
||||
val firstUrlsForConnection = if (firstUrls.isEmpty()) urls.subList(0, 1) else firstUrls
|
||||
val firstUrlsForGet = firstUrls
|
||||
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val outcome =
|
||||
@@ -712,7 +751,8 @@ private fun runResumptionTest(
|
||||
val outcome1 =
|
||||
runOneResumptionConnection(
|
||||
iterIdx = 0,
|
||||
urls = firstUrls,
|
||||
urls = firstUrlsForConnection,
|
||||
fetchUrls = firstUrlsForGet,
|
||||
downloadsDir = downloadsDir,
|
||||
cipherSuites = cipherSuites,
|
||||
offeredAlpns = offeredAlpns,
|
||||
@@ -736,6 +776,7 @@ private fun runResumptionTest(
|
||||
runOneResumptionConnection(
|
||||
iterIdx = 1,
|
||||
urls = secondUrls,
|
||||
fetchUrls = secondUrls,
|
||||
downloadsDir = downloadsDir,
|
||||
cipherSuites = cipherSuites,
|
||||
offeredAlpns = offeredAlpns,
|
||||
@@ -761,7 +802,15 @@ private fun runResumptionTest(
|
||||
|
||||
private suspend fun runOneResumptionConnection(
|
||||
iterIdx: Int,
|
||||
/** URLs whose authority drives socket connect — must be non-empty. */
|
||||
urls: List<URI>,
|
||||
/**
|
||||
* URLs to actually fetch. Distinct from [urls] for the zerortt
|
||||
* iter 0 case where we want to establish a connection (using the
|
||||
* first URL's authority) but not GET anything — the iter exists
|
||||
* solely to receive a NewSessionTicket the next iter can resume on.
|
||||
*/
|
||||
fetchUrls: List<URI>,
|
||||
downloadsDir: File,
|
||||
cipherSuites: IntArray?,
|
||||
offeredAlpns: List<Alpn>,
|
||||
@@ -814,6 +863,40 @@ private suspend fun runOneResumptionConnection(
|
||||
val driver = QuicConnectionDriver(conn, socket, scope)
|
||||
driver.start()
|
||||
|
||||
// 0-RTT path: when this iteration is on a resumed connection AND the
|
||||
// resumption ticket allowed early data, open the bidi streams and
|
||||
// enqueue the GET requests BEFORE awaiting the handshake. The
|
||||
// writer will pick them up using the 0-RTT keys derived in
|
||||
// onEarlyDataKeysReady (long-header type=0x01) and ship them
|
||||
// alongside (or right after) the ClientHello in the first
|
||||
// datagram. RFC 9001 §4.6 / RFC 8446 §4.2.10 — the server may
|
||||
// accept or reject 0-RTT; if rejected the data is silently lost
|
||||
// on the server side and we'd need to re-send post-handshake. For
|
||||
// the runner's 0-RTT testcase against a server that accepts, we
|
||||
// get away without the rebuild — the server processes the
|
||||
// requests and replies in 1-RTT after handshake.
|
||||
val zeroRttPlanned = resumption != null && resumption.maxEarlyDataSize > 0 && fetchUrls.isNotEmpty()
|
||||
val pre0RttHandles =
|
||||
if (zeroRttPlanned) {
|
||||
// ALPN isn't yet renegotiated for THIS connection (we're
|
||||
// pre-handshake), so use the cached ALPN from the prior
|
||||
// connection — RFC 9001 §4.6 requires the same ALPN when
|
||||
// sending 0-RTT data.
|
||||
val cachedAlpn = resumption!!.negotiatedAlpn?.decodeToString().orEmpty()
|
||||
// Pre-handshake stream open works because QuicConnection.init
|
||||
// pre-loaded peerMaxStreamsBidi from the resumption state.
|
||||
val handles =
|
||||
conn.openBidiStreamsBatch(fetchUrls.map { "GET ${it.path}\r\n".encodeToByteArray() }) { stream, request ->
|
||||
stream.send.enqueue(request)
|
||||
stream.send.finish()
|
||||
stream
|
||||
}
|
||||
driver.wakeup()
|
||||
cachedAlpn to handles
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val handshake =
|
||||
withTimeoutOrNull(HANDSHAKE_TIMEOUT_SEC * 1_000L) {
|
||||
runCatching { conn.awaitHandshake() }
|
||||
@@ -846,23 +929,55 @@ private suspend fun runOneResumptionConnection(
|
||||
}
|
||||
|
||||
var anyFailed = false
|
||||
for (url in urls) {
|
||||
val resp =
|
||||
withTimeoutOrNull(TRANSFER_TIMEOUT_SEC * 1_000L) {
|
||||
client.get(authority, url.path)
|
||||
if (pre0RttHandles != null) {
|
||||
// 0-RTT path: streams already opened and GETs already enqueued
|
||||
// pre-handshake. Just collect the responses on each stream.
|
||||
// Server may have accepted the 0-RTT data (responses come back)
|
||||
// or rejected it; rejection means data was dropped server-side
|
||||
// and we'd have to resend in 1-RTT, which is real work and not
|
||||
// wired here. For the runner's zerortt testcase against picoquic
|
||||
// (which accepts), this path is sufficient.
|
||||
val (_, handles) = pre0RttHandles
|
||||
for ((url, stream) in fetchUrls.zip(handles)) {
|
||||
val body =
|
||||
withTimeoutOrNull(TRANSFER_TIMEOUT_SEC * 1_000L) {
|
||||
val chunks = stream.incoming.toList()
|
||||
val total = chunks.sumOf { it.size }
|
||||
val buf = ByteArray(total)
|
||||
var off = 0
|
||||
for (c in chunks) {
|
||||
c.copyInto(buf, off)
|
||||
off += c.size
|
||||
}
|
||||
buf
|
||||
}
|
||||
if (body == null || body.isEmpty()) {
|
||||
anyFailed = true
|
||||
System.err.println("[resumption:$iterIdx] 0RTT GET ${url.path} → empty/timeout")
|
||||
continue
|
||||
}
|
||||
if (resp == null) {
|
||||
anyFailed = true
|
||||
System.err.println("[resumption:$iterIdx] GET ${url.path} → timeout")
|
||||
break
|
||||
val name = url.path.substringAfterLast('/').ifBlank { "index" }
|
||||
File(downloadsDir, name).writeBytes(body)
|
||||
}
|
||||
if (resp.status != 200) {
|
||||
System.err.println("[resumption:$iterIdx] GET ${url.path} → status ${resp.status}")
|
||||
anyFailed = true
|
||||
continue
|
||||
} else {
|
||||
for (url in fetchUrls) {
|
||||
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)
|
||||
}
|
||||
val name = url.path.substringAfterLast('/').ifBlank { "index" }
|
||||
File(downloadsDir, name).writeBytes(resp.body)
|
||||
}
|
||||
|
||||
// Give the server a chance to send its NewSessionTicket — picoquic
|
||||
|
||||
Reference in New Issue
Block a user