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.Decrypteras thePrivateKey[…] 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.