A consumer of go-tool-base asked me where their signing key was supposed to live, and the answer was: that’s deliberately not my call. There’s a deliberate hole in go-tool-base’s signing, and it’s the most important part of the design. The framework will sign your releases, mint you an OpenPGP key, produce a detached signature a consumer can verify. What it will not do is have an opinion about where your private key actually lives. It genuinely doesn’t know, and that’s on purpose.
Here’s why that hole has to be there. Of all the decisions wrapped up in “sign your releases,” the one that varies most wildly between one shop and the next is where the signing key is kept. One team has standardised on AWS KMS and everything has to go through it. The next is a Google shop and it’s GCP KMS or nothing. A third runs Vault. A fourth, the security-conscious solo dev, wants the private half sitting on a YubiKey in a drawer and never anywhere else. These aren’t preferences you can talk someone out of, they’re usually a compliance boundary or an existing investment, and they’re load-bearing.
So if the framework hard-codes one of them, say it bakes in AWS KMS, then every shop that isn’t an AWS shop has to fork it to sign anything. And forking a framework to change one provider integration is the worst of all worlds: you inherit the maintenance, you fall off the upgrade path, and you’ve taken a clean dependency and turned it into a patched copy you now own. The place your users diverge most is the exact place a framework must not be opinionated.
The way out is an interface Go hands you for free: crypto.Signer. If you don’t write Go daily, the idea is simple and worth a sentence. crypto.Signer is the standard library’s name for “a thing that can sign,” and the trick is what it deliberately leaves out. It exposes a public key and a single Sign method, and it says nothing whatsoever about where the private key is or how the signing happens. The same interface covers a key sitting in memory and a key in a hardware module the program can never read, because from the caller’s side both look identical: you hand over a digest, you get back a signature, and you are never given the private key. It’s the same abstraction the standard library’s own TLS stack uses to serve HTTPS from an HSM-held key.
That’s what the whole design hangs on. The actual signing code, the part in pkg/openpgpkey that mints OpenPGP signatures, asks for a crypto.Signer and uses it. It has no idea, and no way to find out, whether the signer it’s holding talks to a local key, fires an API call at KMS, or lights up a YubiKey. It just signs. The dangerous, provider-specific bit and the boring, universal bit have been cleanly separated, and the only thing passing between them is “here’s something that can sign.”
Which leaves the question of how the right signer gets chosen, and that’s a small registry. A backend implements one method, NewSigner(ctx, keyID) (crypto.Signer, error), hands back a signer for whatever provider it speaks, and registers itself at startup, the usual Go pattern where a package’s init() calls Register and you pull the backend in with a blank import. gtb ships with two compiled in, a local one and an AWS KMS one. If your shop is the GCP or the Vault or the YubiKey case, you write a backend that satisfies that one interface, blank-import it, and you’re signing through it, without editing a line of framework code. You add a provider, you don’t fork a framework.
A couple of details fall out of doing it this way that I like more than I expected to. Backends don’t own credential resolution, the caller configures how you authenticate to the provider and the backend just turns a key ID into a signer, so the credential story stays where it belongs and the backend stays tiny. And because backends are compiled in rather than discovered at runtime, a binary built with none of them fails loudly and specifically, telling you it “was built without any signing backends compiled in” rather than mysteriously refusing to sign. You only carry the providers you actually asked for.
This sits underneath the more concrete signing stories, the one about a signature the platform that hosts you can’t forge and the one about why key generation moved into the tool in the first place. Those are about what signing buys you and why it has to be painless. This is the quieter architectural decision sitting beneath both: pick the one part of the problem that’s genuinely different at every org, refuse to decide it for them, and put a single clean interface where the decision would have gone. The framework signs everything the same way. It just never asks where the key sleeps at night.
