OpenPGP Internals — An Implementer's Series

Signatures

Article 4 of 7 · Subpackets, canonicalization, SigSpoof

Sep 9, 2026 · security · 15 min read · 2900 words advanced

Signatures: subpackets, canonicalization, and the verification interface.

security openpgp pgp digital-signatures cryptography

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.

Prerequisites. Article 1 for packet framing and message grammar (essential — several attacks here are grammar failures), Article 2 for key structure and binding signatures.

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.

Why the trailer exists. Without a length marker at the end, an attacker could potentially extend the hashed subpacket area and have a verifier compute the same hash over a different split of the same bytes — a length-extension-flavored ambiguity. The trailer commits to exactly how many octets of signature metadata were hashed, making the boundary unambiguous. Every "my signatures verify locally but not against other implementations" bug I have seen traces to either the trailer or subpacket area length handling.

3. The hashed / unhashed split

This is the single most security-relevant structural fact about OpenPGP signatures.

Half the signature packet is not signed HASHED subpackets creation time key expiration algorithm preferences issuer fingerprint (v6: required) ✓ covered by the signature UNHASHED subpackets issuer key ID (v4, commonly) embedded signature (back sig) arbitrary attacker-added data ✗ NOT covered — freely modifiable by anyone, in transit, silently rule: unhashed data is a routing hint, never an authorization the one exception — embedded back signatures — is safe only because it is itself a signature that gets independently verified

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:

TypeSubpacketPlacementNotes
2Signature Creation TimeHashed (required)Only meaningful because it is hashed
3Signature Expiration TimeHashedRelative to creation time
9Key Expiration TimeHashedIn self-signatures
11 / 21 / 22 / 34Preferred symmetric / hash / compression / AEADHashedIgnoring these breaks interop and can enable downgrades
16Issuer Key IDUsually unhashedA hint. Never a trust input.
32Embedded SignatureUnhashedBack signatures (Article 2). Safe because independently verified.
33Issuer FingerprintHashed in v6 (required)The v6 fix for issuer ambiguity
7RevocableHashedRarely used, occasionally surprising
31Signature TargetHashedBinds 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.

TypeMeaningCovers
0x00Binary documentData exactly as-is
0x01Canonical text documentData with line endings normalized to CRLF — see §6
0x02StandaloneNothing. Carries only subpacket assertions.
0x100x13Certifications (generic → positive)Key + User ID binding · Article 5
0x18Subkey bindingPrimary key + subkey
0x19Primary key binding (back sig)Primary key + subkey, signed by subkey
0x1FDirect key signatureThe key itself, no User ID
0x20Key revocationThe primary key
0x28Subkey revocationA subkey
0x30Certification revocationA prior certification
0x40TimestampThird-party timestamping
0x50Third-party confirmationAnother signature
Type confusion is exploitable. A signature of type 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:

  1. 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.
  2. Trailing whitespace is stripped from every line before hashing. Mail systems mangle trailing whitespace freely, so it cannot be part of the signed content.
  3. Line endings are canonicalized to CRLF for hashing, regardless of what is on the wire.
  4. The final line ending is not hashed.
The trap: what the user sees and what was signed are different byte strings, and the transformation between them is lossy. Trailing whitespace differences are invisible to a reader and stripped before hashing — meaning two visually distinct documents can produce the same signature. If a signature is being used to attest to an exact document, use a binary (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:

  1. 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.
  2. 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.

SigSpoof: injecting into the answer channel Literal Data filename attacker-controlled, unsigned gpg --verbose prints filename unescaped stderr == status-fd 2 two channels, one pipe application parses "[GNUPG:] GOODSIG ..." → reports valid signature no keys involved · no signature present · no cryptography attacked 20 years 1998 → 2018

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:

FailureWhat goes wrong
Scope confusionSignature is valid, but over a different part of the message than what is displayed
Partial verificationSome packets signed, some not; UI reports "signed" for the whole thing
Result-shape misuseAPI returns a promise/array of results; caller checks truthiness rather than each result's verified status
Identity substitutionSignature verifies against some key in the keyring, not the expected one
Time-of-check gapsSignature valid at verification, but key was revoked or expired at signing time
The correct verification predicate is not "did this verify" but: a signature of the expected type, made by a specific expected key, over exactly the data I am about to act on, where that key was valid and unrevoked at the signature's creation time, and where every packet I am treating as signed is actually inside the signed scope. If your API cannot express that, it is the wrong API.

9. Implementation checklist

RulePrevents
Never trust unhashed subpackets for any decisionSilent metadata forgery
Honor the critical bit; reject signatures with unknown critical subpacketsSilently dropping constraints
Verify signature type matches the operation being performedType confusion between certifications and document signatures
Require an issuer fingerprint (v6) or verify against a specific expected keyIdentity substitution
Check key validity at signature creation time, not verification timeAccepting signatures made after revocation
Escape all attacker-controlled strings before printing or loggingSigSpoof-class injection
Never merge status output with diagnostic outputSigSpoof directly
Use a library API, not CLI output parsingThe entire parse-the-output bug class
Return structured results; never a bare booleanScope and identity confusion
Validate OPS/signature pairing structurallyNested-signature scope ambiguity
Prefer detached binary signatures for exact-document attestationCleartext canonicalization surprises
Reject signature packets whose hashed area length exceeds the packetParser 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

← prev: Encryption & AEAD next: Trust & Distribution →
© cvam — written in plaintext, served warm