Skip to content

packet: never emit 1-byte truncated packet numbers#2449

Open
dave wants to merge 1 commit intocloudflare:masterfrom
dave:fix-pkt-num-len-1byte
Open

packet: never emit 1-byte truncated packet numbers#2449
dave wants to merge 1 commit intocloudflare:masterfrom
dave:fix-pkt-num-len-1byte

Conversation

@dave
Copy link
Copy Markdown

@dave dave commented Apr 25, 2026

Fixes #2448

What

Floor pkt_num_len at 2 bytes. Previously returned 1 when num_unacked < 128.

 pub fn pkt_num_len(pn: u64, largest_acked: u64) -> usize {
     let num_unacked: u64 = pn.saturating_sub(largest_acked) + 1;
     // computes ceil of num_unacked.log2() + 1
     let min_bits = u64::BITS - num_unacked.leading_zeros() + 1;
-    min_bits.div_ceil(8) as usize
+    (min_bits.div_ceil(8) as usize).max(2)
 }

(commit also expands the inline comment to explain the floor, and updates the existing pkt_num_encode_decode test, which asserted the 1-byte case.)

Why

1-byte truncation is unambiguous only with ≤128-packet reorder windows. Any path that produces >128-packet reordering — common on mobile data, behind some VPN egress, anywhere pacing meets aggregator hardware — collapses the decode space, AEAD nonce is wrong, decryption fails on the otherwise-correct ciphertext.

quic-go refuses for the same reason — PacketNumberLengthForHeader.

Reproducer

https://github.com/dave/quiche-bug — same hardware, same client, 90s:

Server AEAD failures
Stock 6
Patched (this PR) 0

Cost

1 extra byte per 1-RTT packet during the brief window where num_unacked < 128. Steady-state high-throughput connections see zero impact; post-ACK-burst conditions see at most 1 byte per packet for a small number of packets.

quic-go has shipped this floor since 2017 with no field regressions.

Test changes

pkt_num_encode_decode previously asserted pkt_num_len(0, 0) == 1 with a roundtrip loop branched on the result. Updated: asserts == 2, roundtrip loop simplified to test 2 bytes uniformly.

`pkt_num_len` returns the number of bytes used to truncate an outgoing
packet number. The current implementation can return 1 when fewer than
128 packets are unacked. RFC 9000 §17.1 permits this, but it is unsafe:
if the receiver observes more than 128 packets reordered, the entire
valid range of a 1-byte truncation collapses, and the decoded full
packet number can land on the wrong candidate. The AEAD nonce is
derived from the full packet number, so the receiver fails to decrypt
an otherwise-good packet.

The reference Go implementation, quic-go, refuses 1-byte truncation
for the same reason — see PacketNumberLengthForHeader in
quic-go/internal/protocol/packet_number.go ("it never chooses a
PacketNumberLen of 1 byte, since this is too short under certain
circumstances").

This change floors the result at 2 bytes. Cost: 1 byte per outgoing
1-RTT packet during the brief window where num_unacked < 128 (typically
just after an ACK clears a large batch). quic-go has accepted this cost
since at least 2017.

Fixes the AEAD-decryption-failure pattern reproducible at
https://github.com/dave/quiche-bug — that repo includes a Docker-based
server (this code, with optional patch toggle) and a Go HTTP/3 client
that scans qlog for payload_decrypt_error events.
@dave dave requested a review from a team as a code owner April 25, 2026 17:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pkt_num_len can return 1 byte, leading to AEAD decryption failures under packet reorder

1 participant