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

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.
