OpenPGP Internals — An Implementer's Series

The Wire Format

Article 1 of 7 · Packets, lengths, armor

Sep 9, 2026 · security · 18 min read · 3600 words advanced

OpenPGP on the wire: packets, lengths, and armor.

security openpgp pgp rfc9580 cryptography

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.

Spec baseline. This series is written against RFC 9580 (July 2024), which obsoletes RFC 4880 and its extensions. Where RFC 4880 behavior still matters for interoperability — and it matters constantly — it is called out explicitly. Article 6 covers the LibrePGP fork, which disagrees with RFC 9580 at exactly the layers this article describes.

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.

An encrypted, signed message on the wire PKESK tag 1 · session key SEIPD — tag 18 encrypted; everything below is inside this ciphertext One-Pass Sig tag 4 Literal Data tag 11 · the actual plaintext Signature tag 2 nesting is expressed by containment in a packet body, not by any bracket syntax

Fig. 1 — Structure is implied by what lives inside which packet body. A parser that forgets it is inside a SEIPD has already lost.

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 typeEncodingNotes
01-octet length0–255 octets
12-octet big-endian length0–65535
24-octet big-endian lengthup to 232−1
3Indeterminate — runs to end of inputThe 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.

Implementer's trap. Because tag space differs between the two formats, the same tag number is a different packet depending on bit 6. A parser that reads bits 5–0 unconditionally will misinterpret every legacy packet: a legacy header 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 octetFormLength computationRange
0 – 191One-octetlen = octet[0]0 – 191
192 – 223Two-octetlen = ((o[0] − 192) << 8) + o[1] + 192192 – 8383
224 – 254Partial bodylen = 1 << (o[0] & 0x1F)1 – 1073741824 (powers of 2)
255Five-octetlen = o[1..4] big-endian0 – 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.
The classic resource-exhaustion bug. A parser that allocates a buffer sized to the declared length before reading is trivially DoS-able: a 5-octet length of 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.

TagPacketRole · covered in
0ReservedMUST NOT be used. A zero tag is a strong malformed-input signal.
1Public-Key Encrypted Session Key (PKESK)Session key wrapped to a recipient key · Article 3
2SignatureAll signature types · Article 4
3Symmetric-Key Encrypted Session Key (SKESK)Session key wrapped by a passphrase · Articles 2, 3
4One-Pass SignatureLets a verifier hash in one pass · Article 4
5Secret-KeyPrimary private key · Article 2
6Public-KeyPrimary public key · Article 2
7Secret-SubkeySubordinate private key · Article 2
8Compressed DataZIP / ZLIB / BZip2 container
9Symmetrically Encrypted Data (SED)Deprecated, unauthenticated. MUST NOT be generated; see Article 3.
10MarkerLegacy no-op containing "PGP". Ignore it.
11Literal DataThe actual payload plus filename/timestamp metadata
12TrustLocal keyring state only. MUST NOT be exported.
13User IDUTF-8 identity string, conventionally Name <email> · Article 5
14Public-SubkeySubordinate public key · Article 2
17User AttributeStructured identity, in practice a JPEG image · Article 5
18Sym. Encrypted and Integrity Protected Data (SEIPD)v1 (MDC) and v2 (AEAD) · Article 3
19Modification Detection Code (MDC)Only inside SEIPDv1. Removed conceptually in v2 · Article 3
20Reserved (was AEAD Encrypted Data)The fork point. LibrePGP uses this; RFC 9580 does not · Article 6
21PaddingNew in RFC 9580. Traffic-analysis resistance; content MUST be ignored.
Tag 20 is the single most important interoperability fact in this series. RFC 9580 marks it reserved and does its AEAD work inside SEIPD version 2 (tag 18). LibrePGP defines an entirely separate AEAD Encrypted Data packet at tag 20. A message using tag 20 will not decrypt on a strict RFC 9580 implementation, and vice versa. Article 6 covers the full split.

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.

Why grammar enforcement is a security control. A stream like [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 MESSAGE closed by END PGP PUBLIC KEY BLOCK is 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

CheckWhy
Reject first octet with bit 7 clearCheapest possible malformed-input rejection
Branch on bit 6 before extracting the tagLegacy and OpenPGP formats use different tag widths
Enforce a total input ceiling, streaming5-octet lengths and unbounded partial chunks are both DoS vectors
Enforce partial lengths only on streamable packetsPrevents 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 lengthsPrevents field-offset divergence between implementations
Enforce the message grammar, not just packet syntaxPrevents scope-confusion and status-confusion classes
Bound recursion depth for Compressed and SEIPD nestingA packet whose body decompresses to another compressed packet, repeatedly, is a zip bomb
Require armor header and tail types to matchPrevents object-type confusion
Treat armor headers as untrusted display dataAttacker-controlled text; escape before rendering or logging
Refuse to emit legacy format or indeterminate lengthsReduces 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

← browse the archive next: Keys & S2K →
© cvam — written in plaintext, served warm