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