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:
@@ -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
|
||||
|
||||
|
||||
+79
-7
@@ -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(
|
||||
|
||||
+12
-2
@@ -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,
|
||||
|
||||
+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