fix(quic): gate client-initiated key update + path migration on HANDSHAKE_DONE

Pre-fix, [QuicConnection.initiateKeyUpdate] only checked that 1-RTT
keys were installed — which fires when the TLS handshake derives
Finished, well before HANDSHAKE_DONE arrives. RFC 9001 §6.1 + §4.1.2
require the CLIENT to consider the handshake confirmed (HANDSHAKE_DONE
received) before rolling KEY_PHASE; quinn / quic-go / picoquic
correctly close the connection with PROTOCOL_VIOLATION ("illegal
packet: key update error") when an early update arrives.

The interop runner's keyupdate testcase against quinn flushed this:
the client polled `status == CONNECTED` (which flips on TLS Finished
via `onHandshakeComplete`) and called `initiateKeyUpdate()` before
HANDSHAKE_DONE landed. ~33% repro rate; the rest of the runs HANDSHAKE_DONE
happened to arrive in the gap between the status check and the
update.

Fix:
  - Add `QuicConnection.handshakeConfirmed: Boolean` flipped only by
    the parser when a HANDSHAKE_DONE frame is processed.
  - Add `awaitHandshakeConfirmed()` for callers that need the
    spec-confirmed state.
  - Gate `initiateKeyUpdate()` on `handshakeConfirmed` (returns
    false otherwise — same shape as the existing app-keys-not-yet
    branch).
  - Tighten `triggerPathMigrationLocked` to also gate on
    `handshakeConfirmed` (RFC 9000 §9.1: same confirmation
    requirement). Pre-fix it gated on `handshakeComplete`, which
    flips at TLS-Finished too.
  - InteropClient's keyupdate flow now waits on
    `awaitHandshakeConfirmed` (with a 2s upper bound) rather than
    polling `status == CONNECTED`.

The test fixture in ConnectedClientFixture.newConnectedClient now
delivers HANDSHAKE_DONE by default — most tests want the
production "fully ready" state. New `deliverHandshakeDone = false`
parameter for tests that need to exercise the pre-confirmation
window (KeyUpdateClientInitiatedTest).

Tests:
  - KeyUpdateClientInitiatedTest:
      initiateKeyUpdateBeforeHandshakeDoneIsRejectedAndDoesNotRotateKeys
      initiateKeyUpdateAfterHandshakeDoneRotatesBothDirections
      awaitHandshakeConfirmedSuspendsUntilHandshakeDone
      triggerPathMigrationBeforeHandshakeDoneReturnsNotConnected

Verified against the live interop runner — 5/5 against quinn,
3/3 against quic-go, 3/3 against picoquic (pre-fix: ~33% pass rate
against quinn).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-08 20:22:03 -04:00
parent 707fcee5b6
commit e8991fab69
5 changed files with 290 additions and 10 deletions
@@ -522,11 +522,25 @@ private fun runTransferTest(
// (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)
// RFC 9001 §6.1 + §4.1.2: client MUST wait for
// HANDSHAKE_DONE before rolling KEY_PHASE. quinn /
// quic-go / picoquic close us with PROTOCOL_VIOLATION
// ("illegal packet: key update error") if we update
// earlier. status == CONNECTED is too lenient — it
// flips when TLS Finished is derived (well before
// HANDSHAKE_DONE arrives), so we wait on the
// handshake-confirmed signal instead.
val confirmed =
withTimeoutOrNull(2_000L) {
conn.awaitHandshakeConfirmed()
true
} ?: false
if (!confirmed) {
System.err.println("[boot] keyupdate: HANDSHAKE_DONE not received within 2s — skipping rotation")
} else {
conn.initiateKeyUpdate()
System.err.println("[boot] keyupdate: client initiated rotation to phase 1")
}
conn.initiateKeyUpdate()
System.err.println("[boot] keyupdate: client initiated rotation to phase 1")
}
val outcome =