Skip to content

Why this exists when go-crypto already does OpenPGP

The honest question about this package is not "how does it work" but "why does it exist at all". There is a maintained Go OpenPGP implementation — ProtonMail/go-crypto — with 479 known importers, including go-git, Terraform, OpenTofu, Flux and most of the release-signing tooling in the Go ecosystem. Writing cryptographic packet code that already exists somewhere else is close to the worst default a project can pick.

This page is the argument, including the parts that did not survive contact with the evidence.

The short version

We tried to delete this package, and could not.

Not "decided not to" — actually deleted it, replaced it with go-crypto, ran the tests green, and then put it back when the reason it existed turned out to be real. The reason is narrower than the original justification claimed, and it is written down here so nobody has to rediscover it.

How OpenPGP in Go got here

golang.org/x/crypto/openpgp was frozen and deprecated in March 2021. The proposal is worth reading in full; the operative parts are:

The golang.org/x/crypto/openpgp package is effectively unmaintained. […] x/crypto/openpgp is the crypto package with the most open CLs.

and

If you are required to interoperate with OpenPGP systems and need a maintained package, we suggest considering a maintained community fork of golang.org/x/crypto/openpgp. We don't endorse any specific one.

The Go team had already tried to recruit a community maintainer in 2019 (#30141) and none appeared. Freezing the package was, explicitly, a way to stop "stifling the opportunity for the community to converge on a third-party fork".

Proton had already forked in 2019, because they needed OpenPGP for Proton Mail and upstream had stalled. The other serious fork, keybase/go-crypto, was archived in 2021. So the ecosystem converged on go-crypto by attrition rather than by design: it is a vendor library that everyone else defaulted to, and it is maintained for Proton's product first. That is not a criticism — it is an accurate description of its incentives, and it explains what it does and does not prioritise.

The justification that failed

The original claim was that a KMS-backed certificate could not be assembled with go-crypto, because assembly needs a private key and ours is unreachable.

That was wrong. go-crypto's packet.PrivateKey accepts a crypto.Signer in place of a private key, and a spike confirmed it end to end: a full certificate, both signatures made through a signer that never exposed key material, accepted by an independent parser. Roughly 400 lines of hand-written packet, signature and MPI code were therefore avoidable — including a real bug, where an MPI header was written before leading zero octets were stripped, corrupting about one certificate in 128.

So that code was deleted. The claim did not hold, and a claim that does not hold is not a justification.

The justification that held

Two things could not be delegated, and both were verified rather than assumed.

ECDH decryption has no seam

go-crypto's encrypted_key.go hard-asserts *ecdh.PrivateKey when decrypting a PKESK packet. There is no interface to implement, no crypto.Decrypter equivalent for ECDH, and no way to supply a shared secret computed elsewhere. A key that lives in a KMS cannot be used, at all.

This is not an oversight nobody noticed. It is an open request from April 2026, where a maintainer's response is instructive:

For RSA decryption with a hardware token, it's possible to set a crypto.Decrypter as the PrivateKey […] I would prefer to do the same for ECDH.

Agreed in principle, reviewed within a day — and then silent for four months including an unanswered follow-up. A related PKCS#11 request was declared out of scope outright. That is not neglect; Proton Mail holds its users' keys locally under a passphrase, so externally-held keys are simply not their problem to solve.

The dependency footprint is the actual product

This module's build graph contains nothing but the standard library, and a test asserts it rather than trusting anyone to remember.

That is not tidiness. Pulling go-crypto in for the packet work costs golang.org/x/crypto, github.com/cloudflare/circl and their transitive closure — and when that dependency was briefly added during the experiment above, it brought 18 CVEs that had to be resolved by raising pins. A consumer whose only need is "decrypt a message addressed to my KMS key" would inherit all of it.

The split exists so that the arithmetic — the RFC 6637 key derivation, the RFC 3394 unwrap, the fingerprint — can be depended on without inheriting a cloud SDK, a post-quantum curve library, or anyone else's release cadence.

So why is assembly back?

Because assembly and decryption are one problem, not two. The KDF binds the recipient's fingerprint into its Param block, so a certificate assembled by one implementation and a message decrypted by another must agree on that value exactly — see the fingerprint binding.

Putting assembly in a different module from decryption meant neither half could be tested without the other, and the acceptance test that matters — encrypt to a certificate we assembled, and open it — had to live in a third place and drag both in. Keeping them together costs about 300 lines and buys a test that runs on every commit with no credentials.

The alternative was a module that depends on go-crypto for assembly, which forfeits the footprint guarantee above for the half of the problem that go-crypto can do.

What we took from go-crypto instead

Reading an implementation is not the same as depending on it. Several protections here exist because go-crypto does them and we did not:

  • A randomised salt in every signature, carried as the notation OpenPGP.js originated. It denies an attacker control of the exact hashed bytes, and stops a signing fault being replayable against an identical second signature.
  • A Features subpacket, so a sender uses integrity-protected encryption rather than the malleable legacy packet.
  • Algorithm preferences, strongest first.

The last is measurable: remove that subpacket and GnuPG 2.4.4 falls back to AES-128. See what a certificate claims.

How we know this is right

go-crypto is a test oracle here, not a dependency: every certificate this package assembles is parsed and verified by it, and the session keys it derives are checked against it across three curves.

But two implementations that agree can still both be wrong, especially when one was written by reading the other. So the tests also drive real GnuPG 2.4.4 — importing certificates, checking signatures with --check-sigs, and decrypting messages gpg composed. gpg is what a security researcher actually has installed, and it is what decides whether a published certificate is usable in practice.

Three-way agreement between this package, go-crypto and gpg is the standard. Where gpg dissents, gpg wins.

That approach found something: go-crypto's Signature.Sign discards the error returned by a crypto.Signer, because the RSA branch shadows the named return with :=. A key service outage is reported as success, surfacing later as an unrelated complaint about call ordering. It was confirmed on v1.4.1 and on main, with the ECDSA branch of the same switch as a control showing the correct form, and it traces to a 2018 refactor. This is not an argument that go-crypto is bad code — it is an argument that an oracle you never cross-check is not an oracle.

When you should not use this

Reach for go-crypto, not this, if you want to:

  • encrypt a message — this package only recovers session keys;
  • decrypt the message body — that is a full OpenPGP concern, and sigillum uses go-crypto for exactly that;
  • use X25519 or X448 (algorithms 25 and 26) — different derivation, not implemented here, and no KMS offers those curves for key agreement anyway;
  • do general OpenPGP — key generation, revocation, web of trust, v6 keys, AEAD. None of that is here and none of it is planned.

Use this when the private key is somewhere you cannot read it, the curve is a NIST one, and you would rather not take on a dependency tree to do arithmetic over public data.

See when to use this for the decision in table form.