OpenPGP signature verification has failed in production for twenty years, and almost never because the mathematics failed. It failed because half a signature packet is not covered by the signature, because "what exactly did you sign" is genuinely ambiguous for cleartext messages, and because the interface an application uses to ask whether a signature verified was itself forgeable. This article covers the packet structure, the hash-input construction, the hashed/unhashed split, the cleartext canonicalization traps, and SigSpoof — a bug that spoofed signature verification for two decades without touching a single key.
1. Signature packet anatomy
A version 4 signature packet, field by field:
04 # version 4 00 # signature type (0x00 = binary document) 1B # public-key algorithm (27 = Ed25519) 08 # hash algorithm (8 = SHA-256) 00 1A # length of HASHED subpacket area (26 octets) .. hashed subpackets .. # ← COVERED by the signature 00 0A # length of UNHASHED subpacket area (10 octets) .. unhashed subpackets .. # ← NOT covered. Attacker-controlled. A3 F1 # left 16 bits of the hash — a quick reject check only .. signature value (native encoding for Ed25519, MPIs for RSA) ..
Version 6 differs in three ways: subpacket area lengths are 4 octets instead of 2, the issuer fingerprint subpacket is mandatory, and a salt is included before the hash computation.
1.1 Why v6 signatures are salted
A v4 signature over a fixed message is deterministic in its hash input, which makes the hash a target for offline collision search: an attacker who can find two messages hashing to the same value can transplant a signature from one to the other. v6 prepends a random salt (16–32 octets depending on hash size) to the hash input, so the attacker cannot know the hash input in advance. This converts an offline collision attack into an online one, which is dramatically harder — the same reasoning behind randomized hashing in other signature schemes. It is cheap insurance against a future hash weakness rather than a response to a current break.
2. What actually gets hashed
This is the part implementations get wrong. The signature does not cover just the document. It covers the document plus a reconstruction of the signature packet's own metadata, plus a trailer:
\[ \text{hash input} = \underbrace{\text{salt}}_{\text{v6 only}} \,\|\, \text{data} \,\|\, \underbrace{\text{sig prefix}}_{\text{v,type,alg,hash,hashed-area}} \,\|\, \underbrace{\text{trailer}}_{\text{v} \| \texttt{0xFF} \| \text{len}} \]The trailer for v4 is 0x04 0xFF followed by a 4-octet big-endian length of the hashed portion of the signature packet. For v6 it is 0x06 0xFF followed by a 4-octet length.
3. The hashed / unhashed split
This is the single most security-relevant structural fact about OpenPGP signatures.
Fig. 1 — Anyone can add, remove, or alter unhashed subpackets without invalidating the signature. This is by design, and it is a permanent trap for implementers.
Key subpacket types worth knowing:
| Type | Subpacket | Placement | Notes |
|---|---|---|---|
| 2 | Signature Creation Time | Hashed (required) | Only meaningful because it is hashed |
| 3 | Signature Expiration Time | Hashed | Relative to creation time |
| 9 | Key Expiration Time | Hashed | In self-signatures |
| 11 / 21 / 22 / 34 | Preferred symmetric / hash / compression / AEAD | Hashed | Ignoring these breaks interop and can enable downgrades |
| 16 | Issuer Key ID | Usually unhashed | A hint. Never a trust input. |
| 32 | Embedded Signature | Unhashed | Back signatures (Article 2). Safe because independently verified. |
| 33 | Issuer Fingerprint | Hashed in v6 (required) | The v6 fix for issuer ambiguity |
| 7 | Revocable | Hashed | Rarely used, occasionally surprising |
| 31 | Signature Target | Hashed | Binds a signature to another specific signature |
3.1 The critical bit
Every subpacket type octet carries a high bit: if set, the subpacket is critical and an implementation that does not understand it MUST reject the entire signature. If clear, unknown subpackets may be ignored.
This is a genuine extensibility mechanism and a genuine hazard. An implementation that ignores the critical bit will accept signatures whose semantics it does not understand — for example, a signature carrying a critical constraint subpacket limiting what it authorizes, which the verifier silently drops. Honor the bit.
4. Signature types
The type octet determines what the signature means. Conflating types is a real attack surface.
| Type | Meaning | Covers |
|---|---|---|
0x00 | Binary document | Data exactly as-is |
0x01 | Canonical text document | Data with line endings normalized to CRLF — see §6 |
0x02 | Standalone | Nothing. Carries only subpacket assertions. |
0x10–0x13 | Certifications (generic → positive) | Key + User ID binding · Article 5 |
0x18 | Subkey binding | Primary key + subkey |
0x19 | Primary key binding (back sig) | Primary key + subkey, signed by subkey |
0x1F | Direct key signature | The key itself, no User ID |
0x20 | Key revocation | The primary key |
0x28 | Subkey revocation | A subkey |
0x30 | Certification revocation | A prior certification |
0x40 | Timestamp | Third-party timestamping |
0x50 | Third-party confirmation | Another signature |
0x00 over some bytes and a signature of type 0x13 over a User ID are computed over different structures — but a verifier that checks "is this signature cryptographically valid over this data" without also checking "is this the right type for the question I am asking" can be tricked into accepting a certification as a document signature or vice versa. Always verify type against context, and reject anything that does not match what you asked for.5. One-pass signatures
A signature packet appears after the data it signs — which is a problem for streaming verification, because the verifier does not know which hash algorithm to use until it has already consumed the data. The One-Pass Signature packet (tag 4) is the fix: it appears before the data, announcing the algorithm, type, and issuer, so a verifier can initialize its hash context and stream.
[ One-Pass Sig (tag 4) ] ← announces: type, hash alg, issuer
[ Literal Data (tag 11) ] ← the payload, hashed as it streams
[ Signature (tag 2) ] ← the actual signature value
# Nested signatures reverse: OPS packets stack, signatures unstack.
[ OPS A ][ OPS B ][ Literal ][ Sig B ][ Sig A ]
The nesting flag in the OPS packet indicates whether another OPS follows. Two implementation requirements: the OPS packet's announced parameters are unauthenticated until the matching signature verifies, so they are hints — and the OPS/signature pairing must be structurally validated. A stream with two OPS packets and one signature is malformed, and accepting it invites confusion about which data was actually signed by whom.
6. Cleartext signatures: the canonicalization minefield
Cleartext Signed Messages keep the text human-readable with the signature appended. They are convenient, ubiquitous, and the most bug-prone construct in the format.
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256
# ← blank line ends headers
The quick brown fox.
- This line began with a dash. # ← dash-escaped: "- " prefix added
-----BEGIN PGP SIGNATURE-----
iQIzBAEBCgAdFiEE...
-----END PGP SIGNATURE-----
The canonicalization rules that must be applied before hashing:
- Dash escaping. Lines beginning with
-are prefixed with"- "on output and that prefix is removed on input before hashing. Without this, a line starting with-----BEGIN PGP SIGNATURE-----would terminate the message early. - Trailing whitespace is stripped from every line before hashing. Mail systems mangle trailing whitespace freely, so it cannot be part of the signed content.
- Line endings are canonicalized to CRLF for hashing, regardless of what is on the wire.
- The final line ending is not hashed.
0x00) detached signature, not cleartext signing. Cleartext signing attests to the canonicalized text, and you must be able to state precisely what that means to your users.A second trap specific to cleartext framing: the text between the armor headers and the signature block is not structurally delimited from surrounding content. Applications that extract "the signed part" with a regex — and many do — routinely get the boundaries wrong, allowing content outside the signed region to be displayed as if it were inside. Parse with the spec's rules, not with pattern matching.
7. SigSpoof: forging the verification result
This is the most instructive OpenPGP vulnerability, because the cryptography was never involved. CVE-2018-12020, found by Marcus Brinkmann in 2018, had been present since GnuPG 0.2.2 in 1998 — twenty years.
7.1 The mechanism
GnuPG is a command-line program. Applications drive it and parse its output. Machine-readable results are emitted as status lines on a file descriptor specified by --status-fd:
[GNUPG:] GOODSIG 4F9F89F5505AC1D1 Alice <alice@example.org> [GNUPG:] VALIDSIG 96AF...C1D1 2018-06-08 1528473600 0 4 0 1 8 00 96AF...C1D1 [GNUPG:] TRUST_ULTIMATE
Two design decisions combined catastrophically:
- Many applications invoked GnuPG with
--status-fd 2, merging the status channel with stderr — so human-readable diagnostics and machine-parsed status shared one stream. - In verbose mode, GnuPG printed the filename from the Literal Data packet to stderr without escaping newlines.
The filename field in a Literal Data packet is attacker-controlled, arbitrary-length, and never covered by any signature. So an attacker sets it to:
innocent.txt\n[GNUPG:] GOODSIG DEADBEEFDEADBEEF Alice <alice@example.org>\n[GNUPG:] VALIDSIG ...\n[GNUPG:] TRUST_ULTIMATE\n
GnuPG dutifully prints the filename to stderr. The application reads what it believes is the status stream and sees a perfectly-formed GOODSIG line. The attacker needs no keys — public or private — and the message need not contain a signature at all. Key IDs, algorithm identifiers, creation times, and user IDs in the forged status are all attacker-chosen.
Fig. 2 — The verification was correct. The channel reporting the verification was forgeable. Affected GnuPG ≤ 2.2.7, Enigmail ≤ 2.0.6.1, GPGTools ≤ 2018.2, python-gnupg ≤ 0.4.2.
7.2 The generalizable lessons
- Never multiplex a machine-readable result channel with a human-readable one. If status and diagnostics share a pipe, the diagnostics become an injection vector into the status.
- Escape attacker-controlled data before printing it anywhere, including logs. The Literal Data filename, User ID strings, armor headers, and notation values are all attacker-controlled text in OpenPGP.
- Prefer a library over subprocess parsing. Sequoia, RNP, OpenPGP.js and GopenPGP return structured results, eliminating the parse step entirely. The single most effective mitigation for this entire bug class is not shelling out to a CLI.
- Verification returns a structure, not a boolean. "Valid" is meaningless without which key, which data, which type, and under what policy.
8. Modern signature-verification bugs
The class did not end in 2018. CVE-2025-47934 in OpenPGP.js allowed spoofing of signed and encrypted messages, by exploiting the gap between what an application asks ("is this message signed by X?") and what the library's API actually answers. The recurring shape:
| Failure | What goes wrong |
|---|---|
| Scope confusion | Signature is valid, but over a different part of the message than what is displayed |
| Partial verification | Some packets signed, some not; UI reports "signed" for the whole thing |
| Result-shape misuse | API returns a promise/array of results; caller checks truthiness rather than each result's verified status |
| Identity substitution | Signature verifies against some key in the keyring, not the expected one |
| Time-of-check gaps | Signature valid at verification, but key was revoked or expired at signing time |
9. Implementation checklist
| Rule | Prevents |
|---|---|
| Never trust unhashed subpackets for any decision | Silent metadata forgery |
| Honor the critical bit; reject signatures with unknown critical subpackets | Silently dropping constraints |
| Verify signature type matches the operation being performed | Type confusion between certifications and document signatures |
| Require an issuer fingerprint (v6) or verify against a specific expected key | Identity substitution |
| Check key validity at signature creation time, not verification time | Accepting signatures made after revocation |
| Escape all attacker-controlled strings before printing or logging | SigSpoof-class injection |
| Never merge status output with diagnostic output | SigSpoof directly |
| Use a library API, not CLI output parsing | The entire parse-the-output bug class |
| Return structured results; never a bare boolean | Scope and identity confusion |
| Validate OPS/signature pairing structurally | Nested-signature scope ambiguity |
| Prefer detached binary signatures for exact-document attestation | Cleartext canonicalization surprises |
| Reject signature packets whose hashed area length exceeds the packet | Parser desync and trailer miscomputation |
FAQ
Why is the issuer key ID unhashed in v4 signatures?
Historical pragmatism — it was treated as a lookup hint for finding the right key, not as an assertion. The problem is that implementations then used it as though it were authenticated. v6 fixes this by mandating a hashed issuer fingerprint. On v4, the correct approach is to treat the key ID purely as a keyring index and let the cryptographic verification determine the actual signer.
Are cleartext signatures ever the right choice?
For human-facing announcements where readability matters and the exact byte content does not — release announcements, mailing list posts. For anything where the precise document matters — software artifacts, legal text, anything a machine will act on — use a detached binary signature. The canonicalization is a feature for the first case and a liability for the second.
Does the left-16-bits-of-hash field do anything useful?
It is a fast reject: if those two octets do not match the computed hash, the signature cannot be valid, so you skip the expensive verification. It provides no security — an attacker can trivially make it match. Never treat matching quick-check bits as any kind of positive signal.
How should I handle a signature from a key that has since been revoked?
It depends on the revocation reason, which is why the reason code exists. "Key superseded" or "no longer used" means signatures made before revocation remain valid. "Key compromised" means they do not, because the attacker may have made them. An implementation that treats all revocations identically is wrong in one direction or the other.
Takeaways
- Half the signature packet is unsigned. Unhashed subpackets are attacker-modifiable in transit and must never inform a decision.
- The hash input includes reconstructed metadata and a trailer, not just the document — the source of most cross-implementation verification mismatches.
- Signature type is part of the meaning. Verifying cryptographic validity without checking type is incomplete verification.
- SigSpoof lived for twenty years in the interface, not the mathematics — the strongest argument in this series for using structured library APIs over CLI output parsing.
- Cleartext signing attests to canonicalized text, which is not the bytes the user sees. Know which one you are promising.
- "Valid signature" is not a boolean; it is a claim about a specific key, over specific data, of a specific type, at a specific time.
Next in this series
Article 5 — Trust and key distribution: certification semantics and the Web of Trust's actual algorithm, why the SKS keyserver network collapsed under certificate flooding, and the modern replacements — WKD, verifying keyservers, and key transparency.
References
- RFC 9580 §5.2 — Signature Packet — normative structure, subpacket types, hash input and trailer construction.
- CVE-2018-12020 — SigSpoof — Marcus Brinkmann's status-line injection; affected GnuPG ≤ 2.2.7 and downstream tooling.
- SigSpoof overview — useful summary of the affected-version matrix across GnuPG, Enigmail, GPGTools, python-gnupg.
- CVE-2025-47934 — OpenPGP.js message spoofing — the modern instance of the same verification-interface bug class.
- draft-gallagher-openpgp-signatures — OpenPGP Signatures and Signed Messages — clarifying draft on signature semantics and message structure; useful where RFC 9580 is terse.
- SoK: Why Johnny Can't Fix PGP Standardization — situates the verification-interface problems as standardization failures rather than isolated bugs.