gtb keys wkd β generate the Web Key Directory tree¶
- Authors
- Matt Cockayne
- Date
- 2026-06-09
- Status
- IMPLEMENTED (shipped in MR !34, 2026-06-09; live since v0.12.0)
Summary¶
Add a third subcommand to the gtb keys group (alongside mint and generate) that takes one or more armored OpenPGP public-key files plus a domain and emits a Web Key Directory (WKD) tree ready to upload to a static host. Implemented entirely in Go β no gpg-wks-client dependency.
gtb keys wkd \
--domain phpboyscout.uk \
--output ./wkd-staging \
rotation-authority.asc signing-key-v1.asc
Result:
wkd-staging/
βββ .well-known/
βββ openpgpkey/
βββ phpboyscout.uk/
βββ policy # empty file (RFC Β§3.1)
βββ hu/
βββ <z-base-32-hash-of-"release"> # binary OpenPGP packets
Then wrangler pages deploy wkd-staging --project-name=openpgpkey-phpboyscout-uk ships it.
Motivation¶
Phase2 signing prep doc Β§ Gate 2 approves Cloudflare Pages Direct Upload as the WKD host. The prep doc's example invocation uses gpg-wks-client β a CLI tool that introduces an external dependency gtb has otherwise been careful to avoid (we already write our own keys via gtb keys generate instead of shelling out to gpg).
A native Go implementation closes the loop:
- Pure-Go path consistent with the rest of
gtb keysβ nogpginstall required to generate the WKD tree. - The exact same
pkg/openpgpkeyparser thatpkg/setup/signing_wkd.go(theWKDResolveron MR !9) uses to fetch WKD bytes is now also used to emit them. Server- and client-side share one understanding of OpenPGP packet framing. - Per-rotation deploy is one command, scriptable.
Design decisions¶
D1 β Subcommand placement¶
internal/cmd/keys/wkd.go, alongside mint.go and generate.go. Stays a gtb-only command (not scaffoldable to downstream tools β same boundary as the other two; downstream tools rarely need a WKD endpoint and can always invoke gpg-wks-client if they do).
D2 β Library-first split¶
Per the project's library-first principle, the WKD-tree generator lives in pkg/openpgpkey/wkd.go as a public, Beta-tier API. Shape:
// Entry is one published email + its keys. Multiple keys per email
// (typical: rotation-authority + signing-key) are concatenated under
// the same hu/<hash> file.
type Entry struct {
Email string
Keys [][]byte // armored *or* binary; auto-detected
}
// WriteWKDTree writes the .well-known/openpgpkey/<domain>/{policy,hu/<hash>}
// layout under outDir. Returns the list of paths written for the caller
// to log or assert against in tests.
func WriteWKDTree(outDir, domain string, entries ...Entry) ([]string, error)
internal/cmd/keys/wkd.go is a thin CLI wrapper: flag parsing β read .asc files β group by email β call WriteWKDTree.
D3 β Method: advanced (subdomain), not direct¶
WKD has two URL methods:
- Direct:
https://<domain>/.well-known/openpgpkey/hu/<hash>β served from the same domain as the email. - Advanced:
https://openpgpkey.<domain>/.well-known/openpgpkey/<domain>/hu/<hash>β served from a dedicated subdomain. Required when the main domain serves anything else.
Phase 2 prep doc chose advanced (openpgpkey.phpboyscout.uk is a dedicated subdomain). We default to advanced; expose --method direct for completeness and to make the verifier-side WKDResolver testable against the simpler layout.
Difference in output is one directory level β advanced nests under <domain>/, direct does not.
D4 β Hashing¶
Per draft-koch-openpgp-webkey-service Β§3.1:
- Lowercase the local part (text before
@) using plain ASCII lowercasing β not Unicode case-folding. (Both keys have ASCII-only local parts here.) - Compute
SHA-1over the lowercased bytes. - Encode the 20-byte hash with z-base-32 (Zooko's base-32 alphabet, Phil Zimmermann variant). Produces a 32-character string.
SHA-1 here is not a security primitive β it's the IETF-mandated bucket identifier. The crypto verification still happens via the signing algorithm of the key itself (ed25519 / RSA-4096). The existing .golangci.yaml already exempts pkg/setup/signing_wkd.go from gosec G401|G505 for the same reason; we extend that exemption to pkg/openpgpkey/wkd.go.
z-base-32 is not in the Go stdlib. Options:
- (a) Add a dep on
github.com/tv42/zbase32(~80 LoC, MIT-licensed, no transitive deps). Used by Tailscale, gpg-tools' Go port, and other OpenPGP-adjacent Go code. - (b) Write the encoder inline. The alphabet is fixed (
ybndrfg8ejkmcpqxot1uwisza345h769), encoding is ~50 LoC. No external trust to extend.
D4 picks (b) write inline. Rationale: the encoder is small enough that an in-tree implementation is less code overall than a vendor dep, easier to audit for the security-tooling audience, and avoids inheriting whatever supply-chain surface tv42/zbase32 collects in the future. The unit tests use the IETF spec's published test vectors so any drift is caught.
D5 β Input format and multi-email grouping¶
Accept .asc files (armored OpenPGP) as positional arguments. Internally, dearmor before writing to hu/ (WKD serves binary packets per Β§3.1; an armored file at the WKD URL is non-conformant and most clients reject it).
--email <addr> is repeatable. The CLI takes a list of emails to publish; for each, it walks every parsed entity, matches UIDs against the email, and groups matched keys into that email's hu/<hash> bucket. A single key can appear in multiple buckets if it carries multiple matching UIDs (standard WKD behaviour).
Library shape (resolved from Q1 β multi-email enabled in v0.1):
// Entry binds one email to the keys to publish under it. WriteWKDTree
// groups duplicate Emails across entries by concatenating their Keys
// into the same hu/<hash> file.
type Entry struct {
Email string
Keys [][]byte // armored *or* binary; auto-detected
}
type Method string
const (
MethodAdvanced Method = "advanced" // default
MethodDirect Method = "direct"
)
type Options struct {
Method Method // zero-value = MethodAdvanced
SubmissionAddress string // optional; if non-empty, written to the domain root
}
// WriteWKDTree writes the .well-known/openpgpkey/...{policy,submission-address?,hu/<hash>}
// layout under outDir. Returns the list of paths written for callers to
// log or assert against.
func WriteWKDTree(outDir, domain string, opts Options, entries ...Entry) ([]string, error)
Edge cases:
- Two
.ascfiles with the same email β keys merge into one hu/ file (per D9 ordering). - A
.ascwhose UIDs don't match any requested email β error (caller asked to publish but nothing matched). --emailrequested but no input file contains a UID matching it β error.
D6 β Domain metadata files: policy and submission-address (Q2 resolved: emit both)¶
Per RFC Β§3.1 the directory policy file is "always provided but may be empty"; submission-address is optional but useful for clients that want to discover the WKS submission endpoint.
Emit both:
policyβ zero-byte file by default. A future--policy-key valueflag could populate it.submission-addressβ written whenOptions.SubmissionAddressis non-empty. At the CLI,--submission-addressis empty by default and the file is omitted; pass--submission-address autoto populate it from the first--email, or--submission-address <email>to set it explicitly. (Earlier drafts defaulted empty to the first--email, contradicting the flag help β the empty-means-omit behaviour the help promised is now the implemented default.) The file's content is the email address with no trailing newline.
Both files live in the same directory as hu/ β i.e. .well-known/openpgpkey/<domain>/ for the advanced method, .well-known/openpgpkey/ for direct.
D7 β Reproducibility + idempotence (Q3 resolved: lexicographic by fingerprint)¶
- File contents are byte-deterministic given the same inputs (no timestamps in WKD payloads β the OpenPGP packets are already canonical).
- Within a single
hu/<hash>file, multiple keys are concatenated in lexicographic order of their primary-key fingerprint (uppercase hex). Argument order doesn't affect the output bytes, so re-running with reordered.ascfiles produces a bit-identical tree. - Across multiple emails,
hu/files are written in lexicographic order of their hash (deterministic; the OS dictates the actual disk ordering, but emission order is stable). WriteWKDTreeoverwrites any existing files underoutDir/.well-known/openpgpkey/<domain>/. It refuses to write outside that subtree (path-traversal guard against malicious domain values, thoughgtb keys wkdonly takes--domainfrom a flag).- A reproducibility smoke test (
sha256sum -c) lives inpkg/openpgpkey/wkd_test.go: emit twice with reordered inputs, compare every file's sha256.
D8 β Validation¶
Before generating the tree:
- Each
.ascmust parse viaopenpgp.ReadArmoredKeyRing. - Each entity must carry at least one UID with a parseable email matching the
--emailfilter (if specified) or otherwise providing the email used as the hu/bucket. --domainmust be a valid hostname-shaped string (letters/digits/hyphens, dot-separated). Refuses paths or schemes.- Verbose mode (
-v/--debug) logs each fingerprint as it's grouped into its hu/ bucket β same formatgtb keys mintalready uses.
Verification plan¶
- Unit:
pkg/openpgpkey/wkd_test.goβ z-base-32 encoder against RFC test vectors;WriteWKDTreeround-trip with the two real keys produced earlier today (FP2B26β¦19C2+6E20β¦AA35) into at.TempDir(); assert paths, content equality, directory exclusivity. - Cross-check with
gpg-wks-client: anINT_TEST_WKD=1integration test (gated like the existing integration suites) runsgpg-wks-client --print-wkd-hash [email protected]and asserts our z-base-32 output matches. Catches any bug in the encoder that the unit tests miss. - End-to-end (post-deploy):
gpg --auto-key-locate clear,wkd --locate-keys [email protected]against the actualopenpgpkey.phpboyscout.ukendpoint resolves both keys with matching fingerprints. Documented as a verification step in the how-to.
Out of scope¶
- Auto-deploying to Cloudflare via Wrangler. The deploy command lives in
docs/how-to/publish-wkd.md(to be written) β orchestrating wrangler from inside gtb adds a dep we don't need. - The Web Key Service (WKS) submission protocol (RFC Β§3.2). We don't accept third-party key submissions; the only emails we publish are role addresses we mint ourselves.
- A
gtb keys verifysubcommand that fetches via WKD and cross-checks.pkg/setup/signing_wkd.WKDResolveralready does this at update time; an explicit operator-driven verifier is a separate (likely useful) addition tracked elsewhere.
Resolutions¶
Open questions were reviewed with the user on 2026-06-09 before implementation:
- Q1 β Multi-email enabled in v0.1.
--emailis repeatable; each requested address gets its ownhu/<hash>bucket; a single key with multiple matching UIDs ends up in each matching bucket. Bigger initial surface than the single-email v0.1 the spec originally proposed, but rounds out the RFC coverage in one go. (See D5.) - Q2 β Emit
policyalways,submission-addresson request.policyis zero-byte by default (RFC-required).submission-addressis omitted unless--submission-addressis set βautopopulates it from the first--email, any other value is used verbatim. Future-proofs the endpoint for any WKS-aware client without needing a re-deploy when we add WKS support. (See D6.) - Q3 β Lexicographic by fingerprint. Multi-key
hu/<hash>files concatenate keys in uppercase-hex fingerprint order, making re-runs bit-identical regardless of argument order. (See D7.) - Q4 β Keep
--method. Defaultadvanced(production);--method directavailable for the root-domain layout. ~10 lines of conditional logic but full RFC coverage inpkg/openpgpkey, and gives the library's own test suite a way to exercise both shapes. (See D3.) - Q5 β Stay armored-only in
internal/trustkeys/keys/(deferred default).gtb keys wkddearmors.ascfiles on the fly when emittinghu/<hash>. No binary copy embedded.
Related¶
- Phase 2 signing prep doc β Gate 2 selects Cloudflare Pages Direct Upload as the WKD host.
- Keys-mint spec β
gtb keys mintandgtb keys generate, the prior two subcommands in thekeysgroup. - draft-koch-openpgp-webkey-service-15 β the WKD spec itself.
pkg/setup/signing_wkd.goβ the client-side WKD resolver this spec pairs with.