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
@@ -166,6 +166,56 @@ class QuicConnection(
val handshake = LevelState()
val application = LevelState()
/**
* RFC 9001 §6 1-RTT key update — application-level state.
*
* The TLS handshake hands us the initial 1-RTT secrets via
* [onApplicationKeysReady]. From there, EITHER side can roll forward
* to the next-phase secret by computing
* `next = HKDF-Expand-Label(current, "quic ku", "", Hash.length)` —
* QUIC signals the rotation in the per-packet `KEY_PHASE` bit.
*
* - [appReceiveSecret] / [appSendSecret] hold the LIVE secrets in use
* (the keys derived from these are what [application.receiveProtection]
* / [application.sendProtection] hold).
* - [currentReceiveKeyPhase] / [currentSendKeyPhase] track which phase
* those secrets correspond to (false = phase 0, true = phase 1, then
* flipping). The wire bit must match the live keys' phase.
* - [previousReceiveProtection] holds the keys for the PRIOR phase so
* we can decrypt reordered packets that arrive after we've already
* rotated forward (RFC 9001 §6.1: "The recipient SHOULD retain old
* keys for some time after unprotecting a packet sent using the new
* keys"). Cleared on the next rotation.
*
* Initial-/Handshake-level packets carry long headers and are not
* subject to key update — these fields apply only to APPLICATION.
*
* Why we initiate the rotation in lockstep with the peer rather than
* driving it ourselves: when a peer initiates a key update, RFC 9001
* §6.1 requires us to respond with packets in the new phase so they
* can confirm the rotation took effect. We don't proactively initiate
* key updates — there's no safety benefit at our connection scale and
* not initiating means we never have to track per-packet usage limits
* (RFC 9001 §6.6).
*/
@Volatile
internal var appCipherSuite: Int = 0
@Volatile
internal var appReceiveSecret: ByteArray? = null
@Volatile
internal var appSendSecret: ByteArray? = null
@Volatile
internal var currentReceiveKeyPhase: Boolean = false
@Volatile
internal var currentSendKeyPhase: Boolean = false
@Volatile
internal var previousReceiveProtection: PacketProtection? = null
@Volatile
var handshakeComplete: Boolean = false
private set
@@ -429,6 +479,15 @@ class QuicConnection(
) {
application.sendProtection = packetProtectionFromSecret(cipherSuite, clientSecret)
application.receiveProtection = packetProtectionFromSecret(cipherSuite, serverSecret)
// Stash the live secrets + cipher suite so we can derive
// next-phase keys via HKDF-Expand-Label("quic ku") on demand
// when the peer initiates a key update (RFC 9001 §6). Only
// application-level keys are subject to key update —
// Initial / Handshake levels are short-lived and never see
// the KEY_PHASE bit (long headers don't carry it).
appCipherSuite = cipherSuite
appReceiveSecret = serverSecret.copyOf()
appSendSecret = clientSecret.copyOf()
qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION)
qlogObserver.onKeyUpdated("server", EncryptionLevel.APPLICATION)
}
@@ -1271,6 +1330,137 @@ class QuicConnection(
}
}
/**
* RFC 9001 §6.1 key update — rotate application-level keys forward by
* one phase. Called from [com.vitorpamplona.quic.connection.feedShortHeaderPacket]
* once it has positively decrypted a packet whose `KEY_PHASE` bit
* differs from [currentReceiveKeyPhase] using freshly-derived keys.
*
* next_secret = HKDF-Expand-Label(current_secret, "quic ku", "", Hash.length)
* next_key = HKDF-Expand-Label(next_secret, "quic key", "", aead.key_length)
* next_iv = HKDF-Expand-Label(next_secret, "quic iv", "", iv.length)
*
* Header-protection key is NOT updated (RFC 9001 §6.1: "The QUIC header
* is protected using the same packet protection key as the packet
* payload, but the header_protection key is not updated when keys are
* updated").
*
* Caller has already validated that the new-phase keys decrypt the
* triggering packet — we install them as live, demote the prior keys
* to [previousReceiveProtection] for the reorder window, and update
* the send side in lockstep so our next outbound packet carries the
* matching `KEY_PHASE` bit.
*
* Returns the [PacketProtection] the caller should retry-decrypt with
* (the new receive-side keys); null if app keys aren't installed yet
* (handshake hasn't completed) or the cipher suite is unsupported.
*
* Idempotent guard: if [currentReceiveKeyPhase] already matches
* [newPhase] (concurrent path raced us to the rotation), this returns
* the current keys unchanged. Should never happen given the parser is
* single-threaded, but cheap insurance.
*/
internal fun deriveNextPhaseReceiveKeys(): com.vitorpamplona.quic.connection.PacketProtection? {
val current = appReceiveSecret ?: return null
val cs = appCipherSuite.takeIf { it != 0 } ?: return null
// RFC 9001 §6.1 — secret rotation label.
val nextSecret =
com.vitorpamplona.quic.crypto.HKDF
.expandLabel(current, "quic ku", ByteArray(0), current.size)
// Reuse the existing builder; it derives key + iv + hp from a secret,
// and the spec just discards the new HP key (we keep the old one).
val nextProtection =
com.vitorpamplona.quic.connection.packetProtectionFromSecret(
cipherSuite = cs,
secret = nextSecret,
)
// Keep the OLD HP key — RFC 9001 §6.1 forbids rotating it.
val live = application.receiveProtection ?: return null
val rebound =
com.vitorpamplona.quic.connection.PacketProtection(
aead = nextProtection.aead,
key = nextProtection.key,
iv = nextProtection.iv,
hp = live.hp,
hpKey = live.hpKey,
)
// Rotation is committed by the caller after AEAD success — return
// the new keys without mutating state. The caller calls
// [commitKeyUpdate] on success.
return rebound
}
/**
* Commit a successful 1-RTT key rotation. Called by the parser after
* [deriveNextPhaseReceiveKeys] returned keys that decrypted the
* triggering packet. Side effects:
* - Demote the live receive keys to [previousReceiveProtection]
* (kept for the reorder window — packets sent before the peer
* rotated are still tagged with the old KEY_PHASE).
* - Install the next-phase receive keys as live.
* - Flip [currentReceiveKeyPhase].
* - Roll the send-side secret + keys forward in lockstep so our next
* outbound packet carries the matching KEY_PHASE bit. Per RFC 9001
* §6.1 the peer uses our matching-phase response to confirm the
* rotation took effect.
* - Replace the stashed secret with the next-phase secret so a SECOND
* rotation derives off the right base.
*
* The send-side rotation is unconditional — we always echo the peer's
* phase rather than running independent rotation schedules. This is
* spec-compliant (the peer just observes our phase; there's no
* requirement to drive our own rotation independently) and avoids the
* extra plumbing needed to enforce RFC 9001 §6.6 packet-count limits.
*/
internal fun commitKeyUpdate(newReceive: com.vitorpamplona.quic.connection.PacketProtection) {
val live = application.receiveProtection ?: return
previousReceiveProtection = live
application.receiveProtection = newReceive
// Re-derive the receive secret so the NEXT rotation hashes off the
// right base. The receive keys were derived from
// HKDF-Expand-Label(current_secret, "quic ku", ...), so the new
// current_secret is the same expansion.
val cs = appCipherSuite
val curRx = appReceiveSecret
if (curRx != null && cs != 0) {
appReceiveSecret =
com.vitorpamplona.quic.crypto.HKDF
.expandLabel(curRx, "quic ku", ByteArray(0), curRx.size)
}
currentReceiveKeyPhase = !currentReceiveKeyPhase
// Send side: roll forward in lockstep so our next outbound packet
// carries the matching KEY_PHASE bit. Peer's loss-recovery uses our
// matching-phase response as the "rotation confirmed" signal.
val curTx = appSendSecret
if (curTx != null && cs != 0) {
val nextSendSecret =
com.vitorpamplona.quic.crypto.HKDF
.expandLabel(curTx, "quic ku", ByteArray(0), curTx.size)
appSendSecret = nextSendSecret
val freshSend =
com.vitorpamplona.quic.connection.packetProtectionFromSecret(
cipherSuite = cs,
secret = nextSendSecret,
)
val liveSend = application.sendProtection
if (liveSend != null) {
// Reuse old HP key for send too (HP is not rotated).
application.sendProtection =
com.vitorpamplona.quic.connection.PacketProtection(
aead = freshSend.aead,
key = freshSend.key,
iv = freshSend.iv,
hp = liveSend.hp,
hpKey = liveSend.hpKey,
)
currentSendKeyPhase = !currentSendKeyPhase
}
}
qlogObserver.onKeyUpdated("server", EncryptionLevel.APPLICATION)
qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION)
}
/** Caller must hold [lock]. Snapshot of streams for the driver's send loop. */
internal fun streamsLocked(): Map<Long, QuicStream> = streams
@@ -262,24 +262,88 @@ private fun feedShortHeaderPacket(
nowMillis: Long,
) {
val state = conn.levelState(EncryptionLevel.APPLICATION)
val proto = state.receiveProtection
if (proto == null) {
val live = state.receiveProtection
if (live == null) {
conn.qlogObserver.onPacketDropped(
"no application receive keys",
datagram.size - offset,
)
return
}
// RFC 9001 §6 — pick the right keys BEFORE running AEAD by peeking at
// the protected first byte's key-phase bit. Three cases:
// 1. Wire phase == current phase → use current (live) keys.
// 2. Wire phase != current phase but matches the previously-current
// phase (we already rotated past it) → use the retained
// [previousReceiveProtection] for reordered packets.
// 3. Wire phase != current phase and != previous → peer has rotated
// to the next phase; derive next-phase keys, attempt AEAD, on
// success commit the rotation.
// Without this dance, every post-rotation packet AEAD-fails silently
// (qlog drops) and the connection wedges (no ACKs to peer, peer falls
// into PTO mode → throughput collapse). Surfaced by quic-go
// transferloss interop, which initiates a key update around server
// pn=100 by default.
val peek =
ShortHeaderPacket.peekKeyPhase(
bytes = datagram,
offset = offset,
dcidLen = conn.sourceConnectionId.length,
hp = live.hp,
hpKey = live.hpKey,
)
if (peek == null) {
conn.qlogObserver.onPacketDropped(
"AEAD auth failed or header parse failed at level APPLICATION",
datagram.size - offset,
)
return
}
val keysToUse: PacketProtection
val rotateOnSuccess: PacketProtection?
when {
peek.keyPhase == conn.currentReceiveKeyPhase -> {
keysToUse = live
rotateOnSuccess = null
}
// Reordered packet from before our last rotation — try the
// retained previous keys. Reordering window is small but real
// for paths with non-trivial RTT.
conn.previousReceiveProtection != null -> {
keysToUse = conn.previousReceiveProtection!!
rotateOnSuccess = null
}
// Peer just rotated. Derive next-phase keys and prepare to commit
// if AEAD succeeds. Failure path is the same as a corrupted /
// unauthenticated packet — silent drop.
else -> {
val nextPhase = conn.deriveNextPhaseReceiveKeys()
if (nextPhase == null) {
conn.qlogObserver.onPacketDropped(
"AEAD auth failed or header parse failed at level APPLICATION",
datagram.size - offset,
)
return
}
keysToUse = nextPhase
rotateOnSuccess = nextPhase
}
}
val parsed =
ShortHeaderPacket.parseAndDecrypt(
bytes = datagram,
offset = offset,
dcidLen = conn.sourceConnectionId.length,
aead = proto.aead,
key = proto.key,
iv = proto.iv,
hp = proto.hp,
hpKey = proto.hpKey,
aead = keysToUse.aead,
key = keysToUse.key,
iv = keysToUse.iv,
hp = keysToUse.hp,
hpKey = keysToUse.hpKey,
largestReceivedInSpace = state.pnSpace.largestReceived,
)
if (parsed == null) {
@@ -289,6 +353,14 @@ private fun feedShortHeaderPacket(
)
return
}
// AEAD succeeded with the candidate next-phase keys → commit the
// rotation. The commit installs them as live, demotes the prior keys
// to [previousReceiveProtection], and rolls the send side forward so
// the next outbound carries the matching KEY_PHASE bit (peer uses
// that to confirm the rotation completed).
if (rotateOnSuccess != null) {
conn.commitKeyUpdate(rotateOnSuccess)
}
state.pnSpace.observeInbound(parsed.packet.packetNumber, nowMillis)
if (conn.qlogObserver !== com.vitorpamplona.quic.observability.QlogObserver.NoOp) {
conn.qlogObserver.onPacketReceived(
@@ -304,7 +304,12 @@ private fun buildBestLevelPacket(
val pn = app.pnSpace.allocateOutbound()
val built =
ShortHeaderPacket.build(
ShortHeaderPlaintextPacket(conn.destinationConnectionId, pn, payload),
ShortHeaderPlaintextPacket(
conn.destinationConnectionId,
pn,
payload,
keyPhase = conn.currentSendKeyPhase,
),
proto.aead,
proto.key,
proto.iv,
@@ -736,7 +741,12 @@ private fun buildApplicationPacket(
val sizeBytes =
runCatching {
ShortHeaderPacket.build(
ShortHeaderPlaintextPacket(conn.destinationConnectionId, pn, payload),
ShortHeaderPlaintextPacket(
conn.destinationConnectionId,
pn,
payload,
keyPhase = conn.currentSendKeyPhase,
),
proto.aead,
proto.key,
proto.iv,
@@ -90,6 +90,49 @@ object ShortHeaderPacket {
return packet
}
/**
* Header-protection unmask only peek the first byte's key-phase bit
* before committing to a particular AEAD key set. Returns null if the
* datagram is too short for a HP sample.
*
* RFC 9001 §6 (key update): the receiver MUST decide which key-phase
* keys to use BEFORE running AEAD. The key-phase bit is part of the
* header-protected first byte, so the only honest way to choose the
* right keys is to unmask the first byte first. This helper does that
* cheaply (one HP-block call), letting the caller pick current vs
* next-phase keys before paying for the AEAD.
*
* The result also reports the unmasked PN length so the caller can
* stop early on an obviously bogus header rather than allocating
* buffers for the doomed AEAD attempt.
*/
fun peekKeyPhase(
bytes: ByteArray,
offset: Int,
dcidLen: Int,
hp: HeaderProtection,
hpKey: ByteArray,
): Peek? {
if (offset >= bytes.size) return null
val first = bytes[offset].toInt() and 0xFF
if ((first and 0x80) != 0) return null
val pnOffset = offset + 1 + dcidLen
val sampleStart = pnOffset + 4
if (sampleStart + 16 > bytes.size) return null
val sample = bytes.copyOfRange(sampleStart, sampleStart + 16)
val mask = hp.mask(hpKey, sample)
val unprotectedFirst = first xor (mask[0].toInt() and 0x1F)
return Peek(
keyPhase = (unprotectedFirst and 0x04) != 0,
pnLen = (unprotectedFirst and 0x03) + 1,
)
}
data class Peek(
val keyPhase: Boolean,
val pnLen: Int,
)
/** Strip HP + decrypt a short-header packet. The DCID length must be known from connection state. */
fun parseAndDecrypt(
bytes: ByteArray,
@@ -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