Featured image of post Sign your own binaries with go-tool-base, part 4: mint and publish your public key

Sign your own binaries with go-tool-base, part 4: mint and publish your public key

By the end of Part 3 your release pipeline can sign through a KMS key it never holds, over credentials that expire in minutes. The private half is locked away exactly where you want it. There’s a snag, though: a signature is no use to anyone who can’t get hold of the matching public key, and a KMS key won’t hand you one. KMS deals in raw signing operations, not OpenPGP entities. So this part does two things: it produces the published public key from the KMS key without ever touching the private bytes, then puts that key somewhere your release platform can’t reach.

That last bit is the part people skip, and it’s the part that does the real work. The whole scheme in a signature the platform can’t forge rests on the verifying key living somewhere an attacker can’t poison in the same breath as the release. We’ll come back to why that matters once the key is in hand.

Mint the public key from KMS

gtb keys mint builds an OpenPGP public key out of a signing backend. In Part 1 the backend was a .pem on disk; now it’s aws-kms, and only that flag changes:

gtb keys mint \
    --backend aws-kms \
    --kms-region eu-west-2 \
    --key-id alias/acme-release-signing-v1 \
    --name "Acme Releases" \
    --email release@acme.dev \
    --created "2026-06-02T00:00:00Z" \
    --output signing-key-v1.asc
INFO Minted OpenPGP key backend=aws-kms key_id=alias/acme-release-signing-v1 output=signing-key-v1.asc creation_time=2026-06-02T00:00:00Z fingerprint=...

Here’s the neat part, and it’s worth pausing on. An OpenPGP key isn’t just a lump of public-key material; the key carries a self-signature, a signature it makes over itself to bind the name and email to the key. So minting a public key normally needs the private key to do that signing. mint doesn’t. It wraps the backend in a crypto.Signer, and every signing operation, the self-signature included, becomes a kms:Sign call. KMS does the maths inside the HSM and hands back a signature; no private byte is ever exported, not even to stamp the key’s own identity onto itself. The mechanism is the subject of a signing key that never leaves KMS if you want to see how the signer is wired up.

Two things to get right. First, the AWS credentials: minting needs both kms:GetPublicKey and kms:Sign on the key, because it reads the public material and then signs the self-signature with it. The signer role you stood up in Parts 2 and 3 can do both; running this locally, your own credentials need the same. Second, and this is the same lesson Part 1 hammered on, pin --created. An OpenPGP fingerprint is derived partly from the creation time, so a different timestamp gives you a different fingerprint and, in effect, a different key as far as your tooling is concerned. Use the moment the KMS key was created and never let it drift: the key you embed in Part 5 and the key you publish here have to be byte-identical, and --created is what guarantees it.

Mint the rotation authority while you’re here

You won’t use it until Part 7, but the rotation-authority key is far easier to create now, alongside everything else, than to bolt on in a panic when you need it. It’s a break-glass key: an offline key whose only job is to vouch for a new signing key if the KMS one ever has to be replaced. The spare front-door key you tape to the back of a drawer, not the one on your keyring.

Because it’s break-glass, it isn’t a KMS key. You generate it on a trusted offline machine and the private half goes straight into cold storage:

gtb keys generate \
    --algorithm ed25519 \
    --name "Acme Rotation Authority" \
    --email release@acme.dev \
    --created "2026-06-02T00:00:00Z" \
    --output rotation-authority.asc
INFO Generated OpenPGP keypair algorithm=ed25519 public_output=rotation-authority.asc private_output=rotation-authority.priv.asc creation_time=2026-06-02T00:00:00Z fingerprint=...
WARN Move the private-half file to offline storage now. private_output=rotation-authority.priv.asc

For Ed25519 you get an armored public .asc and an armored secret-key block (.priv.asc, the same wire format gpg --export-secret-keys produces). Do what the warning says, and do it before you forget: move rotation-authority.priv.asc to offline storage immediately. An encrypted USB stick and a paper backup is not paranoid for a key you might not touch for two years and will desperately need when you do.

