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 =
@@ -1481,6 +1481,81 @@ class QuicConnection(
qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION)
}
/**
* RFC 9001 §6.1 — initiate a 1-RTT key update from our side. Derive
* next-phase send keys (and pre-derive next-phase receive keys, for
* the inevitable response from the peer) using HKDF-Expand-Label
* "quic ku", install both as live, flip the phase fields. The next
* outbound packet carries `KEY_PHASE = 1` and the peer is expected
* to mirror back in the same phase.
*
* RFC 9001 §6.5 says an endpoint MUST NOT initiate a key update
* before the handshake is confirmed (HANDSHAKE_DONE received). The
* caller is responsible for that check; this method just performs
* the rotation. §6.4 also forbids initiating a second update before
* the current one has been confirmed (peer responds in matching
* phase) — same caller contract.
*
* Header-protection key is unchanged (RFC 9001 §6.1: HP key is NOT
* rotated when keys are updated).
*
* Returns true if rotation succeeded; false if app keys aren't yet
* installed (handshake hasn't completed) or the cipher suite isn't
* cached. The interop runner's keyupdate testcase requires the
* client to send packets in phase 1 — without this method we'd
* only echo peer-initiated rotations and the test fails with
* "Expected to see packets sent with key phase 1 from both client
* and server".
*/
fun initiateKeyUpdate(): Boolean {
val cs = appCipherSuite.takeIf { it != 0 } ?: return false
val curRx = appReceiveSecret ?: return false
val curTx = appSendSecret ?: return false
val liveRx = application.receiveProtection ?: return false
val liveSend = application.sendProtection ?: return false
// Derive next-phase secrets and protections for both directions
// up front. We MUST roll both sides because the peer responds in
// the new phase — if our receive state is still at the old phase
// when their response lands, the receive-side commit path will
// re-derive the SAME keys we just installed (idempotent but
// wasteful) and then promote them, ending up with our previous
// receive keys orphaned in [previousReceiveProtection].
val nextRxSecret =
com.vitorpamplona.quic.crypto.HKDF
.expandLabel(curRx, "quic ku", ByteArray(0), curRx.size)
val nextTxSecret =
com.vitorpamplona.quic.crypto.HKDF
.expandLabel(curTx, "quic ku", ByteArray(0), curTx.size)
val freshRx = packetProtectionFromSecret(cs, nextRxSecret)
val freshTx = packetProtectionFromSecret(cs, nextTxSecret)
previousReceiveProtection = liveRx
application.receiveProtection =
com.vitorpamplona.quic.connection.PacketProtection(
aead = freshRx.aead,
key = freshRx.key,
iv = freshRx.iv,
hp = liveRx.hp,
hpKey = liveRx.hpKey,
)
application.sendProtection =
com.vitorpamplona.quic.connection.PacketProtection(
aead = freshTx.aead,
key = freshTx.key,
iv = freshTx.iv,
hp = liveSend.hp,
hpKey = liveSend.hpKey,
)
appReceiveSecret = nextRxSecret
appSendSecret = nextTxSecret
currentReceiveKeyPhase = !currentReceiveKeyPhase
currentSendKeyPhase = !currentSendKeyPhase
qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION)
qlogObserver.onKeyUpdated("server", EncryptionLevel.APPLICATION)
return true
}
/** Caller must hold [lock]. Snapshot of streams for the driver's send loop. */
internal fun streamsLocked(): Map<Long, QuicStream> = streams