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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user