Notice it carries the same release@acme.dev user ID as the signing key, not a separate rotation@ address. That’s deliberate, and it’s the one place people trip: WKD groups keys by the email in their UID, so the matching address is what puts the rotation authority in the same bucket as the signing key, and into the same embedded trust set. Give it a different email and it quietly drops out of the published bucket, your embedded and WKD key sets stop matching, and the cross-check from Part 5 starts failing every update. Same release identity, two keys. The public half travels with the signing key from here on, published and embedded together.

Build the WKD tree

Now publish them. The way clients find a public key from an email address is the Web Key Directory: a fixed set of files under .well-known/openpgpkey/ on a web server, where each key lives at a path derived by hashing the local-part of its email. gtb keys wkd builds that tree for you, in pure Go, with no gpg or gpg-wks-client anywhere in sight:

gtb keys wkd \
    --domain acme.dev \
    --email release@acme.dev \
    --output ./wkd-staging \
    signing-key-v1.asc rotation-authority.asc
INFO WKD bucket email=release@acme.dev hash=... keys=2
INFO wrote path=wkd-staging/.well-known/openpgpkey/acme.dev/policy
INFO wrote path=wkd-staging/.well-known/openpgpkey/acme.dev/submission-address
INFO wrote path=wkd-staging/.well-known/openpgpkey/acme.dev/hu/...
INFO WKD tree complete output=wkd-staging method=advanced emails=1 files=3

Generating the rotation-authority key and building the WKD tree

Both keys carry release@acme.dev in their UID here, so they land in the same hu/ bucket, concatenated. (If you’d rather split them, give each a different email and pass --email twice.) The tree under ./wkd-staging holds a policy file (required by the spec, and empty), a submission-address file, and one hu/<z-base-32-hash> file per email with the keys inside.

One decision matters for where this gets served. The default --method is advanced, which serves the tree from a dedicated openpgpkey.acme.dev subdomain, which is why the path above has acme.dev nested inside it. Pass --method direct instead and the tree is served from acme.dev itself. Advanced is the modern default and what you want unless you’ve a reason otherwise; it does mean you’ll need DNS and TLS for openpgpkey.acme.dev.

Publish it somewhere the platform can’t reach

This is the bit that’s tempting to fudge, and the bit you mustn’t. Do not drop the WKD tree onto the same host, or under the same account, as your code and your releases.

Walk the attack through. Your binary will carry an embedded copy of the public key (Part 5). On every self-update it fetches the published key over WKD and checks the two agree before trusting a download. That cross-check is the entire defence. If an attacker who compromised your release platform could also rewrite the WKD tree, they’d swap both keys for one of their own, sign a malicious release with it, and the client would wave it straight through. The cross-check would be comparing a forged key against another copy of the same forged key. Worthless. The defence only holds if the published key and the embedded key come from infrastructure an attacker would have to breach separately. That argument is laid out in full in a signature the platform can’t forge; this is where you actually pay for it.

In practice that means a different host with its own credentials. A static host like Cloudflare Pages in Direct Upload mode does the job: you build the tree locally and push it with the Wrangler CLI under a token scoped to Pages edit and nothing else, no Git integration wired to your code repo. The token that can rewrite your keys has no power over your releases, and vice versa. For the advanced method you’ll also point DNS for openpgpkey.acme.dev at that host and let it terminate TLS. Whatever you pick, the test is simple: could one stolen credential change both the key in the binary’s update path and the key on the server? If yes, you’ve built a very elaborate way of trusting nobody.

Where this leaves you

You’ve turned a key locked inside KMS into a published, fetchable OpenPGP key without the private half ever surfacing, minted the offline rotation authority you’ll be glad of later, and put both somewhere your release platform can’t quietly rewrite. The verifying side of the loop now exists out in the world, ready to be checked against.

What’s missing is the checking. Part 5 bakes the public key into the binary as its trust anchor and turns enforcement on, so the tool refuses any update whose signature doesn’t hold, the moment that update lands.

Built with Hugo
Theme Stack designed by Jimmy