<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Wkd on PHP Boy Scout</title><link>https://blog-570662.gitlab.io/tags/wkd/</link><description>Recent content in Wkd on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Sat, 20 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://blog-570662.gitlab.io/tags/wkd/index.xml" rel="self" type="application/rss+xml"/><item><title>Sign your own binaries with go-tool-base, part 4: mint and publish your public key</title><link>https://blog-570662.gitlab.io/sign-your-own-binaries-with-go-tool-base-part-4/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/sign-your-own-binaries-with-go-tool-base-part-4/</guid><description>&lt;img src="https://blog-570662.gitlab.io/sign-your-own-binaries-with-go-tool-base-part-4/cover-sign-your-own-binaries-with-go-tool-base-part-4.png" alt="Featured image of post Sign your own binaries with go-tool-base, part 4: mint and publish your public key" /&gt;&lt;p&gt;By the end of &lt;a class="link" href="https://blog-570662.gitlab.io/sign-your-own-binaries-with-go-tool-base-part-3/" &gt;Part 3&lt;/a&gt;
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&amp;rsquo;s a snag, though: a signature is no use to anyone who can&amp;rsquo;t get hold of the
matching public key, and a KMS key won&amp;rsquo;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 &lt;em&gt;from&lt;/em&gt; the KMS key without ever touching the private bytes,
then puts that key somewhere your release platform can&amp;rsquo;t reach.&lt;/p&gt;
&lt;p&gt;That last bit is the part people skip, and it&amp;rsquo;s the part that does the real work. The
whole scheme in &lt;a class="link" href="https://blog-570662.gitlab.io/a-signature-the-platform-cant-forge/" &gt;a signature the platform can&amp;rsquo;t
forge&lt;/a&gt; rests on
the verifying key living somewhere an attacker can&amp;rsquo;t poison in the same breath as the
release. We&amp;rsquo;ll come back to why that matters once the key is in hand.&lt;/p&gt;
&lt;h2 id="mint-the-public-key-from-kms"&gt;Mint the public key from KMS
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;gtb keys mint&lt;/code&gt; builds an OpenPGP public key out of a signing backend. In
&lt;a class="link" href="https://blog-570662.gitlab.io/sign-your-own-binaries-with-go-tool-base-part-1/" &gt;Part 1&lt;/a&gt;
the backend was a &lt;code&gt;.pem&lt;/code&gt; on disk; now it&amp;rsquo;s &lt;code&gt;aws-kms&lt;/code&gt;, and only that flag changes:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gtb keys mint &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --backend aws-kms &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --kms-region eu-west-2 &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --key-id alias/acme-release-signing-v1 &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --name &lt;span class="s2"&gt;&amp;#34;Acme Releases&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --email release@acme.dev &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --created &lt;span class="s2"&gt;&amp;#34;2026-06-02T00:00:00Z&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --output signing-key-v1.asc
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;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=...
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here&amp;rsquo;s the neat part, and it&amp;rsquo;s worth pausing on. An OpenPGP key isn&amp;rsquo;t just a lump
of public-key material; the key carries a self-signature, a signature it makes
&lt;em&gt;over itself&lt;/em&gt; to bind the name and email to the key. So minting a public key
normally needs the private key to do that signing. &lt;code&gt;mint&lt;/code&gt; doesn&amp;rsquo;t. It wraps the
backend in a &lt;code&gt;crypto.Signer&lt;/code&gt;, and every signing operation, the self-signature
included, becomes a &lt;code&gt;kms:Sign&lt;/code&gt; 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&amp;rsquo;s own
identity onto itself. The mechanism is the subject of &lt;a class="link" href="https://blog-570662.gitlab.io/a-signing-key-that-never-leaves-kms/" &gt;a signing key that never
leaves KMS&lt;/a&gt; if
you want to see how the signer is wired up.&lt;/p&gt;
&lt;p&gt;Two things to get right. First, the AWS credentials: minting needs both
&lt;code&gt;kms:GetPublicKey&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; &lt;code&gt;kms:Sign&lt;/code&gt; 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 &lt;code&gt;--created&lt;/code&gt;. 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 &lt;code&gt;--created&lt;/code&gt;
is what guarantees it.&lt;/p&gt;
&lt;h2 id="mint-the-rotation-authority-while-youre-here"&gt;Mint the rotation authority while you&amp;rsquo;re here
&lt;/h2&gt;&lt;p&gt;You won&amp;rsquo;t use it until &lt;a class="link" href="" &gt;Part 7&lt;/a&gt;,
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&amp;rsquo;s a break-glass key: an offline
key whose only job is to vouch for a &lt;em&gt;new&lt;/em&gt; 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.&lt;/p&gt;
&lt;p&gt;Because it&amp;rsquo;s break-glass, it isn&amp;rsquo;t a KMS key. You generate it on a trusted offline
machine and the private half goes straight into cold storage:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gtb keys generate &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --algorithm ed25519 &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --name &lt;span class="s2"&gt;&amp;#34;Acme Rotation Authority&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --email release@acme.dev &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --created &lt;span class="s2"&gt;&amp;#34;2026-06-02T00:00:00Z&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --output rotation-authority.asc
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;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=...
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;WARN Move the private-half file to offline storage now. private_output=rotation-authority.priv.asc
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;For Ed25519 you get an armored public &lt;code&gt;.asc&lt;/code&gt; and an armored secret-key block
(&lt;code&gt;.priv.asc&lt;/code&gt;, the same wire format &lt;code&gt;gpg --export-secret-keys&lt;/code&gt; produces). Do what the
warning says, and do it before you forget: move &lt;code&gt;rotation-authority.priv.asc&lt;/code&gt; to
offline storage immediately. An encrypted USB stick &lt;em&gt;and&lt;/em&gt; a paper backup is not
paranoid for a key you might not touch for two years and will desperately need when
you do.&lt;/p&gt;
&lt;p&gt;Notice it carries the &lt;em&gt;same&lt;/em&gt; &lt;code&gt;release@acme.dev&lt;/code&gt; user ID as the signing key, not a
separate &lt;code&gt;rotation@&lt;/code&gt; address. That&amp;rsquo;s deliberate, and it&amp;rsquo;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.&lt;/p&gt;
&lt;h2 id="build-the-wkd-tree"&gt;Build the WKD tree
&lt;/h2&gt;&lt;p&gt;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 &lt;code&gt;.well-known/openpgpkey/&lt;/code&gt; on a web server,
where each key lives at a path derived by hashing the local-part of its email.
&lt;code&gt;gtb keys wkd&lt;/code&gt; builds that tree for you, in pure Go, with no &lt;code&gt;gpg&lt;/code&gt; or
&lt;code&gt;gpg-wks-client&lt;/code&gt; anywhere in sight:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gtb keys wkd &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --domain acme.dev &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --email release@acme.dev &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --output ./wkd-staging &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; signing-key-v1.asc rotation-authority.asc
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;INFO WKD bucket email=release@acme.dev hash=... keys=2
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;INFO wrote path=wkd-staging/.well-known/openpgpkey/acme.dev/policy
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;INFO wrote path=wkd-staging/.well-known/openpgpkey/acme.dev/submission-address
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;INFO wrote path=wkd-staging/.well-known/openpgpkey/acme.dev/hu/...
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;INFO WKD tree complete output=wkd-staging method=advanced emails=1 files=3
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;img alt="Generating the rotation-authority key and building the WKD tree" class="gallery-image" data-flex-basis="378px" data-flex-grow="157" height="760" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://blog-570662.gitlab.io/sign-your-own-binaries-with-go-tool-base-part-4/demo-publish-wkd.gif" width="1200"&gt;
&lt;/p&gt;
&lt;p&gt;Both keys carry &lt;code&gt;release@acme.dev&lt;/code&gt; in their UID here, so they land in the same &lt;code&gt;hu/&lt;/code&gt;
bucket, concatenated. (If you&amp;rsquo;d rather split them, give each a different email and
pass &lt;code&gt;--email&lt;/code&gt; twice.) The tree under &lt;code&gt;./wkd-staging&lt;/code&gt; holds a &lt;code&gt;policy&lt;/code&gt; file
(required by the spec, and empty), a &lt;code&gt;submission-address&lt;/code&gt; file, and one
&lt;code&gt;hu/&amp;lt;z-base-32-hash&amp;gt;&lt;/code&gt; file per email with the keys inside.&lt;/p&gt;
&lt;p&gt;One decision matters for where this gets served. The default &lt;code&gt;--method&lt;/code&gt; is
&lt;code&gt;advanced&lt;/code&gt;, which serves the tree from a dedicated &lt;code&gt;openpgpkey.acme.dev&lt;/code&gt; subdomain,
which is why the path above has &lt;code&gt;acme.dev&lt;/code&gt; nested inside it. Pass &lt;code&gt;--method direct&lt;/code&gt;
instead and the tree is served from &lt;code&gt;acme.dev&lt;/code&gt; itself. Advanced is the modern
default and what you want unless you&amp;rsquo;ve a reason otherwise; it does mean you&amp;rsquo;ll need
DNS and TLS for &lt;code&gt;openpgpkey.acme.dev&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="publish-it-somewhere-the-platform-cant-reach"&gt;Publish it somewhere the platform can&amp;rsquo;t reach
&lt;/h2&gt;&lt;p&gt;This is the bit that&amp;rsquo;s tempting to fudge, and the bit you mustn&amp;rsquo;t. Do &lt;strong&gt;not&lt;/strong&gt; drop
the WKD tree onto the same host, or under the same account, as your code and your
releases.&lt;/p&gt;
&lt;p&gt;Walk the attack through. Your binary will carry an embedded copy of the public key
(Part 5). On every self-update it fetches the &lt;em&gt;published&lt;/em&gt; 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&amp;rsquo;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 &lt;em&gt;separately&lt;/em&gt;. That argument is laid out in full in &lt;a class="link" href="https://blog-570662.gitlab.io/a-signature-the-platform-cant-forge/" &gt;a signature the
platform can&amp;rsquo;t forge&lt;/a&gt;;
this is where you actually pay for it.&lt;/p&gt;
&lt;p&gt;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&amp;rsquo;ll also point
DNS for &lt;code&gt;openpgpkey.acme.dev&lt;/code&gt; 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&amp;rsquo;s
update path &lt;em&gt;and&lt;/em&gt; the key on the server? If yes, you&amp;rsquo;ve built a very elaborate way of
trusting nobody.&lt;/p&gt;
&lt;h2 id="where-this-leaves-you"&gt;Where this leaves you
&lt;/h2&gt;&lt;p&gt;You&amp;rsquo;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&amp;rsquo;ll be glad
of later, and put both somewhere your release platform can&amp;rsquo;t quietly rewrite. The
verifying side of the loop now exists out in the world, ready to be checked against.&lt;/p&gt;
&lt;p&gt;What&amp;rsquo;s missing is the checking. &lt;a class="link" href="" &gt;Part 5&lt;/a&gt;
bakes the public key into the binary as its trust anchor and turns enforcement on, so
the tool refuses any update whose signature doesn&amp;rsquo;t hold, the moment that update
lands.&lt;/p&gt;</description></item></channel></rss>