Skip to content

Publish your public key

By the end of this you'll have the public half of your KMS signing key as an OpenPGP key file, an offline rotation-authority key beside it, and both published as a Web Key Directory (WKD) on a host your release platform has no access to.

This is the fourth part of the signing series. Sign from CI without stored keys left your pipeline able to sign through KMS. But a signature is no use to someone who can't get the matching public key, and KMS deals in raw signing operations, not OpenPGP keys. This part builds the public key from KMS without touching the private half, then puts it where an attacker who owns your release host can't rewrite it.

Allow about thirty minutes, plus whatever your static host and DNS take.

Before you start

  • The KMS key from Put the signing key in AWS KMS, and its alias.
  • AWS credentials allowed kms:GetPublicKey and kms:Sign on that key. Minting reads the public half and then signs with it, as the next step explains. The key policy gives kms:Sign to the signer role alone, so run the mint as the signer role or from a pipeline that assumes it.
  • The gtb CLI. The commands below are checked against v0.50.0.
  • A domain you control, and a static host for it that is separate from your code and release hosting. More on why below.

Mint the public key from KMS

gtb keys mint builds an OpenPGP public key from a signing backend. In Sign and verify on your laptop the backend was a .pem file. Now it's aws-kms:

gtb keys mint \
    --backend aws-kms \
    --kms-region eu-west-2 \
    --key-id alias/acme-release-signing-v1 \
    --name "Acme Releases" \
    --email [email protected] \
    --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=...

An OpenPGP key isn't only public-key material. It carries a self-signature, which binds the name and email to the key, and making that normally needs the private key. mint hands the signing to the backend instead, so the self-signature is one more kms:Sign call. KMS does the maths inside its HSM and returns a signature. No private byte leaves it, not even to sign the key's own identity.

Pick --created once and never change it. The fingerprint is computed partly from the creation time, so minting the same KMS key with a different timestamp gives what every tool treats as a different key. The key you embed in your binary in the next part has to match this one byte for byte. The KMS key's own creation date is a sensible choice:

aws kms describe-key --key-id alias/acme-release-signing-v1 \
    --query KeyMetadata.CreationDate --output text

Write the value down beside the key's alias. You'll need it again whenever you re-mint.

Make a rotation-authority key

You won't use this until the rotation part of the series, but it's far easier to make now than in a hurry later. The rotation authority is 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.

Because it's for emergencies, it doesn't live in KMS. Generate it on a trusted machine, ideally one that's offline:

gtb keys generate \
    --algorithm ed25519 \
    --name "Acme Rotation Authority" \
    --email [email protected] \
    --output rotation-authority.asc
INFO  Generated OpenPGP keypair algorithm=ed25519 public_output=rotation-authority.asc private_output=rotation-authority.priv.asc creation_time=2026-11-02T14:50:17Z fingerprint=3023D464AF06360994F6E4A555E8928F70BD4408
WARN  Move the private-half file to offline storage now. private_output=rotation-authority.priv.asc

You get an armoured public key, rotation-authority.asc, and an armoured secret-key block, rotation-authority.priv.asc, in the same format gpg --export-secret-keys writes. Move the private half to offline storage now. An encrypted USB stick and a paper backup isn't overdoing it for a key you might not touch for two years and will badly need when you do.

It carries the same [email protected] address as the signing key, not a separate rotation@ one, and that's deliberate. WKD groups keys by the email in their user ID. The shared address puts the rotation authority in the same published bucket as the signing key, and so in the same trust set your binary embeds. Give it a different email and it drops out of that bucket, the published and embedded key sets stop matching, and every update fails the check you'll set up in the next part.

Build the WKD tree

A Web Key Directory is how a client finds a public key from an email address: a fixed layout of files under .well-known/openpgpkey/ on a web server, with each address's keys at a path derived from a hash of its local part. gtb keys wkd builds the tree in Go, with no gpg needed:

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

The hash depends only on the address, so yours for [email protected] is the same. The tree holds two files:

  • policy, which the WKD spec requires. It's empty.
  • hu/<hash>, with both keys inside, because both carry [email protected].

Pass --submission-address auto if you also want a submission-address file. Nothing in this series needs one.

The default --method is advanced, which serves the tree from a dedicated openpgpkey.acme.dev subdomain. That's why acme.dev appears inside the path. With --method direct it's served from acme.dev itself. Use advanced unless you have a reason not to, and know that it needs DNS and TLS for openpgpkey.acme.dev.

Publish it where your release host can't reach

Don't put the WKD tree on the same host, or under the same account, as your code and releases. This is the step that's tempting to cut short, and it's the one the whole scheme depends on.

Your binary will carry an embedded copy of the public key. On every self-update it also fetches the published key over WKD and checks that the two agree before it trusts a download. If an attacker who controls your release platform could rewrite the WKD tree too, they'd replace both keys with their own, sign a malicious release, and the check would compare one forged key against another copy of it. The check only protects anyone if the published key and the embedded key come from infrastructure an attacker has to breach separately.

So publish to a different host with its own credentials. A static host such as Cloudflare Pages in Direct Upload mode works: build the tree locally and push it with the Wrangler CLI, under a token that can edit that Pages project and nothing else, with no Git integration to your code repository. For the advanced method, point DNS for openpgpkey.acme.dev at that host and let it terminate TLS.

Whatever you choose, ask one question: could a single stolen credential change both the key in the binary's update path and the key on the server? If it could, start again.

Where this leaves you

Your KMS key now has a published, fetchable OpenPGP public key, made without its private half ever leaving KMS. The offline rotation authority sits beside it, and both live somewhere your release platform can't quietly rewrite.

What's missing is a tool that checks. The next part embeds the public key in your binary and turns on enforcement, so it refuses any update whose signature doesn't hold.