Publish public keys via WKD¶
WKD (Web Key Directory) lets a client fetch your public key from a
well-known URL derived from an email address. The verifier uses this
as a second, externally-administered trust anchor: a gtb update
fetches the WKD copy and refuses to install a release unless the
embedded key (shipped in the binary) and the WKD copy agree on the
key's fingerprint. A compromise of just the codebase, or just the
WKD host, cannot poison verification.
This how-to walks the end-to-end deploy: generate the WKD tree from
your .asc public-key files, host it on Cloudflare Pages, and verify
the round-trip with gpg --auto-key-locate.
What you'll have at the end¶
- A live WKD endpoint at
https://openpgpkey.<yourdomain>/.well-known/openpgpkey/<yourdomain>/hu/<hash> - A Cloudflare Pages project deploying via Direct Upload (no Git, no webhook β explicitly disjoint from your code-hosting and signing trust anchors)
- A
gpg --locate-keys release@<yourdomain>lookup that returns the same keys you embedded in the binary
Why openpgpkey.<yourdomain>?
The openpgpkey. subdomain literal is fixed by
draft-koch-openpgp-webkey-service Β§3.1 β it is the
spec's "advanced" URL form. Every WKD client (GnuPG, GTB's
verifier, Sequoia, etc.) hardcodes this prefix when constructing
the lookup URL, so we don't choose it. The verifier configuration
on the consumer side only ever takes the email; the hostname
falls out mechanically. See the
Release-binary signing concept doc's "How the
verifier finds your WKD endpoint" section for the full derivation
walk-through.
One-time setup¶
Per phase2-signing-prep doc Β§ Gate 2, the WKD trust anchor must live outside both your code-hosting (GitLab/GitHub) and your KMS (AWS/GCP/Azure). The recipe enforces that with three distinct account boundaries:
- Cloudflare account on a fresh email (not the address tied to your code-hosting or cloud accounts). Set MFA with a different authenticator app than your existing accounts β ideally a hardware key. Holding two of the three anchors must still leave the third uncompromisable.
- API token under
My Profile β API Tokens β Create Tokenwith permissionAccount β Cloudflare Pages β Editonly. No Zone, no DNS edit, no other scopes. Store it in your password manager. - Pages project at
Workers & Pages β Create β Pages β Upload assets. Name it after the subdomain (e.g.openpgpkey-yourdomain-uk). Do not connect a Git repository β that's what would re-introduce GitHub/GitLab as a trust dependency. - Custom domain: in the project's settings, bind
openpgpkey.<yourdomain>. Cloudflare provisions TLS and gives you a CNAME target like<project>.pages.dev. - DNS (Cloudflare or whichever provider hosts your apex
domain): add
openpgpkey CNAME <project>.pages.dev. TLS provisioning typically takes <2 minutes. Verify withcurl -I https://openpgpkey.<yourdomain>/β expect a 404 with valid TLS.
You also need wrangler locally:
# via mise (recommended if you already use it)
mise use -g wrangler@latest
# or via npm
npm install -g wrangler
Generate the WKD tree¶
gtb keys wkd reads one or more armored OpenPGP public keys and
emits the directory structure the WKD spec mandates:
gtb keys wkd \
--domain phpboyscout.uk \
--email [email protected] \
--submission-address [email protected] \
--output ./wkd-staging \
rotation-authority.asc signing-key-v1.asc
Result (advanced layout β the default and what Cloudflare Pages
serves under openpgpkey.<yourdomain>):
wkd-staging/
βββ .well-known/
βββ openpgpkey/
βββ phpboyscout.uk/
βββ policy (empty file, RFC Β§3.1)
βββ submission-address (the email you configured)
βββ hu/<z-base-32-hash> (binary concatenated keys)
A successful run logs each hu/ bucket's hash plus every file
written.
The submission-address file¶
--submission-address controls the optional WKS submission-address
file:
- omitted / empty (the default) β the file is not written.
--submission-address autoβ writes the first--emailvalue.--submission-address <email>β writes that address verbatim.
Pass auto (as opposed to leaving the flag unset) when you want the
file populated from your first --email without spelling the address
out twice.
One-bucket vs multi-bucket¶
WKD groups by email. If both your keys have the same UID
([email protected], the standard role-address pattern), they
end up in one hu/<hash> file, in lexicographic fingerprint order so
the output bytes are reproducible across deploys.
To publish multiple emails (e.g. release@ for releases and
security@ for advisories), pass --email repeatedly:
gtb keys wkd \
--domain phpboyscout.uk \
--email [email protected] \
--email [email protected] \
--output ./wkd-staging \
keys/*.asc
Each --email gets its own hu/<hash> bucket; a key with UIDs for
multiple emails appears in every matching bucket.
Deploy¶
export CLOUDFLARE_API_TOKEN=... # from your password manager
wrangler pages deploy ./wkd-staging \
--project-name=openpgpkey-phpboyscout-uk \
--branch=main \
--commit-dirty=true
The --commit-dirty=true flag tells Wrangler not to require a Git
working tree (which we don't have for WKD β the source of truth is
the offline-stored signing keys, not a Git repo).
Verify the round-trip¶
A WKD-aware GPG client should resolve both your keys:
gpg --auto-key-locate clear,wkd --locate-keys [email protected]
You should see both fingerprints (the rotation-authority and the
signing-key) printed, matching what gtb keys mint reported and what
you embedded in internal/trustkeys/keys/.
Direct HTTP fetch also works for spot-checking β the hash is logged
by the gtb keys wkd run that produced the file. Copy it from there:
curl -fsSL "https://openpgpkey.phpboyscout.uk/.well-known/openpgpkey/phpboyscout.uk/hu/<the-hash-gtb-logged>" \
| gpg --list-packets | head -30
You should see two public key packet blocks (one per embedded
key) and matching :user ID packet: entries for
[email protected].
Per-rotation re-deploy¶
When you mint a new signing key (or rotation key), re-run gtb keys
wkd against the new public-key file set plus any keys you want
to keep serving, and wrangler pages deploy again. There's no
manual surgery on Cloudflare β the directory is fully reproducible
from the .asc files.
The Pages project's deploy history makes rollback trivial: if a bad key ever ships, redeploy a known-good staging directory in seconds.
What can go wrong¶
| Symptom | Cause | Fix |
|---|---|---|
curl β¦ /hu/<hash> returns 404 |
DNS still propagating, or you uploaded the wrong layout | Wait 2 min; if it persists, confirm the file is at .well-known/openpgpkey/<domain>/hu/<hash>, not β¦openpgpkey/hu/β¦ |
gpg --locate-keys resolves direct but not advanced |
Subdomain CNAME missing | Re-check DNS: dig openpgpkey.<domain> CNAME |
gtb update says "embedded β WKD fingerprint mismatch" |
A different key was uploaded than the one embedded | Redeploy from the same .asc set used to populate internal/trustkeys/keys/ |
wrangler complains about an unknown project |
Project not yet created or wrong --project-name |
Re-check the Cloudflare dashboard project listing |
Related¶
- Spec: gtb keys wkd β design decisions, RFC details, the threat-model rationale.
- Phase 2 signing prep doc β the upstream gate decisions for domain, email, and host.
pkg/openpgpkeyβ the library functionWriteWKDTreethat the CLI wraps.pkg/setup/signing_wkd.goβ the client-sideWKDResolverthat pairs with this generator.