An OpenPGP message is a concatenation of self-describing packets. Everything else in this series — key material, encryption, signatures, trust — is a specific packet type carrying a specific payload. This article covers the framing layer exclusively: the two header formats, all six length encodings (including the one that has caused the most parser CVEs), the complete tag table, the encoding rules for numbers and strings, the grammar that says which packet sequences are legal, and ASCII armor. If you are writing a parser, this is the layer that will hurt you first.
1. The mental model: a message is a packet stream
There is no OpenPGP "file header," no global length field, no table of contents. An OpenPGP object — a message, a certificate, a detached signature, a private keyring — is a bare concatenation of packets, each of which announces its own type and length in its first few octets. Parsing is therefore a loop:
while bytes remain:
tag, length_type = parse_header(next_octet)
body_length = parse_length(length_type, following_octets)
body = read(body_length)
dispatch(tag, body)
That simplicity is the format's great virtue and its central hazard. Because a packet stream is context-free at the framing layer, any packet can syntactically follow any other packet. Semantic legality is enforced by a separate grammar (§6), and implementations that parse without enforcing that grammar have historically accepted messages the standard never intended to exist. A large fraction of OpenPGP's security history is the story of parsers that were too permissive at exactly this boundary.
Fig. 1 — Structure is implied by what lives inside which packet body. A parser that forgets it is inside a SEIPD has already lost.
2. The packet header
Every packet begins with a Packet Tag octet. Bit 7 is always set — a first octet with bit 7 clear is malformed, and this is the cheapest sanity check a parser has. Bit 6 then selects between two mutually incompatible framings.
bit 7 6 5 4 3 2 1 0
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 1 │ f │ packet type / length │
└───┴───┴───┴───┴───┴───┴───┴───┘
│ │
│ └─ f=1 → OpenPGP format: bits 5..0 = packet type (0–63)
│ f=0 → Legacy format: bits 5..2 = packet type (0–15)
│ bits 1..0 = length type
└───── always 1
2.1 OpenPGP format (bit 6 set)
The current framing. Six bits of tag space gives 64 packet types, and the length is encoded separately in the octets that follow. This is what RFC 9580 requires implementations to generate.
2.2 Legacy format (bit 6 clear)
Inherited from RFC 1991 and RFC 2440. Only four bits of tag space (types 0–15), with the low two bits selecting one of four length encodings:
| Length type | Encoding | Notes |
|---|---|---|
0 | 1-octet length | 0–255 octets |
1 | 2-octet big-endian length | 0–65535 |
2 | 4-octet big-endian length | up to 232−1 |
3 | Indeterminate — runs to end of input | The dangerous one. See §3.3. |
You must still parse legacy format — decades of stored messages and certificates use it, and GnuPG emitted it by default for years. You should not generate it.
0xB4 is tag 13 (User ID), but read as OpenPGP format the low six bits give 52. Handle the branch before touching the tag.3. Length encodings — where parsers die
In OpenPGP format there are four length forms, distinguished by the value of the first length octet. This is a variable-length integer scheme with a deliberately awkward second range.
| First octet | Form | Length computation | Range |
|---|---|---|---|
0 – 191 | One-octet | len = octet[0] | 0 – 191 |
192 – 223 | Two-octet | len = ((o[0] − 192) << 8) + o[1] + 192 | 192 – 8383 |
224 – 254 | Partial body | len = 1 << (o[0] & 0x1F) | 1 – 1073741824 (powers of 2) |
255 | Five-octet | len = o[1..4] big-endian | 0 – 232−1 |
3.1 Why the two-octet form has that +192
The offset exists to make the encoding canonical — there is exactly one valid encoding for each length. Without the bias, lengths 0–191 would be representable in both the one- and two-octet forms, and a canonical-encoding check would be impossible. Concretely:
\[ \text{len} = ((o_0 - 192) \times 256) + o_1 + 192, \qquad 192 \le o_0 \le 223 \]Worked example: octets 0xC5 0x2E → \(((197 - 192) \times 256) + 46 + 192 = 1280 + 238 = 1518\).
If you are writing an encoder, emit the shortest legal form. If you are writing a parser that needs to detect malicious or fingerprintable output, note that non-canonical length encodings (a 5-octet form encoding the value 10, say) are legal to parse but are a strong signal about which implementation produced the message.
3.2 Partial body lengths — streaming, and its costs
Partial body lengths exist so that an encryptor can begin emitting ciphertext before knowing the total plaintext length — essential for streaming large files or piping. A packet body is split into a sequence of chunks: zero or more partial chunks, each a power of two in size, terminated by exactly one chunk with a normal (non-partial) length.
# A literal data packet streamed in chunks CB # tag 11 (Literal Data), OpenPGP format E9 # partial length: 1 << (0xE9 & 0x1F) = 1 << 9 = 512 octets .. 512 bytes .. E8 # partial length: 1 << 8 = 256 octets .. 256 bytes .. 2A # final chunk, one-octet length: 42 octets → packet ends here .. 42 bytes ..
Three rules that implementations get wrong:
- Only the first chunk's length octet is part of the packet header. Subsequent chunk lengths are inline in the body stream and must not be treated as new packets.
- Partial lengths are legal only for the first length octet of a packet, and RFC 9580 restricts them to packets whose bodies are streams — Literal Data, Compressed Data, and the encrypted-data packets. A partial-length User ID packet is malformed.
- The minimum partial chunk is 512 octets in practice (RFC 9580 forbids partial lengths below that for the first chunk), which exists to stop an attacker forcing pathological chunking.
0xFFFFFFFF asks for 4 GiB. A parser that instead accumulates partial chunks without bound is DoS-able by an infinite stream of partial chunks. The correct posture is streaming with an enforced total ceiling, applied to the decompressed and decrypted size as well — see the decompression-bomb discussion in Article 3.3.3 Indeterminate length, and why it enabled EFAIL-adjacent tricks
Legacy length type 3 means "this packet extends to the end of the input." It cannot be nested meaningfully, it makes truncation undetectable, and it removes the parser's ability to know where a packet was supposed to end. RFC 9580 keeps it only for backward compatibility and effectively forbids generating it.
The security consequence is direct: if a packet has no declared end, an attacker can append data and a naive parser will treat the appended bytes as part of the legitimate packet body. Article 3 covers how this class of trick combined with missing integrity protection to produce practical plaintext exfiltration.
4. The packet tag table
RFC 9580 divides the tag space at 40: packets with type IDs 0–39 are critical — an implementation that does not recognize one MUST reject the message rather than skip it. Types 40–63 are non-critical and MAY be ignored. This is a meaningful improvement over RFC 4880, where "unknown packet" handling was underspecified and implementations diverged.
| Tag | Packet | Role · covered in |
|---|---|---|
| 0 | Reserved | MUST NOT be used. A zero tag is a strong malformed-input signal. |
| 1 | Public-Key Encrypted Session Key (PKESK) | Session key wrapped to a recipient key · Article 3 |
| 2 | Signature | All signature types · Article 4 |
| 3 | Symmetric-Key Encrypted Session Key (SKESK) | Session key wrapped by a passphrase · Articles 2, 3 |
| 4 | One-Pass Signature | Lets a verifier hash in one pass · Article 4 |
| 5 | Secret-Key | Primary private key · Article 2 |
| 6 | Public-Key | Primary public key · Article 2 |
| 7 | Secret-Subkey | Subordinate private key · Article 2 |
| 8 | Compressed Data | ZIP / ZLIB / BZip2 container |
| 9 | Symmetrically Encrypted Data (SED) | Deprecated, unauthenticated. MUST NOT be generated; see Article 3. |
| 10 | Marker | Legacy no-op containing "PGP". Ignore it. |
| 11 | Literal Data | The actual payload plus filename/timestamp metadata |
| 12 | Trust | Local keyring state only. MUST NOT be exported. |
| 13 | User ID | UTF-8 identity string, conventionally Name <email> · Article 5 |
| 14 | Public-Subkey | Subordinate public key · Article 2 |
| 17 | User Attribute | Structured identity, in practice a JPEG image · Article 5 |
| 18 | Sym. Encrypted and Integrity Protected Data (SEIPD) | v1 (MDC) and v2 (AEAD) · Article 3 |
| 19 | Modification Detection Code (MDC) | Only inside SEIPDv1. Removed conceptually in v2 · Article 3 |
| 20 | Reserved (was AEAD Encrypted Data) | The fork point. LibrePGP uses this; RFC 9580 does not · Article 6 |
| 21 | Padding | New in RFC 9580. Traffic-analysis resistance; content MUST be ignored. |
5. Encoding primitives inside packet bodies
5.1 Multiprecision Integers (MPI)
The traditional container for big numbers — RSA moduli, DSA parameters, and so on. An MPI is a 2-octet big-endian bit count followed by the value, big-endian, with no leading zero octets:
# The integer 511 (0x01FF) as an MPI 00 09 # 9 bits of significance 01 FF # value, ceil(9/8) = 2 octets\[ \text{octets consumed} = 2 + \left\lceil \frac{\text{bitcount}}{8} \right\rceil \]
RFC 9580 tightened this: the encoded bit length MUST match the position of the most significant non-zero bit. Non-canonical MPIs — a declared length of 16 bits for the value 511, say — must be rejected. RFC 4880 was looser here, and permissive MPI parsing has been a reliable source of implementation divergence, since two implementations disagreeing about how many octets a field consumed will disagree about where every subsequent field starts.
5.2 Native (fixed-length) encoding
Modern algorithms do not use MPIs. X25519, X448, Ed25519, Ed448, and the post-quantum algorithms of RFC 9980 all use fixed-length octet strings in their algorithm-specific native encoding. This is a deliberate simplification: a 32-octet X25519 public key is 32 octets, always, with no length prefix and no leading-zero ambiguity. It also eliminates a whole class of parsing bug, because there is nothing to get wrong.
The practical consequence for parser authors: the encoding of key material is a function of the public-key algorithm ID, not a uniform rule. You need a per-algorithm table. Getting this wrong for a single algorithm produces a parser that works on 95% of real-world keys and silently corrupts the rest.
5.3 Times, strings, and key IDs
- Timestamps are 4-octet big-endian seconds since the Unix epoch. This overflows in 2106. RFC 9580 is aware of this and has not fixed it; treat it as a known long-term defect.
- User IDs are UTF-8 with no length prefix inside the body — the packet length bounds them. There is no structure requirement;
Name (comment) <email>is convention, not syntax. Do not write a parser that assumes it (Article 5 covers why comment fields are an attack surface). - Key IDs are 8 octets. Where they come from differs by key version and is covered in Article 2 — and the difference matters, because key IDs are not collision-resistant and must never be used as a security-relevant identifier.
6. The message grammar
A syntactically valid packet stream is not necessarily a valid OpenPGP message. RFC 9580 specifies a grammar, and enforcing it is a security requirement rather than a politeness. Informally:
OpenPGP Message := Encrypted | Signed | Compressed | Literal
Encrypted := (PKESK | SKESK)+ SEIPD
Compressed := Compressed Data Packet # body decompresses to a Message
Signed := One-Pass Signed Message | Signature Packet, Message
One-Pass Signed := OPS Packet, Message, corresponding Signature Packet
Literal := Literal Data Packet
Note the recursion: the body of a Compressed Data packet must itself be a valid Message, and the plaintext of a SEIPD packet must itself be a valid Message. This is why "decrypt then parse" is a loop and not a step.
[Literal] [Literal] is syntactically fine and semantically meaningless — but a permissive implementation might display the first and ignore the second, while another displays the concatenation. Any place two implementations disagree about what a message "is" becomes an exploitable gap: signature-scope confusion (Article 4), partially-signed message display, and encryption-status confusion in mail clients all live here. Parse to the grammar, reject anything that does not fit, and report the failure rather than doing something reasonable-looking.7. ASCII armor
Armor is the transport encoding that lets binary packet streams survive email and text channels. It is not a security mechanism and provides no integrity guarantee beyond a non-cryptographic checksum.
-----BEGIN PGP MESSAGE----- # armor header line Comment: optional armor headers # Key: Value, then a blank line hQIMA0f7Zk8lQ1n2AQ//SgQx3jN1cB1Y... # base64 of the packet stream ...more base64... =njUN # CRC24, base64, '=' prefixed (optional in 9580) -----END PGP MESSAGE----- # armor tail; type MUST match header
Points worth knowing:
- The CRC24 became optional in RFC 9580. It was always a transport-error check, never a security control, and its presence gave some users the false impression of integrity. If present it must be correct; its absence is not an error.
- Armor headers are attacker-controlled. A
Comment:line is arbitrary text that many tools display. Anything that renders armor headers to a user or logs them unescaped inherits an injection surface. - The header and tail types must match. A
BEGIN PGP MESSAGEclosed byEND PGP PUBLIC KEY BLOCKis malformed, and accepting the mismatch has been used to confuse tools about what kind of object they just processed. - Cleartext Signed Messages are a different construct with their own dash-escaping and trailing-whitespace rules, and they are the single most bug-prone corner of the format. Article 4 covers them in full, because their failure modes are signature failures rather than framing failures.
8. Reading a real message, octet by octet
A password-encrypted message. Every octet is accounted for:
C3 # 1100_0011 → bit7=1, bit6=1 → OpenPGP format, tag 3 = SKESK 1D # length 29 octets (one-octet form, 0x1D < 192) 06 # SKESK version 6 09 02 09 # cipher AES-256, AEAD OCB, S2K field lengths 04 # S2K type 4 = Argon2 .. 16 octets salt .. 03 04 10 # Argon2: 3 passes, parallelism 4, memory exponent 16 .. AEAD nonce and wrapped session key .. D2 # OpenPGP format, tag 18 = SEIPD FF 00 00 04 A1 # 5-octet length form → 1185 octets 02 # SEIPD version 2 → AEAD, not MDC 09 02 06 # AES-256, OCB, chunk size exponent 6 .. 32 octets salt .. .. ciphertext chunks, each with an authentication tag .. .. final authentication tag over the whole message ..
Two things to notice. First, the framing layer told us nothing about whether this message is trustworthy — that determination lives entirely in the SEIPD's AEAD tags and, if there is a signature inside, in the signature verification. Second, the SKESK version (6) and the SEIPD version (2) must agree; RFC 9580 pairs v6 PKESK/SKESK with SEIPDv2, and v3 PKESK/v4 SKESK with SEIPDv1. Mixing them is malformed, and the pairing rule exists specifically to prevent downgrade attacks in which an attacker swaps a modern container for a weak one.
9. A parser hardening checklist
| Check | Why |
|---|---|
| Reject first octet with bit 7 clear | Cheapest possible malformed-input rejection |
| Branch on bit 6 before extracting the tag | Legacy and OpenPGP formats use different tag widths |
| Enforce a total input ceiling, streaming | 5-octet lengths and unbounded partial chunks are both DoS vectors |
| Enforce partial lengths only on streamable packets | Prevents partial-length abuse on fixed-structure packets |
| Reject packet type 0 and unknown critical types (<40) | Required by RFC 9580; silently skipping is a downgrade surface |
| Reject non-canonical MPI bit lengths | Prevents field-offset divergence between implementations |
| Enforce the message grammar, not just packet syntax | Prevents scope-confusion and status-confusion classes |
| Bound recursion depth for Compressed and SEIPD nesting | A packet whose body decompresses to another compressed packet, repeatedly, is a zip bomb |
| Require armor header and tail types to match | Prevents object-type confusion |
| Treat armor headers as untrusted display data | Attacker-controlled text; escape before rendering or logging |
| Refuse to emit legacy format or indeterminate lengths | Reduces the surface you ask your peers to parse |
10. Tools for inspecting the wire format
You should not be reading hex by hand for long. The standard instruments:
# GnuPG's packet dumper — the most widely available gpg --list-packets --verbose message.pgp # Sequoia's, which is stricter and reports policy violations sq packet dump --hex message.pgp # Strip armor to raw binary for hexdump work gpg --dearmor < message.asc > message.bin xxd message.bin | head -40 # pgpdump — long-standing, very readable annotations pgpdump -i -l -m message.pgp
A useful habit when implementing: dump the same message with two independent tools. Where they disagree about packet boundaries, you have found either a bug in one of them or an ambiguity in your test vector — and both are worth investigating before you trust your own parser.
FAQ
Do I need to support legacy-format packets in a new implementation?
For parsing, yes — the installed base of stored messages and certificates is large and old, and a parser that rejects legacy framing will fail on real data constantly. For generation, no: emit OpenPGP format only. The asymmetry between what you accept and what you produce is deliberate.
Why does the two-octet length form have that strange 192 offset?
Canonicality. Without the bias, every length under 192 would have two valid encodings, making it impossible to require a unique wire representation. The offset makes the one-octet and two-octet ranges disjoint, so "shortest encoding" is well-defined and checkable.
Is ASCII armor providing any security?
No. It is base64 plus a framing convention plus an optional non-cryptographic CRC24. It survives text transport; it does not detect tampering. RFC 9580 making the CRC optional reflects exactly this — it was never doing security work.
What is the Padding packet actually for?
Traffic analysis resistance. Message sizes leak information — a 40-byte encrypted message and a 40-kilobyte one are distinguishable regardless of how strong the cipher is. Tag 21 lets an implementation pad to a size bucket. Its contents must be ignored by receivers, which also means it must never be included in any hash computation.
Takeaways
- The format is self-describing and context-free at the framing layer, which makes parsing easy and makes grammar enforcement mandatory rather than optional.
- Length parsing is the highest-risk code in an OpenPGP implementation. Four encodings, one of which is a streaming chunk protocol with its own rules, and one legacy form with no end at all.
- Tag 20 is the fork. RFC 9580 reserves it; LibrePGP uses it for AEAD. Interoperability failures at this tag are not bugs, they are the schism.
- MPI is legacy encoding. Modern algorithms use fixed-length native encodings, and your parser needs a per-algorithm dispatch table rather than one uniform rule.
- Version pairing between session-key packets and encryption containers is a downgrade defense, not bookkeeping. Enforce it.
Next in this series
Article 2 — Keys, fingerprints, and secret-key protection: the v4 → v6 transition, why key IDs are not identifiers, SHA-1 in v4 fingerprints and what actually breaks, the full S2K story including Argon2, and the AEAD-protected secret key format.
References
- RFC 9580 — OpenPGP — the normative spec this article follows. Sections 4 (packet syntax), 6 (armor), and 10 (message grammar) are the direct sources here.
- RFC 4880 — OpenPGP Message Format (obsoleted) — still the format most deployed software implements; read it for the legacy behavior you must parse.
- RFC 1991 — PGP Message Exchange Formats — where legacy packet framing comes from, if you want the archaeology.
- SoK: Why Johnny Can't Fix PGP Standardization — systematization of how the standardization process itself produced the ambiguities this article's hardening checklist defends against.
- RFC 9580 §5 — Packet Types — the authoritative tag table, including the critical/non-critical split at 40.
- Sequoia PGP — its
openpgpcrate is the most readable modern reference implementation of this parsing layer.