36. QUIC packets and frames
By the end of this lesson you will parse QUIC long headers and short headers from raw bytes, classify all 21 QUIC frame types, and understand the packet type hierarchy that carries the QUIC handshake and application data.
1. Problem
A single UDP datagram arriving on port 443 might contain one or more QUIC packets โ each with its own header, packet number, and payload. Inside each packet is a sequence of frames: STREAM frames carry data, ACK frames acknowledge delivery, CRYPTO frames carry the TLS handshake. If your parser misreads the first byte, it will misidentify the header form, read the wrong fields, and fail silently.
This lesson builds a QUIC packet header parser and a frame type classifier. Combined with the varint codec from Lesson 35, these form the foundation for all subsequent QUIC work.
2. Theory
Two header forms
QUIC uses two header formats, distinguished by the first bit of the first byte:
- Long Header (bit 7 = 1): used during connection establishment. Carries Version, both Connection IDs, and a Length field. Four subtypes: Initial, 0-RTT, Handshake, Retry.
- Short Header (bit 7 = 0): used after the handshake for all 1-RTT data. Omits Version and Source Connection ID โ both sides already know them. Minimal overhead for the common case.
The Long Header exists because during the handshake, the receiver does not yet know which Connection ID length the peer will use, what version they agreed on, or which keys apply. After the handshake, all that is negotiated, so the Short Header drops the redundant fields.
Why a Length field matters
Long Header packets include a Length field (encoded as a varint) that tells the receiver how many bytes the packet occupies. This enables packet coalescing (RFC 9000, ยง12.2): multiple QUIC packets can be packed into a single UDP datagram. The receiver uses Length to find where one packet ends and the next begins.
Short Header packets do not have a Length field. They must be the last (or only) QUIC packet in a UDP datagram. You cannot coalesce two Short Header packets โ there is no way to know where the first one ends.
Packet number encoding
QUIC packet numbers are strictly monotonically increasing (unlike TCP sequence numbers, which count bytes and can repeat on retransmission). The packet number is encoded in 1-4 bytes, with the length indicated by the Packet Number Length field in the header. The actual packet number is reconstructed by combining the truncated value with the largest acknowledged packet number:
RFC 9000, ยง17.1: "The packet number is protected using header protection; the length of the packet number field is encoded in lower two bits of the first byte... the encoded packet number is decoded by finding the packet number value that is closest to the next expected packet number."
Frames: the unit of information
A QUIC packet payload is a sequence of frames. Each frame begins with a type byte (encoded as a varint), followed by type-specific fields. A single packet may contain multiple frames of different types โ for example, an ACK frame followed by several STREAM frames.
RFC 9000 ยง12.4 defines 21 frame types. The frame type determines:
- What fields follow
- Which packet types may carry it (Initial, Handshake, 0-RTT, 1-RTT)
- Whether it is ack-eliciting (the receiver must send an ACK)
Packet type restrictions
Not every frame can appear in every packet type. For example:
| Frame | Initial | Handshake | 0-RTT | 1-RTT |
|---|---|---|---|---|
| PADDING | โ | โ | โ | โ |
| ACK | โ | โ | โ | โ |
| CRYPTO | โ | โ | โ | โ |
| STREAM | โ | โ | โ | โ |
| NEW_CONNECTION_ID | โ | โ | โ | โ |
| HANDSHAKE_DONE | โ | โ | โ | โ |
STREAM frames are forbidden in Initial and Handshake packets because those packet types use Initial/Handshake encryption keys โ application data must wait for 1-RTT keys (or use 0-RTT keys for early data).
3. Math / Spec
Long Header format (RFC 9000, ยง17.2)
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+
|1|1|T T|R R|P P| First byte
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Version (32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| DCID Len (8) | Destination Connection ID (0..160) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| SCID Len (8) | Source Connection ID (0..160) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
First byte breakdown:
- Bit 7: Header Form = 1 (long header)
- Bit 6: Fixed Bit = 1
- Bits 5-4: Long Packet Type (00=Initial, 01=0-RTT, 10=Handshake, 11=Retry)
- Bits 3-2: Reserved (set to 0 before header protection)
- Bits 1-0: Packet Number Length minus 1
After the common fields, each type has specific additions:
- Initial: Token Length (varint) + Token + Length (varint) + Packet Number
- 0-RTT: Length (varint) + Packet Number
- Handshake: Length (varint) + Packet Number
- Retry: Retry Token + Retry Integrity Tag (16 bytes)
Short Header format (RFC 9000, ยง17.3)
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+
|0|1|S|R R|K|P P| First byte
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Destination Connection ID (0..160) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Packet Number (8/16/24/32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
- Bit 7: Header Form = 0 (short header)
- Bit 6: Fixed Bit = 1
- Bit 5: Spin Bit (for passive RTT measurement โ see RFC 9312)
- Bits 4-3: Reserved
- Bit 2: Key Phase (for key updates)
- Bits 1-0: Packet Number Length minus 1
All 21 frame types (RFC 9000, ยง19)
| Type | Name | Purpose |
|---|---|---|
| 0x00 | PADDING | Increase packet size (e.g., Initial must be โฅ1200 bytes) |
| 0x01 | PING | Keep-alive; elicits ACK |
| 0x02 | ACK | Acknowledge received packets |
| 0x03 | ACK_ECN | ACK with ECN counts |
| 0x04 | RESET_STREAM | Abruptly terminate a stream's sending side |
| 0x05 | STOP_SENDING | Request peer to stop sending on a stream |
| 0x06 | CRYPTO | Carry TLS handshake messages |
| 0x07 | NEW_TOKEN | Provide token for future 0-RTT |
| 0x08โ0x0f | STREAM | Carry application data (3 low bits = OFF/LEN/FIN) |
| 0x10 | MAX_DATA | Connection-level flow control |
| 0x11 | MAX_STREAM_DATA | Stream-level flow control |
| 0x12 | MAX_STREAMS (bidi) | Limit on bidirectional streams peer may open |
| 0x13 | MAX_STREAMS (uni) | Limit on unidirectional streams |
| 0x14 | DATA_BLOCKED | Sender is blocked by connection flow control |
| 0x15 | STREAM_DATA_BLOCKED | Sender is blocked by stream flow control |
| 0x16 | STREAMS_BLOCKED (bidi) | Cannot open more bidirectional streams |
| 0x17 | STREAMS_BLOCKED (uni) | Cannot open more unidirectional streams |
| 0x18 | NEW_CONNECTION_ID | Supply a new Connection ID to peer |
| 0x19 | RETIRE_CONNECTION_ID | Ask peer to stop using a Connection ID |
| 0x1a | PATH_CHALLENGE | Validate a network path (8 random bytes) |
| 0x1b | PATH_RESPONSE | Reply to PATH_CHALLENGE (echo 8 bytes) |
| 0x1c | CONNECTION_CLOSE (QUIC) | Close connection with QUIC error code |
| 0x1d | CONNECTION_CLOSE (app) | Close connection with application error code |
| 0x1e | HANDSHAKE_DONE | Server signals handshake complete |
4. Code
The implementation builds on the varint codec from Lesson 35. Three new files:
quic_packet.hโ structs for long/short headers, frame type constants, public API.quic_packet.cโ parsing and building long/short headers, frame name lookup.main.cโ CLI that constructs one of each packet type, prints the hex, then parses it back.
Parsing a Long Header
The parser reads the first byte, extracts the header form bit and packet type, then reads the fixed fields (version, DCID length, DCID, SCID length, SCID):
int quic_long_hdr_parse(const uint8_t *buf, size_t len,
struct quic_long_hdr *out, size_t *consumed)
{
if (len < 7) return -1;
if ((buf[0] & 0x80) == 0) return -1; /* not a long header */
out->first_byte = buf[0];
out->version = (uint32_t)buf[1] << 24 | (uint32_t)buf[2] << 16 |
(uint32_t)buf[3] << 8 | buf[4];
size_t pos = 5;
out->dcid_len = buf[pos++];
if (out->dcid_len > 20 || pos + out->dcid_len > len) return -1;
memcpy(out->dcid, buf + pos, out->dcid_len);
pos += out->dcid_len;
if (pos >= len) return -1;
out->scid_len = buf[pos++];
if (out->scid_len > 20 || pos + out->scid_len > len) return -1;
memcpy(out->scid, buf + pos, out->scid_len);
pos += out->scid_len;
*consumed = pos;
return 0;
}
Frame type classification
const char *quic_frame_name(uint64_t frame_type)
{
if (frame_type == 0x00) return "PADDING";
if (frame_type == 0x01) return "PING";
if (frame_type == 0x02) return "ACK";
if (frame_type == 0x03) return "ACK_ECN";
if (frame_type >= 0x08 && frame_type <= 0x0f) return "STREAM";
/* ... all 21 types ... */
}
Building and running
make # builds ./quic_packet
./quic_packet # constructs and parses all four long header types
make test # runs the test suite
5. Tests
make test
The test suite (tests/test_packet.c) covers:
| Test | What it verifies |
|---|---|
test_long_hdr_initial |
Initial packet round-trips: version, DCID, SCID, type bits |
test_long_hdr_0rtt |
0-RTT packet type bits are 01 in first byte |
test_long_hdr_handshake |
Handshake packet type bits are 10 |
test_long_hdr_retry |
Retry packet type bits are 11 |
test_short_hdr_roundtrip |
Short header round-trips with spin bit, key phase |
test_reject_truncated_long |
Buffer too short for long header โ error |
test_reject_truncated_short |
Buffer too short for DCID โ error |
test_dcid_max_20 |
DCID length > 20 โ rejected |
test_frame_names |
All 21 frame types map to correct string names |
test_frame_allowed_initial |
ACK and CRYPTO allowed in Initial; STREAM forbidden |
test_frame_allowed_1rtt |
All frame types allowed in 1-RTT packets |
test_coalesce_long_headers |
Two long header packets coalesced into one datagram |
test_header_form_bit |
Bit 7 = 1 โ long, bit 7 = 0 โ short |
6. Exercises
1. Decode a real Initial packet by hand (โ )
Given this hex dump of the first 30 bytes of a QUIC Initial packet (from RFC 9001, Appendix A):
c0 00 00 00 01 08 83 94 c8 f0 3e 51 57 08 00 41
03 07 43 5f 63 5f 65 5f 67 5f 69 5f 6b 5f 6d
Decode every field by hand:
- First byte
0xc0: Header Form bit? Fixed bit? Packet type? Packet Number Length? - Version
00 00 00 01โ which QUIC version? - DCID Length and DCID bytes
- SCID Length and SCID bytes
- Any remaining varint fields (Token Length, Packet Length)
Verify against ./quic_packet -p <hex> output.
2. Build all four long header types (โ )
Using the code, construct one packet of each type (Initial, 0-RTT, Handshake, Retry) with DCID = {0xDE, 0xAD} and SCID = {0xBE, 0xEF}. Print the raw hex. Verify that quic_long_hdr_type() extracts the correct type from each first byte. Why is the Retry packet special โ what does it lack that the other three have?
3. Implement STREAM frame encoding/decoding (โ โ )
A STREAM frame (type 0x08โ0x0f) has this layout (RFC 9000, ยง19.8):
Type (1 byte) | Stream ID (varint) | [Offset (varint)] | [Length (varint)] | Data
The low 3 bits of the type byte encode optional fields:
- Bit 0 (0x01): FIN โ this frame marks the end of the stream
- Bit 1 (0x02): LEN โ Length field is present
- Bit 2 (0x04): OFF โ Offset field is present
Implement quic_stream_frame_build() and quic_stream_frame_parse(). Test with:
- Stream 0, no offset, no length, 100 bytes, no FIN (type = 0x08)
- Stream 4, offset 1000, length present, 50 bytes, FIN (type = 0x0f)
- Stream 16383, offset 0, length present, 0 bytes, FIN (type = 0x0b)
4. Implement packet coalescing and decoalescing (โ โ )
RFC 9000 ยง12.2 allows multiple QUIC packets in one UDP datagram. Long Header packets include a Length field, so the receiver can find packet boundaries. Short Header packets lack Length and must be last.
Implement quic_coalesce() (combine packets) and quic_decoalesce() (split them apart). Test by coalescing Initial + Handshake packets, then decoalescing and verifying both parse correctly. Also test that decoalescing rejects a datagram that tries to coalesce two short header packets.
5. Write a frame-allowed-in-packet-type validator (โ โ )
RFC 9000, Table 3 specifies which frames are allowed in which packet types. Implement quic_frame_allowed(frame_type, packet_type) that returns 1 if the frame is allowed, 0 otherwise.
Test these cases (from the RFC):
- STREAM in 0-RTT โ allowed
- STREAM in Initial โ forbidden
- ACK in Handshake โ allowed
- ACK in 0-RTT โ forbidden
- HANDSHAKE_DONE in Handshake โ forbidden
- HANDSHAKE_DONE in 1-RTT โ allowed
6. Parse a pcap capture into QUIC packets (โ โ โ )
Capture a real QUIC connection:
SSLKEYLOGFILE=/tmp/keys.log curl --http3 https://quic.tech:8443 -o /dev/null
tcpdump -i any -w /tmp/quic.pcap 'udp port 8443'
Write a program that reads the pcap file, strips the Ethernet + IP + UDP headers, and feeds the UDP payload to your QUIC parser. For each UDP datagram, print:
- Number of coalesced QUIC packets
- For each packet: header form (long/short), type, version, DCID, SCID, packet number length
- Count of frames by type in the entire capture
Verify that the first datagram contains coalesced Initial + Handshake packets.
References
- RFC 9000, ยง17 โ Packet formats (Long Header ยง17.2, Short Header ยง17.3)
- RFC 9000, ยง12 โ Packets and frames: general structure, coalescing rules
- RFC 9000, ยง19 โ Frame types and formats (all 21 types defined here)
- RFC 9000, ยง12.4, Table 3 โ Which frames are permitted in which packet types
- RFC 9001, Appendix A โ Example Initial packet bytes used in test vectors
- RFC 9312 โ QUIC spin bit for passive RTT measurement