Skip to content

What a certificate claims, and why each claim is there

An OpenPGP certificate is not just a public key. It is a set of statements the key makes about itself, each carried in a signature subpacket, and each one changes how a sender behaves.

The interesting thing about these statements is that omitting one is not an error. A certificate with no algorithm preferences parses, verifies, imports and works — it just quietly receives weaker messages forever, with nothing anywhere to say so. That failure mode is why this page exists.

The two signatures

A certificate assembled here carries exactly two signatures, both made by the primary key through the key service:

  • a positive certification (type 0x13) over the primary and the user ID — the key asserting that this identity is its own;
  • a subkey binding (type 0x18) over the primary and then the encryption subkey, in that order — the primary adopting the subkey.

The ordering in the binding is load-bearing: it is what ties the subkey to this certificate and no other. A binding computed over the subkey alone would verify against any primary that cared to claim it.

What each subpacket does

Subpacket Where Why it is there
Signature creation time hashed Required by RFC 9580; a signature without one is rejected by anything that checks
Issuer fingerprint hashed Identifies the signing key unambiguously
Key flags hashed Says what each key may do — certify/sign on the primary, encrypt on the subkey
Preferred ciphers hashed Stops the sender falling back to something weaker
Preferred hashes hashed Same, for the digest
Features hashed Asks for integrity-protected encryption
Salt notation hashed Randomises the digest
Issuer key ID unhashed A hint for finding the key — not a claim

The hashed/unhashed split is the part worth internalising. Anything in the hashed area is covered by the signature and is therefore a claim the signature makes. Anything unhashed can be changed by anyone without invalidating the signature, so it must never be trusted for a decision. The issuer key ID is unhashed because it is a 64-bit lookup hint; the issuer fingerprint is hashed because it is an assertion.

Algorithm preferences: the measurable one

RFC 9580 says an implementation may assume TripleDES when a certificate states no preference. GnuPG is kinder than the specification and falls back to AES-128.

Neither is what this key can do, and neither would ever be noticed — this package decrypts AES-128 and TripleDES perfectly well, so every message would simply arrive weaker than necessary with no error, no warning and no log line.

So the preferences are stated, strongest first, and the test that proves it does not check that we can parse our own output. It runs real GnuPG, has it encrypt to the certificate, and asserts which cipher gpg chose:

with the preferred-ciphers subpacket:    algorithm 9  (AES-256)
with the subpacket removed:              algorithm 7  (AES-128)

That second line is measured, not assumed — it comes from deleting the subpacket and re-running. A test that cannot fail is decoration, and this one was checked by making it fail.

Features: asking for integrity protection

The Features subpacket advertises that the certificate's holder understands integrity-protected encryption (SEIPD).

Without it, a sender is entitled to fall back to the legacy Symmetrically Encrypted Data packet, which carries no integrity check at all and is malleable — an attacker who can modify the ciphertext can make predictable changes to the plaintext. For a channel whose entire purpose is receiving vulnerability reports from strangers, accepting a malleable format would be an odd choice.

The salt: defence in depth, honestly labelled

Every signature carries a random salt, in the notation OpenPGP.js originated and go-crypto emits by default. A v6 signature has a dedicated field for this; v4 — which is what this package writes, because that is the version whose fingerprint SessionKey can compute — does not, so the notation is the interoperable place to put one.

It buys two things, neither of which is a live break:

  • an attacker cannot control the exact bytes hashed, which is the precondition for a chosen-prefix collision;
  • a fault during signing — a bitflip in an HSM mid-operation — cannot be replayed against a second signature over identical input.

Both are cheap to prevent and expensive to retrofit. The two signatures get independent salts; reusing one would leave them sharing exactly what the salt was drawn to deny.

What the salt costs

Assembly is no longer byte-reproducible. Assembling the same inputs twice produces different octets.

What it does not change is the recipient: fingerprints are hashed over the public-key packets alone, so a salted and an unsalted assembly of the same keys accept exactly the same messages. That distinction is asserted rather than assumed — one test checks the octets differ, and that the fingerprints do not.

If byte-reproducibility is a hard requirement — a reproducible build, or a published certificate that must not appear to have changed when regenerated — WithoutSignatureSalt() turns it off, and says in its own documentation what you are giving up.

What is deliberately absent

  • Key expiry. A certificate that expires is a certificate that stops receiving reports on a date nobody diarised. Rotation here means publishing a new certificate, which is a decision for whoever operates the address.
  • Compression preferences. Not a security control in this threat model — there is no adaptive attacker to feed a compression oracle — and stating nothing lets the sender pick.
  • Revocation certificates. Producing one needs the primary key, which is a key-service operation and an operational procedure, not an assembly-time artefact.