feat(quic): client-initiated 1-RTT key update + dispatch ecn/blackhole/amplificationlimit

The runner's keyupdate testcase has TESTCASE_CLIENT=keyupdate (server
runs plain transfer). The runner verifies the pcap shows BOTH sides
emit packets in phase 1 — pre-fix our receive-only key-update path
satisfied a server-initiated rotation but not this test, because
aioquic's transfer-server doesn't rotate spontaneously. Result: 0
phase-1 packets either direction, "Expected to see packets sent with
key phase 1 from both client and server".

QuicConnection.initiateKeyUpdate() (now public) is the send-side
analogue of commitKeyUpdate: derives next-phase secrets for both
directions via HKDF-Expand-Label "quic ku", installs as live
(reusing old HP keys per RFC §6.1), flips currentSendKeyPhase +
currentReceiveKeyPhase together. The receive side has to roll too
because the peer responds in the new phase — leaving currentReceive
at 0 would force feedShortHeaderPacket to take the
deriveNextPhase-then-commit path on the response and orphan the
keys we just installed in previousReceiveProtection.

InteropClient adds an `initiateKeyUpdate` flag to runTransferTest;
the keyupdate dispatch sets it true. After awaitHandshake (TLS done,
1-RTT keys derived) the flag-flow polls briefly for status=CONNECTED
(HANDSHAKE_DONE arrived → handshake confirmed per RFC 9001 §6.5
prerequisite) before calling initiateKeyUpdate, then sends the GET.
The GET goes out in phase 1, the server mirrors phase 1 in its
response, runner is satisfied.

Also added ecn, amplificationlimit, blackhole to the runTransferTest
dispatch (all reuse the plain-transfer flow; the runner verifies
behaviour via pcap independent of any client-side dance). aioquic
phase 3 result: ✓(retry, keyupdate, blackhole),
?(resumption, zerortt, ecn — feature gaps requiring session tickets,
0-RTT, and IP-layer ECT codepoints respectively),
amplificationlimit blocked by a runner-side cert-gen bug on macOS
(tr LC_CTYPE=C doesn't suppress UTF-8 errors, the chainlen=9 cert
inflation step fails).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-07 17:34:13 -04:00
15 changed files with 1026 additions and 336 deletions
@@ -209,10 +209,33 @@ fun main() {
// ipv6 — same flow over an IPv6 socket;
// JDK DatagramChannel.connect handles
// the v6 address resolution natively.
// ecn — runner verifies ECN-CE counts in
// the pcap. Client just does a 100KB
// transfer; the IP-layer ECT codepoint
// is set by the sim and we don't
// need to do anything special.
// amplificationlimit — runner verifies server obeys 3x
// amplification limit. Pure server
// check — client does a normal
// transfer (the runner sets
// TESTCASE_CLIENT=transfer).
// blackhole — sim drops ALL packets for several
// seconds mid-transfer; client must
// resume after blackhole ends. Our
// PTO + retransmit handles this; the
// runner sets TESTCASE_CLIENT=transfer.
// keyupdate — server initiates a 1-RTT key update
// mid-transfer (KEY_PHASE bit flips).
// Our RFC 9001 §6 receive-side key
// update lands the rotation; runner
// verifies the pcap shows packets
// in both phases. Server-side test
// from our perspective.
"handshake", "chacha20",
"transfer", "http3", "multiplexing",
"transferloss", "transfercorruption", "longrtt", "goodput", "crosstraffic",
"retry", "ipv6",
"ecn", "amplificationlimit", "blackhole",
// NOTE: the runner does NOT have a `versionnegotiation` testcase
// (its Available list excludes it). The :quic VN-handling code
// (applyVersionNegotiation, FORCE_VERSION_NEGOTIATION constant)
@@ -245,6 +268,28 @@ fun main() {
)
}
// 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
// server emitting packets in phase 1 — without our side
// initiating, aioquic's plain-transfer server doesn't
// rotate spontaneously and the test fails with "Expected
// to see packets sent with key phase 1 from both client
// and server".
"keyupdate" -> {
runTransferTest(
requests = requests,
downloadsDir = downloadsDir,
cipherSuites = cipherSuites,
offeredAlpns = offeredAlpns,
initialVersion = initialVersion,
keyLogPath = keyLogPath,
qlogDir = qlogDir,
parallel = requests.split(Regex("\\s+")).count { it.isNotBlank() } > 1,
initiateKeyUpdate = true,
)
}
// The runner reuses TESTCASE_CLIENT=multiconnect for the
// handshakeloss + handshakecorruption tests (see
// testcases_quic.py:746). Each URL must be fetched on a fresh
@@ -294,6 +339,7 @@ private fun runTransferTest(
keyLogPath: String?,
qlogDir: File?,
parallel: Boolean,
initiateKeyUpdate: Boolean = false,
): Int {
val urls =
requests
@@ -407,6 +453,27 @@ private fun runTransferTest(
// runs even when DEBUG=0 — this is a control-flow boundary,
// not a hot-path trace.
System.err.println("[boot] transfer mode: parallel=$parallel urls=${urls.size}")
// RFC 9001 §6 keyupdate testcase: the runner verifies the pcap
// shows packets in BOTH key phases from BOTH sides. Without
// initiating from our side, only the server's natural rotation
// (if any) would show — aioquic's transfer-server doesn't
// initiate, so we'd see only phase 0. Initiate after the
// handshake is confirmed (HANDSHAKE_DONE → status=CONNECTED,
// RFC 9001 §6.5 prerequisite) but BEFORE we send the GET so the
// request itself is in phase 1 — the server's response then
// mirrors phase 1, satisfying the runner's check. Brief poll
// for status because awaitHandshake returns on TLS-done
// (1-RTT keys derived) which is one ack ahead of HANDSHAKE_DONE
// arriving.
if (initiateKeyUpdate) {
withTimeoutOrNull(2_000L) {
while (conn.status != QuicConnection.Status.CONNECTED) delay(10)
}
conn.initiateKeyUpdate()
System.err.println("[boot] keyupdate: client initiated rotation to phase 1")
}
val outcome =
withTimeoutOrNull(TRANSFER_TIMEOUT_SEC * 1_000L) {
val responses =