Merge branch 'worktree-agent-a2d7586cf714834cf' into claude/research-quic-libraries-hH1Dc
This commit is contained in:
+57
-25
@@ -125,21 +125,42 @@ fun drainOutbound(
|
||||
var natural = 0
|
||||
for (p in firstPass) natural += p.size
|
||||
if (natural < 1200) {
|
||||
val deficit = 1200 - natural
|
||||
// Rewind the Initial PN — we'll reissue with the same PN and the
|
||||
// same captured frames plus padding. The SentPacket entry recorded
|
||||
// by the natural-size build will be overwritten by the rebuild
|
||||
// below since both use the same PN.
|
||||
initialState.pnSpace.rewindOutboundForRebuild()
|
||||
val paddedInitial =
|
||||
buildLongHeaderFromFrames(
|
||||
conn = conn,
|
||||
level = EncryptionLevel.INITIAL,
|
||||
frames = initialContents!!.frames, // null-safe: gated by `initialNatural != null` above
|
||||
tokens = initialContents.tokens,
|
||||
nowMillis = nowMillis,
|
||||
padBytes = deficit,
|
||||
)
|
||||
// Padding rebuild: re-issue the Initial with PADDING frames inside
|
||||
// the AEAD envelope so the on-wire datagram clears the §14.1 floor.
|
||||
//
|
||||
// Off-by-one trap: the QUIC long-header Length field is a varint
|
||||
// (RFC 9000 §16). When the natural-size payload is tiny enough
|
||||
// for Length to fit in 1 byte (body ≤ 63 bytes), the rebuild's
|
||||
// larger body crosses the 64-byte threshold and Length grows to
|
||||
// 2 bytes — adding 1 wire byte that wasn't in `natural`. Same
|
||||
// shape at 16384 bytes (2 → 4) and 2^30 (4 → 8). A naive
|
||||
// `padBytes = 1200 - natural` then produces a 1199-byte
|
||||
// datagram for PING-only Initials.
|
||||
//
|
||||
// Solution: rebuild with the initial deficit, measure, and if we
|
||||
// still fall short, bump by the residual and rebuild once more.
|
||||
// Each iteration adds PADDING bytes 1:1 to the wire size; the
|
||||
// varint grows monotonically so this terminates in ≤ 2 rounds
|
||||
// for any reachable payload size.
|
||||
var padBytes = 1200 - natural
|
||||
var paddedInitial: ByteArray
|
||||
while (true) {
|
||||
initialState.pnSpace.rewindOutboundForRebuild()
|
||||
paddedInitial =
|
||||
buildLongHeaderFromFrames(
|
||||
conn = conn,
|
||||
level = EncryptionLevel.INITIAL,
|
||||
frames = initialContents!!.frames, // null-safe: gated by `initialNatural != null` above
|
||||
tokens = initialContents.tokens,
|
||||
nowMillis = nowMillis,
|
||||
padBytes = padBytes,
|
||||
)
|
||||
var totalAfterRebuild = paddedInitial.size
|
||||
if (handshakeNatural != null) totalAfterRebuild += handshakeNatural.size
|
||||
if (applicationPkt != null) totalAfterRebuild += applicationPkt.size
|
||||
if (totalAfterRebuild >= 1200) break
|
||||
padBytes += 1200 - totalAfterRebuild
|
||||
}
|
||||
concat(listOfNotNull(paddedInitial, handshakeNatural, applicationPkt))
|
||||
} else {
|
||||
concat(firstPass)
|
||||
@@ -185,7 +206,10 @@ private fun concat(parts: List<ByteArray>): ByteArray {
|
||||
* - §14.1 — a client datagram containing an Initial MUST be ≥ 1200 bytes,
|
||||
* even a close-only one. We do this by building once at natural size and,
|
||||
* if short, rewinding the PN and rebuilding with a PADDING-frame deficit
|
||||
* inside the AEAD envelope.
|
||||
* inside the AEAD envelope. The rebuild loops because the long-header
|
||||
* Length varint (RFC 9000 §16) can grow by 1 byte once the body crosses
|
||||
* the 64-byte threshold, so a single-shot deficit can land 1 byte short
|
||||
* of 1200.
|
||||
*/
|
||||
private fun buildClosingDatagram(
|
||||
conn: QuicConnection,
|
||||
@@ -233,15 +257,23 @@ private fun buildClosingDatagram(
|
||||
padBytes = 0,
|
||||
)
|
||||
if (natural.size >= 1200) return natural
|
||||
init.pnSpace.rewindOutboundForRebuild()
|
||||
return buildLongHeaderFromFrames(
|
||||
conn = conn,
|
||||
level = EncryptionLevel.INITIAL,
|
||||
frames = listOf(transportFrame),
|
||||
tokens = emptyList(),
|
||||
nowMillis = nowMillis,
|
||||
padBytes = 1200 - natural.size,
|
||||
)
|
||||
var padBytes = 1200 - natural.size
|
||||
var padded: ByteArray
|
||||
do {
|
||||
init.pnSpace.rewindOutboundForRebuild()
|
||||
padded =
|
||||
buildLongHeaderFromFrames(
|
||||
conn = conn,
|
||||
level = EncryptionLevel.INITIAL,
|
||||
frames = listOf(transportFrame),
|
||||
tokens = emptyList(),
|
||||
nowMillis = nowMillis,
|
||||
padBytes = padBytes,
|
||||
)
|
||||
if (padded.size >= 1200) break
|
||||
padBytes += 1200 - padded.size
|
||||
} while (true)
|
||||
return padded
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
+45
-11
@@ -104,22 +104,56 @@ class CloseDatagramRfcComplianceTest {
|
||||
datagram,
|
||||
"PTO probe MUST produce an Initial datagram pre-handshake — RFC 9002 §6.2.4",
|
||||
)
|
||||
// Padding aim is ≥ 1200 (RFC 9000 §14.1). The writer's existing
|
||||
// padding code overshoots the deficit calculation by ~1 byte
|
||||
// when the natural-size payload is small enough to use a 1-byte
|
||||
// Length varint that grows to 2 after padding. We accept ≥ 1199
|
||||
// here so the regression test catches the original
|
||||
// "0-bytes-emitted" / "45-bytes-malformed" symptoms; the strict
|
||||
// ≥ 1200 conformance is tracked separately as a tiny-payload
|
||||
// padding fix (close-only datagrams already exceed 1200, this
|
||||
// only affects PING-only PTO probes).
|
||||
// RFC 9000 §14.1: client datagrams containing an Initial MUST
|
||||
// be ≥ 1200 bytes. The writer's padding rebuild now accounts
|
||||
// for Length-varint growth (1 → 2 bytes) when the natural-size
|
||||
// payload is small (PING-only) so the deficit calculation
|
||||
// produces a final size that strictly meets the spec floor.
|
||||
assertTrue(
|
||||
datagram.size >= 1199,
|
||||
"PTO Initial datagram must be padded close to 1200, got ${datagram.size}",
|
||||
datagram.size >= 1200,
|
||||
"PTO Initial datagram MUST be ≥ 1200 bytes per RFC 9000 §14.1, got ${datagram.size}",
|
||||
)
|
||||
assertEquals(false, conn.pendingPing, "pendingPing MUST be cleared after the probe")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single-byte PING-only Initial pads to exactly 1200 bytes (no overshoot beyond varint growth)`() =
|
||||
runBlocking {
|
||||
// Boundary case for the padding-rebuild deficit calculation.
|
||||
// The smallest possible Initial-level frame payload is a single
|
||||
// PING frame (1 byte, encoded as 0x01 — RFC 9000 §19.2). With
|
||||
// no token, the 1-byte natural payload encodes a Length varint
|
||||
// of 1 byte. Once the rebuild adds ~1170 bytes of PADDING the
|
||||
// Length value crosses the 63-byte varint boundary and grows
|
||||
// to 2 bytes. The deficit calculation MUST account for that
|
||||
// growth, otherwise the rebuilt packet falls 1 byte short of
|
||||
// the §14.1 1200-byte floor.
|
||||
//
|
||||
// Asserts the strict floor (≥ 1200) AND that we don't overshoot
|
||||
// by more than the varint-growth delta plus a small slack — the
|
||||
// padded packet should land in the 1200..1203 range, never
|
||||
// 1199 (pre-fix) and never 1300+ (over-correction).
|
||||
val conn =
|
||||
QuicConnection(
|
||||
serverName = "example.test",
|
||||
config = QuicConnectionConfig(),
|
||||
tlsCertificateValidator = PermissiveCertificateValidator(),
|
||||
)
|
||||
conn.pendingPing = true
|
||||
|
||||
val datagram = drainOutbound(conn, nowMillis = 0L)
|
||||
assertNotNull(datagram, "PING-only PTO probe must produce a datagram")
|
||||
assertTrue(
|
||||
datagram.size >= 1200,
|
||||
"padded Initial MUST be ≥ 1200 bytes (RFC 9000 §14.1), got ${datagram.size}",
|
||||
)
|
||||
assertTrue(
|
||||
datagram.size <= 1203,
|
||||
"padded Initial should not overshoot the 1200 floor by more than the " +
|
||||
"Length-varint growth (≤ 3 bytes), got ${datagram.size}",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ConnectionCloseFrame encodes type 0x1c when frameType is non-null (transport close)`() {
|
||||
// Bug B fix relies on the writer passing frameType=0L (instead of
|
||||
|
||||
Reference in New Issue
Block a user