DEV Community

Cover image for My reason for hand-rolling it was wrong
Matt Cockayne
Matt Cockayne

Posted on Originally published at phpboyscout.uk

My reason for hand-rolling it was wrong

One evening I deleted four hundred lines of my own cryptographic packet code, dropped someone else's library in its place, and ran the tests. They passed... which wasn't really the result I was hoping for.

Writing your own is the worst default there is

I'll start with the case against me, because it's a pretty strong one. There's already a maintained OpenPGP implementation for Go. ProtonMail/go-crypto has something like 479 known importers, including go-git, Terraform, OpenTofu, Flux and most of the release-signing tooling in the ecosystem. If you need OpenPGP in Go, that's the answer, and writing your own packet, signature and MPI code instead is close to the worst default a project can pick.

I'd written my own anyway, in go/encryption, and I thought I had a good reason. Our private key lives in a KMS, so it can't be read, exported or handed to a library. Assembling an OpenPGP certificate needs signatures made with that key, and my claim was that go-crypto couldn't do that, because it wants a private key and I haven't got one to give it. It sounded reasonable, and I'd written it down in my own docs with a fair bit of confidence... it was also wrong!

Test it by deleting it

So rather than argue with myself about it, I deleted the package. Properly gone (not "decided not to use it"), with go-crypto wired in its place, and then I ran the acceptance tests to see what broke.

As far as I could tell, nothing did.

It turns out packet.PrivateKey will take a crypto.Signer in place of an actual key. That means a signature can be made by something that never hands over any key material, which is more or less the definition of a KMS. A spike carried it end to end: a full certificate, both signatures made through a signer, accepted by an independent parser. So the four hundred lines went, and with them the story I'd been telling myself about why I needed them.

There was a bonus humiliation buried in the diff, too. Somewhere in the deleted code sat a real bug of mine: an MPI header written before the leading zero octets were stripped, which corrupted roughly one certificate in every hundred and twenty-eight. A one-in-128 bug, in cryptographic code I'd written because I didn't trust the alternative!

Then two things refused to die

That wasn't the end of it, though, because the package came back. Not all of it, and not for the reason I'd originally given.

The first thing to hold was ECDH decryption, which has nowhere to plug in. go-crypto's encrypted_key.go hard-asserts *ecdh.PrivateKey when it decrypts a PKESK packet (the part of an OpenPGP message that carries the session key, if you want something to google). There's no interface to implement, no crypto.Decrypter equivalent for ECDH, and no way to hand it a shared secret worked out somewhere else. So a key that lives in a KMS can't be used for that at all... not awkwardly, not slowly, just not.

It's an odd asymmetry once you spot it. RSA has an interface for this and ECDH doesn't. Signing was fine because signing had crypto.Signer, and decryption wasn't because the ECDH equivalent hasn't been built yet (and I do think "yet" is the right word, as there's an open request for it).

The second thing was the dependency footprint. go/encryption's build graph contains nothing but the standard library, and a test asserts it so I don't have to remember. Pulling go-crypto in costs golang.org/x/crypto, github.com/cloudflare/circl and everything underneath them, and when it went in during the experiment it brought eighteen CVEs along for the ride, all of which had to be resolved by raising pins. Anyone whose entire need is "decrypt a message addressed to my KMS key" would inherit the lot.

This is not go-crypto's fault

That open request is this one, from April, asking for exactly the ECDH interface I needed. The maintainer's reply was pretty encouraging:

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 and reviewed inside a day... then four months of silence, with a follow-up still unanswered. A related PKCS#11 request was declared out of scope outright.

It's easy to read that as neglect, until you remember what Proton actually is. Proton Mail holds its users' keys locally, under a passphrase. Keys held somewhere else aren't their problem to solve, and turning down hardware-token support for a product that doesn't use hardware tokens is a perfectly sound call about their own roadmap. I think that's what I'd been getting wrong all along. go-crypto isn't really an abandoned library, it's a vendor library that the rest of us happened to default to.

How we all ended up here

The history explains a lot of this, so bear with me for a minute. golang.org/x/crypto/openpgp was frozen and deprecated in March 2021, and the proposal is blunt about why: it was "effectively unmaintained" and "the crypto package with the most open CLs". The Go team had already tried to recruit a community maintainer in 2019 and nobody came forward. The freeze notice suggests using a maintained community fork, and then adds, rather carefully: we don't endorse any specific one.

Proton had already forked it in 2019, because they needed OpenPGP for Proton Mail and upstream had stalled, and the other serious fork (keybase's) was archived in 2021. So the ecosystem landed on go-crypto by attrition, as the one still standing once the others had gone.

The upshot is that a big chunk of Go's release-signing tooling now depends on a fork that's maintained, entirely legitimately, for one company's mail product. I can't really point at a wrong decision anywhere along the way. It's kinda what you'd expect when a language freezes a package and hopes for the best.

Why assembly came back too

I'd have got this bit wrong if I'd stopped at "keep decryption, delegate assembly", because the two turn out to be the same problem. The key-derivation function binds the recipient's fingerprint into its parameter block, so a certificate assembled by one implementation and a message decrypted by another have to agree on that value exactly.

Split them across modules and you can't test one without the other, and the acceptance test that actually matters (encrypt to a certificate we assembled, then open it) ends up living in a third place, dragging both of them in.

Keeping them together costs about three hundred lines and buys a test that runs on every commit with no credentials at all. I'm happy with that trade, and it's a very different argument from the one I started with.

What it looks like now

go-crypto is still in that go.mod, as a test dependency. Every message the package can open, it also opens with go-crypto and checks they both recover the same session key. A second suite does the same against gpg itself, because gpg is what a security researcher is most likely to have on their machine. So the hand-rolled code gets checked against the library I didn't use and the binary pretty much everyone else has.

Four hundred lines out, about three hundred back in, and a reason for them I can actually stand behind this time. I don't think I've ever been quite so put out by a passing test suite.


Originally published at phpboyscout.uk on 20 October 2026.

Top comments (0)