feat(quic): RFC 9001 §6 1-RTT key update

quic-go initiates a 1-RTT key update partway through every transferloss
or transfercorruption test (KEY_PHASE bit flips 0→1 around server pn=100
by default). Pre-fix our parser used the OLD application keys for every
post-update packet, AEAD-failed all of them, never sent another ACK,
the server fell into PTO mode, and throughput collapsed (~24kbps over
60s vs the 10Mbps the path supports).

The fix is end-to-end:

- ShortHeaderPacket.peekKeyPhase: HP-unmasks just the first byte to
  surface the key-phase bit BEFORE running AEAD. The parser uses this
  to pick the right keys instead of paying for a doomed AEAD.

- QuicConnection: tracks the live application secrets (server- and
  client-side) and current send/receive key phase, plus a
  previousReceiveProtection slot for RFC §6.1 reorder-window decryption.
  deriveNextPhaseReceiveKeys derives the next phase via
  HKDF-Expand-Label("quic ku", "", Hash.length) without committing;
  commitKeyUpdate installs them only after AEAD has succeeded, then
  rolls the send side forward in lockstep so our next outbound
  carries the matching KEY_PHASE bit (peer needs that to confirm the
  rotation completed). HP key is NOT rotated, per spec.

- QuicConnectionParser.feedShortHeaderPacket: three-way dispatch on
  the peeked bit — matches current → live keys; matches retained
  previous → previous keys (reordered packet); else → derive
  next-phase, attempt AEAD, commit on success.

- QuicConnectionWriter: ShortHeaderPlaintextPacket(... keyPhase =
  conn.currentSendKeyPhase) at both 1-RTT build sites (steady-state
  and CONNECTION_CLOSE).

We don't drive key updates ourselves — only echo the peer's. Avoids
the bookkeeping for RFC 9001 §6.6 packet-count limits and the safety
benefits of voluntary rotation aren't load-bearing at our connection
scale.

Tests: peekKeyPhase round-trip + long-header rejection;
2-byte-pn round-trip when largestReceived is far behind (the original
suspected-but-not-actual cause before the key-phase reveal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-07 15:53:56 -04:00
parent d5c854befa
commit b622d0c936
5 changed files with 421 additions and 9 deletions
@@ -174,6 +174,103 @@ class ShortPayloadHeaderProtectionTest {
}
}
@Test
fun peek_key_phase_returns_phase_bit_without_aead() {
// Build a phase-1 packet, peek without running AEAD, expect the
// peek to surface keyPhase=true even though we never had the
// AEAD keys to actually decrypt the body. This is the gating
// operation in feedShortHeaderPacket — pick keys based on the
// peek before attempting AEAD.
val plain =
ShortHeaderPlaintextPacket(
dcid = dcid,
packetNumber = 0L,
payload = byteArrayOf(0x01, 0x02, 0x03, 0x04),
keyPhase = true,
)
val wire =
ShortHeaderPacket.build(
plain = plain,
aead = Aes128Gcm,
key = proto.clientKey,
iv = proto.clientIv,
hp = hp,
hpKey = proto.clientHp,
largestAckedInSpace = -1L,
)
val peek =
ShortHeaderPacket.peekKeyPhase(
bytes = wire,
offset = 0,
dcidLen = dcid.length,
hp = hp,
hpKey = proto.clientHp,
)
assertNotNull(peek)
assertEquals(true, peek.keyPhase)
}
@Test
fun peek_key_phase_returns_null_for_long_header() {
// Long-header form bit set → peek must reject.
val longHeader = ByteArray(64) { 0 }
longHeader[0] = 0xC0.toByte() // form=1, fixed=1
val peek =
ShortHeaderPacket.peekKeyPhase(
bytes = longHeader,
offset = 0,
dcidLen = dcid.length,
hp = hp,
hpKey = proto.clientHp,
)
assertEquals(null, peek)
}
@Test
fun two_byte_pn_round_trips_when_largest_acked_is_far_behind() {
// Reproduces the quic-go transferloss interop drop: server sends pn=100
// with pnLen=2 (because their `num_unacked = pn - largest_acked` exceeds
// 128 from their sender-side bookkeeping), client's largestReceived is
// 99 from the contiguous burst it just acked. If our HP unmask + PN
// decode mishandles 2-byte PNs, AEAD auth fails and we silently drop
// every packet from here on. The connection wedges in a one-packet-
// per-PTO loop because we stop generating ACKs.
val payload = byteArrayOf(0x01, 0x02, 0x03, 0x04)
val plain =
ShortHeaderPlaintextPacket(
dcid = dcid,
packetNumber = 100L,
payload = payload,
)
// largestAckedInSpace = -1 forces num_unacked = 101, which exceeds
// the 128-byte threshold and selects pnLen=2 in the builder.
val wire =
ShortHeaderPacket.build(
plain = plain,
aead = Aes128Gcm,
key = proto.clientKey,
iv = proto.clientIv,
hp = hp,
hpKey = proto.clientHp,
largestAckedInSpace = -1L,
)
val parsed =
ShortHeaderPacket.parseAndDecrypt(
bytes = wire,
offset = 0,
dcidLen = dcid.length,
aead = Aes128Gcm,
key = proto.clientKey,
iv = proto.clientIv,
hp = hp,
hpKey = proto.clientHp,
largestReceivedInSpace = 99L,
)
assertNotNull(parsed, "2-byte pn=100 with largestReceived=99 should decrypt")
assertEquals(100L, parsed.packet.packetNumber)
assertContentEquals(payload, parsed.packet.payload)
}
@Test
fun payload_at_or_above_threshold_is_unchanged() {
// pnLen=1, payload=4: already satisfies pnLen+payload >= 4. The