<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Gtb-Signing-Tutorial on PHP Boy Scout</title><link>https://phpboyscout.uk/tags/gtb-signing-tutorial/</link><description>Recent content in Gtb-Signing-Tutorial on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Tue, 23 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/tags/gtb-signing-tutorial/index.xml" rel="self" type="application/rss+xml"/><item><title>Sign your own binaries with go-tool-base, part 7: rotation and break-glass</title><link>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-7/</link><pubDate>Tue, 23 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-7/</guid><description>&lt;img src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-7/cover-sign-your-own-binaries-with-go-tool-base-part-7.png" alt="Featured image of post Sign your own binaries with go-tool-base, part 7: rotation and break-glass" /&gt;&lt;p&gt;Most signing guides stop the moment the first release goes out the door,
which is a shame, because the question that keeps you up at night comes later:
what do you do when the key has to change? Keys get rotated on a schedule,
keys get compromised, and one day you&amp;rsquo;ll want to move off RSA onto something
newer. This last part of the &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base/" &gt;signing series&lt;/a&gt;
covers the bit everyone skips, swapping a signing key out from under a fleet
of installed tools without locking a single one of them out, and the
break-glass key for the day the primary is gone.&lt;/p&gt;
&lt;p&gt;By &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-6/" &gt;Part 6&lt;/a&gt;
you&amp;rsquo;ve a pipeline signing every tagged release through a KMS key, a public key
published over WKD, and that same key baked into the binary as its trust
anchor. It all works. The catch nobody mentions is that you&amp;rsquo;ve now got a key
embedded in software sitting on other people&amp;rsquo;s machines, and that key is the
one thing in the whole chain you can&amp;rsquo;t quietly change. So let&amp;rsquo;s plan for
changing it properly.&lt;/p&gt;
&lt;h2 id="why-theres-no-auto-rotate-button"&gt;Why there&amp;rsquo;s no auto-rotate button
&lt;/h2&gt;&lt;p&gt;If you&amp;rsquo;ve used KMS for encryption, you&amp;rsquo;ll know it can rotate keys for you on a
yearly tick. Asymmetric &lt;code&gt;SIGN_VERIFY&lt;/code&gt; keys don&amp;rsquo;t get that, and the
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-signing-kms" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-signing-kms&lt;/code&gt;&lt;/a&gt;
module sets &lt;code&gt;enable_key_rotation = false&lt;/code&gt; on purpose. That&amp;rsquo;s not an oversight
to work around; it falls straight out of how the key is built. The private
half of a signing key never leaves the HSM and has no export path at all, so
there&amp;rsquo;s no mechanism by which AWS could hand your verifiers a rotated public
half and keep the old one verifiable. KMS auto-rotation works for symmetric
keys precisely because you never see the key material; the instant you need a
&lt;em&gt;public&lt;/em&gt; key your clients pin against, rotation stops being something a cloud
provider can do behind your back.&lt;/p&gt;
&lt;p&gt;So rotation here is a runbook you maintain, not a checkbox you tick. That
sounds like the worse deal until you weigh it: you&amp;rsquo;ve got a key with no
exfiltration path, in exchange for rotating it by hand on the rare occasions
you must. For a release-signing key that&amp;rsquo;s exactly the right trade. You don&amp;rsquo;t
want this key rotating itself; you want to be standing right there when it
happens.&lt;/p&gt;
&lt;h2 id="rotate-by-minting-a-new-key-never-by-changing-the-old-one"&gt;Rotate by minting a new key, never by changing the old one
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the rule that makes everything else fall into place. You do not edit
the existing key. The module&amp;rsquo;s &lt;code&gt;key_spec&lt;/code&gt; and &lt;code&gt;name&lt;/code&gt; are immutable by design,
and that immutability is a feature: the v1 key is a fixed point that stays
verifiable while you stand up its replacement next to it.&lt;/p&gt;
&lt;p&gt;So you mint a &lt;em&gt;second&lt;/em&gt; key. A second module instance, named &lt;code&gt;acme-release-signing-v2&lt;/code&gt;,
with its own alias &lt;code&gt;alias/acme-release-signing-v2&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;signing_kms_v2&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;gitlab.com/phpboyscout/signing-kms/aws&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;0.1.2&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;acme-release-signing-v2&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; oidc_provider_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;aws_iam_openid_connect_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;gitlab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;arn&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; ci_subject_filters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;project_path:acme/acme-cli:ref_type:tag:ref:v*&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; key_administrator_arns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; automation_role_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;automation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;arn&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Apply that and the v1 key carries on signing, undisturbed. Now mint its public
half exactly the way you minted v1 back in Part 4, only the alias 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-v2 &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-08T00: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-v2.asc
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;You&amp;rsquo;ve now got two real signing keys, both live, neither one a threat to the
other. The whole rotation is the careful business of moving traffic from one to
the other while every installed client keeps verifying.&lt;/p&gt;
&lt;h2 id="the-dual-publish-dual-sign-window"&gt;The dual-publish, dual-sign window
&lt;/h2&gt;&lt;p&gt;This is the part that does the work, and it leans on one fact about how the
verifier treats your keys. The trust anchor isn&amp;rsquo;t a single key, it&amp;rsquo;s a &lt;em&gt;set&lt;/em&gt;:
v1, v2, and the rotation authority all sit in &lt;code&gt;internal/trustkeys/keys/&lt;/code&gt; and
all get embedded together. A release verifies if &lt;strong&gt;any&lt;/strong&gt; key in that set
validates its signature. That&amp;rsquo;s what makes a handover window possible, because
during it a client might hold v1, or v2, or both, and verify happily whichever
it has.&lt;/p&gt;
&lt;p&gt;So you run an overlap. Three moves, in order.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;First, publish both public keys over WKD.&lt;/strong&gt; Same email bucket, same command
you already know from Part 4, just with v2 added to the file list:&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 signing-key-v2.asc rotation-authority.asc
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Both signing keys share &lt;code&gt;release@acme.dev&lt;/code&gt; in their UID, so they land in one
&lt;code&gt;hu/&lt;/code&gt; bucket together, and the rotation authority rides along as it always has.
Deploy that staging tree the way you deployed it before. Now the WKD endpoint
serves the new trust anchor, so a client that fetches keys on its next update
picks v2 up automatically.&lt;/p&gt;
&lt;p&gt;&lt;img alt="Dual-publishing v1, v2 and the rotation authority into one WKD bucket" class="gallery-image" data-flex-basis="400px" data-flex-grow="166" height="720" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-7/demo-rotate-wkd.gif" width="1200"&gt;
&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Second, embed v2 alongside v1 and ship a release.&lt;/strong&gt; Drop &lt;code&gt;signing-key-v2.asc&lt;/code&gt;
into &lt;code&gt;internal/trustkeys/keys/&lt;/code&gt; next to v1, cut a release, and that build now
ships knowing about both keys. Installed clients pick the new trust anchor up
as they update through the window. This is the slow bit, and it should be: you
want the new key spread far and wide &lt;em&gt;before&lt;/em&gt; you stop signing with the old
one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Third, sign with v2, wait, then retire v1.&lt;/strong&gt; Point the pipeline&amp;rsquo;s
&lt;code&gt;GTB_SIGNING_KEY_ID&lt;/code&gt; at &lt;code&gt;alias/acme-release-signing-v2&lt;/code&gt; so new releases are signed
by the new key. A client that&amp;rsquo;s already updated verifies against the embedded
v2; one that&amp;rsquo;s lagging still has v1 in its set and, because you&amp;rsquo;re still
publishing v1 over WKD, can still fetch and trust it. Nobody&amp;rsquo;s locked out.&lt;/p&gt;
&lt;p&gt;Leave that overlap running long enough that you&amp;rsquo;re confident the slow movers
have updated, weeks, not hours, depending on how often your users actually run
the thing. Only &lt;em&gt;then&lt;/em&gt; do you retire v1: drop its module instance, pull
&lt;code&gt;signing-key-v1.asc&lt;/code&gt; from the embed directory and the WKD file set, and ship a
release that knows only about v2. The rotation is done, and at no point did an
installed tool see a signature it couldn&amp;rsquo;t check.&lt;/p&gt;
&lt;h2 id="the-break-glass-key-for-when-theres-no-handover"&gt;The break-glass key, for when there&amp;rsquo;s no handover
&lt;/h2&gt;&lt;p&gt;Everything above assumes the old key can hand over to the new one: it&amp;rsquo;s still
there, still able to sign, and you&amp;rsquo;re rotating on your own terms. The
nightmare is the other case. The KMS key is gone, access revoked, or you&amp;rsquo;ve
reason to think it&amp;rsquo;s compromised and you daren&amp;rsquo;t sign anything with it again.
There&amp;rsquo;s no handover, because the thing that would do the handing is exactly
what you&amp;rsquo;ve lost.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s what the rotation-authority key is for, and it&amp;rsquo;s why
&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-4/" &gt;Part 4&lt;/a&gt;
had you mint it the moment everything else was calm. It&amp;rsquo;s an offline ed25519
key, generated once on a trusted machine, and the go-tool-base how-to,
&lt;a class="link" href="https://gtb.phpboyscout.uk/how-to/generate-rotation-key/" target="_blank" rel="noopener"
 &gt;generate the rotation-authority key&lt;/a&gt;,
walks the storage in detail. The short version: the private half never lives
on a networked box. You print a paper backup with &lt;code&gt;paperkey&lt;/code&gt;, write it to an
encrypted USB stick, and the two go in a safe; the local copy gets shredded.
The how-to even has you type the paper backup &lt;em&gt;back in&lt;/em&gt; once before you walk
away, because discovering your printer ate a stripe of pixels is a problem you
want now, not eighteen months from now when the building&amp;rsquo;s on fire.&lt;/p&gt;
&lt;p&gt;The public half, though, has been in your trust set the whole time, embedded
in the binary and served over WKD right alongside the signing keys. That&amp;rsquo;s the
trick. Because every installed client already trusts the rotation authority, it
can vouch for a brand-new signing key &lt;em&gt;outside&lt;/em&gt; the normal sign-with-the-old-key
path. You bring the private half out of the safe, use it to authorise the new
key, ship that, and installed tools adopt the replacement on their next update,
all without the dead primary key ever having to sign a thing.&lt;/p&gt;
&lt;p&gt;It is, deliberately, a key you hope never to touch. But a break-glass key you
forgot to cut is just a pane of glass.&lt;/p&gt;
&lt;h2 id="the-whole-chain-end-to-end"&gt;The whole chain, end to end
&lt;/h2&gt;&lt;p&gt;That&amp;rsquo;s the series. Step back and the shape is one clean line: a signing key is
&lt;em&gt;born&lt;/em&gt; inside KMS and never leaves it (Part 2); its public half is &lt;em&gt;minted&lt;/em&gt; out
of the HSM without the private bytes ever surfacing (Part 4); that public key is
&lt;em&gt;published&lt;/em&gt; off-platform over WKD, somewhere your release host can&amp;rsquo;t quietly
rewrite (Part 4), and &lt;em&gt;embedded&lt;/em&gt; into the binary as a required trust anchor
(Part 5); every tagged release is &lt;em&gt;signed&lt;/em&gt; through the key over short-lived OIDC
credentials with no stored secrets (Parts 3 and 6); a stranger&amp;rsquo;s copy of your
tool &lt;em&gt;verifies&lt;/em&gt; its own updates against that anchor before trusting a byte
(&lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;the cross-check&lt;/a&gt;);
and when the day comes, the whole thing is &lt;em&gt;rotatable&lt;/em&gt; without locking anyone
out. A key that can&amp;rsquo;t be stolen, can&amp;rsquo;t be forged, and can still be replaced.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;ve followed all seven parts, you&amp;rsquo;ve built that. If you&amp;rsquo;ve dipped in for
one piece, the &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base/" &gt;pillar&lt;/a&gt;
ties the lot together and points at the deep-dives behind the &lt;em&gt;why&lt;/em&gt;. Either
way, your users are getting updates they can actually trust, which was the
whole point. Go and leave your supply chain better than you found it.&lt;/p&gt;</description></item><item><title>Sign your own binaries with go-tool-base, part 6: sign every release with GoReleaser</title><link>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-6/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-6/</guid><description>&lt;img src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-6/cover-sign-your-own-binaries-with-go-tool-base-part-6.png" alt="Featured image of post Sign your own binaries with go-tool-base, part 6: sign every release with GoReleaser" /&gt;&lt;p&gt;By now you&amp;rsquo;ve got all the pieces lying on the bench. A KMS key that signs but
never hands over its private half (Part 2). A CI role you can assume over OIDC
with no stored credentials (Part 3). A public key you&amp;rsquo;ve minted and published
(Part 4), and embedded in the binary (Part 5). What you don&amp;rsquo;t have yet is the
bit that makes it routine: a release that signs itself, every time, without you
remembering to do anything. That&amp;rsquo;s this part. We wire signing into the
tagged-release pipeline so that pushing a &lt;code&gt;v*&lt;/code&gt; tag is the whole ceremony.&lt;/p&gt;
&lt;p&gt;This is the part of the &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base/" &gt;signing series&lt;/a&gt;
where the chain stops being a set of commands you run by hand and becomes
something the pipeline does for you. We&amp;rsquo;re using &lt;a class="link" href="https://goreleaser.com/" target="_blank" rel="noopener"
 &gt;GoReleaser&lt;/a&gt;,
which already builds your binaries, writes a checksums file and cuts the
release. It needs one extra trick: sign the checksums on the way out, through
the KMS key, using credentials that only exist for the length of the job. And
you don&amp;rsquo;t hand-write that trick into your release config. &lt;code&gt;gtb&lt;/code&gt; does.&lt;/p&gt;
&lt;p&gt;You&amp;rsquo;ll need a working GoReleaser setup releasing a go-tool-base CLI, plus the
KMS key, the signer role and the embedded public key from the earlier parts.&lt;/p&gt;
&lt;h2 id="one-command-wires-the-signing-in"&gt;One command wires the signing in
&lt;/h2&gt;&lt;p&gt;In &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/" &gt;Part 5&lt;/a&gt;
you ran &lt;code&gt;gtb enable signing&lt;/code&gt; to turn on the &lt;em&gt;verifying&lt;/em&gt; side: embed the key,
check every update against it. Now you give that same command the key the
release pipeline should &lt;em&gt;sign&lt;/em&gt; with, and it wires the producing side too:&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 &lt;span class="nb"&gt;enable&lt;/span&gt; signing --key-id alias/acme-release-signing-v1
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That records the KMS key in your &lt;code&gt;.gtb/manifest.yaml&lt;/code&gt; and regenerates
&lt;code&gt;.goreleaser.yaml&lt;/code&gt; with a &lt;code&gt;signs:&lt;/code&gt; block that calls &lt;code&gt;gtb sign&lt;/code&gt;. The WKD email
and everything else you set in Part 5 stay exactly as they were; you&amp;rsquo;re adding
the key, not starting over. Because the block is generated, you don&amp;rsquo;t hand-edit
the release config any more than you hand-edit the embed wiring. Change the key
later (a new region, a rotated alias) and you re-run the command, not the YAML.&lt;/p&gt;
&lt;p&gt;The generated block looks like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;signs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;checksums&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;gtb&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;--ci&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;sign&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;--backend&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;aws-kms&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;--kms-region&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;eu-west-2&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;--key-id&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;alias/acme-release-signing-v1&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;--public-key&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;internal/trustkeys/keys/signing-key-v1.asc&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;--output&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;${signature}&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;${artifact}&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;artifacts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;checksum&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;${artifact}.sig&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The region defaults to &lt;code&gt;eu-west-2&lt;/code&gt; and the public key to the
&lt;code&gt;internal/trustkeys/keys/signing-key-v1.asc&lt;/code&gt; you embedded in Part 5; pass
&lt;code&gt;--kms-region&lt;/code&gt; or &lt;code&gt;--public-key&lt;/code&gt; if yours differ. The backend defaults to
&lt;code&gt;aws-kms&lt;/code&gt;, which is the one that matters in CI.&lt;/p&gt;
&lt;h2 id="why-only-the-checksums"&gt;Why only the checksums
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;artifacts: checksum&lt;/code&gt; is the line doing the load-bearing work. It tells
GoReleaser to run the signing command once, over the checksums manifest only,
not over every binary and archive in the release.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s why that&amp;rsquo;s enough. GoReleaser builds your binaries, then writes a
&lt;code&gt;checksums.txt&lt;/code&gt; listing the SHA-256 of each one. Every artefact in the release
is named in that file by its hash. So if you sign the manifest, you&amp;rsquo;ve
transitively vouched for everything it lists: change a single byte of any
binary and its hash no longer matches the line in &lt;code&gt;checksums.txt&lt;/code&gt;, and the
moment you alter &lt;code&gt;checksums.txt&lt;/code&gt; to cover for that, the signature over it
breaks. One signature, the entire release covered, through the hash chain. The
per-binary build stays completely untouched, which keeps reproducible builds
reproducible. &lt;code&gt;signature: &amp;quot;${artifact}.sig&amp;quot;&lt;/code&gt; names the output
&lt;code&gt;checksums.txt.sig&lt;/code&gt;, the detached, ASCII-armored OpenPGP signature your tool
looks for on every self-update.&lt;/p&gt;
&lt;h2 id="no-shim-just-gtb"&gt;No shim, just &lt;code&gt;gtb&lt;/code&gt;
&lt;/h2&gt;&lt;p&gt;If you go reading go-tool-base&amp;rsquo;s own release config, you&amp;rsquo;ll find its &lt;code&gt;signs:&lt;/code&gt;
block points at a &lt;code&gt;scripts/sign-release.sh&lt;/code&gt; shim rather than calling &lt;code&gt;gtb&lt;/code&gt;
directly. Yours doesn&amp;rsquo;t, and the difference is worth understanding.&lt;/p&gt;
&lt;p&gt;go-tool-base is signing the very binary it&amp;rsquo;s in the middle of building, so it
can&amp;rsquo;t use an installed &lt;code&gt;gtb&lt;/code&gt; to do it. Its shim runs &lt;code&gt;go run ./cmd/gtb&lt;/code&gt; to
build a throwaway signer from source. It also reads the key id, public key and
region from environment variables, because the one config has to serve every
build. Your tool has neither problem. It calls the &lt;code&gt;gtb&lt;/code&gt; you already installed,
and the generator already knows your key id, region and public-key path because
you just told it. So there&amp;rsquo;s nothing for a shim to abstract: the whole
invocation goes straight into &lt;code&gt;args:&lt;/code&gt;, where you can read it.&lt;/p&gt;
&lt;p&gt;The one thing you do still need is &lt;code&gt;gtb&lt;/code&gt; on the release runner&amp;rsquo;s &lt;code&gt;PATH&lt;/code&gt;.
Install it in a &lt;code&gt;before_script&lt;/code&gt;, or bake it into your CI image, the same way
you would any other release tool.&lt;/p&gt;
&lt;h2 id="where-the-credentials-come-from"&gt;Where the credentials come from
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;gtb sign&lt;/code&gt; asks KMS to sign. KMS will only oblige if the caller has
credentials, and the whole point of &lt;a class="link" href="https://phpboyscout.uk/no-access-keys-in-ci/" &gt;keyless CI&lt;/a&gt;
is that there are no stored credentials to leak. So they&amp;rsquo;re minted on the fly.&lt;/p&gt;
&lt;p&gt;On GitLab, the release job declares an &lt;code&gt;id_tokens:&lt;/code&gt; block. GitLab injects a
short-lived OIDC token (a JWT) into the job, the &lt;code&gt;before_script&lt;/code&gt; writes it to a
file, and the AWS SDK&amp;rsquo;s default credential chain picks it up from there. No
&lt;code&gt;aws&lt;/code&gt; CLI call, no &lt;code&gt;assume-role-with-web-identity&lt;/code&gt; you write yourself: set
&lt;code&gt;AWS_ROLE_ARN&lt;/code&gt; and &lt;code&gt;AWS_WEB_IDENTITY_TOKEN_FILE&lt;/code&gt; and the SDK does the
web-identity exchange the first time &lt;code&gt;gtb sign&lt;/code&gt; touches KMS:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;goreleaser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;AWS_WEB_IDENTITY_TOKEN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;sts.amazonaws.com&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;AWS_REGION&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;eu-west-2&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;AWS_ROLE_ARN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;arn:aws:iam::…:role/acme-release-signing-v1-signer&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;AWS_WEB_IDENTITY_TOKEN_FILE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/tmp/oidc-token&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;before_script&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;echo &amp;#34;$AWS_WEB_IDENTITY_TOKEN&amp;#34; &amp;gt; &amp;#34;$AWS_WEB_IDENTITY_TOKEN_FILE&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The key id and region live in the generated &lt;code&gt;signs:&lt;/code&gt; block now, not here, so CI
only supplies the AWS credentials. &lt;code&gt;AWS_REGION&lt;/code&gt; still earns its place: the
&lt;code&gt;--kms-region&lt;/code&gt; flag points the KMS client at the key, while the SDK uses
&lt;code&gt;AWS_REGION&lt;/code&gt; for the STS exchange that mints the credentials in the first place.
The &lt;code&gt;aud&lt;/code&gt; has to match the audience the signer role&amp;rsquo;s trust policy expects
(Part 3 set this up; for the OIDC provider go-tool-base uses, that&amp;rsquo;s
&lt;code&gt;sts.amazonaws.com&lt;/code&gt;). On GitHub the moving parts are the same, you just let
&lt;code&gt;aws-actions/configure-aws-credentials&lt;/code&gt; do the token-to-credentials dance
instead of writing the file yourself.&lt;/p&gt;
&lt;p&gt;Those credentials are scoped tight. The signer role&amp;rsquo;s trust policy pins it to
this project&amp;rsquo;s tag pipelines, so even if the role ARN leaked, nothing but a
release tag on your repo can assume it.&lt;/p&gt;
&lt;h2 id="dont-sign-when-theres-nothing-to-sign-with"&gt;Don&amp;rsquo;t sign when there&amp;rsquo;s nothing to sign with
&lt;/h2&gt;&lt;p&gt;A local &lt;code&gt;goreleaser release --snapshot&lt;/code&gt;, or a CI run that isn&amp;rsquo;t a release, has
no OIDC token and no business reaching for KMS. GoReleaser is told to skip the
whole signing step in that case: the release job runs with &lt;code&gt;--skip=sign&lt;/code&gt; unless
the web-identity token is present. So a non-release build never so much as
looks at KMS, which is exactly what you want when you&amp;rsquo;re iterating on your
laptop. The signing only fires on the real thing.&lt;/p&gt;
&lt;h2 id="cut-a-release"&gt;Cut a release
&lt;/h2&gt;&lt;p&gt;With all of that in place, releasing is one push:&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;git tag v1.4.0
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git push origin v1.4.0
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The tag pipeline fires. GoReleaser builds the binaries, writes &lt;code&gt;checksums.txt&lt;/code&gt;,
calls &lt;code&gt;gtb sign&lt;/code&gt;, &lt;code&gt;gtb&lt;/code&gt; asks KMS to sign over OIDC, and &lt;code&gt;checksums.txt.sig&lt;/code&gt;
lands next to the manifest. Both get attached to the release. Nobody typed a
signing command and no private key was anywhere near the runner.&lt;/p&gt;
&lt;p&gt;&lt;img alt="Wiring the release signing with gtb enable signing --key-id" class="gallery-image" data-flex-basis="360px" data-flex-grow="150" height="800" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-6/demo-sign-release.gif" width="1200"&gt;
&lt;/p&gt;
&lt;h2 id="a-two-person-gate-if-you-want-one"&gt;A two-person gate, if you want one
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a window worth thinking about: a compromised CI runner during a release
could, in principle, ride the OIDC credentials to get one malicious thing
signed. You can shut that window with an approval gate in front of the signing
job. On GitLab, a protected environment with a required approval (or a manual
job) makes the release wait for a second pair of eyes; on GitHub, &amp;ldquo;required
reviewers&amp;rdquo; on the release environment does the same. It&amp;rsquo;s optional, and it adds
friction to every release, so weigh it against how exposed your runners are. For
a lot of projects the OIDC scoping alone is enough; for anything where a forged
release would be a genuine incident, the gate is cheap insurance.&lt;/p&gt;
&lt;h2 id="where-this-leaves-you"&gt;Where this leaves you
&lt;/h2&gt;&lt;p&gt;Every release you cut from here on carries a signature made by a key you control
and verifiable by anyone, and you got there by pushing a tag. That closes the
loop the series has been building toward: the production side now actually
&lt;em&gt;produces&lt;/em&gt; signed releases.&lt;/p&gt;
&lt;p&gt;Which means you can finally pull the trigger on the bit Part 5 left primed. We
embedded the public key and left enforcement off, because turning it on before
you ship signatures would brick every update. Now that signatures are shipping,
&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-7/" &gt;Part 7&lt;/a&gt;
deals with the part everyone skips and nobody can afford to: rotating the key,
and getting yourself out of trouble if one ever goes bad, without locking your
users out.&lt;/p&gt;</description></item><item><title>Sign your own binaries with go-tool-base, part 5: embed the key and require verification</title><link>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/</link><pubDate>Sun, 21 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/</guid><description>&lt;img src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/cover-sign-your-own-binaries-with-go-tool-base-part-5.png" alt="Featured image of post Sign your own binaries with go-tool-base, part 5: embed the key and require verification" /&gt;&lt;p&gt;By now you&amp;rsquo;ve got a public key your tool can publish off-platform: minted from a
KMS-held private key in &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-4/" &gt;Part 4&lt;/a&gt;
and served over WKD. That&amp;rsquo;s half the trust loop. The other half lives inside the
binary itself: the tool has to &lt;em&gt;hold a copy of the key it expects&lt;/em&gt; so that when an
update lands, it can check the signature against something an attacker who owns the
release page can&amp;rsquo;t quietly swap. This part bakes that trust anchor in, wires the
self-updater to use it, and turns enforcement on without locking out the people who
already have your tool installed.&lt;/p&gt;
&lt;p&gt;That last clause is the one that bites, so we&amp;rsquo;ll come to it slowly. First, turning
signing on.&lt;/p&gt;
&lt;h2 id="enable-signing-with-one-command"&gt;Enable signing with one command
&lt;/h2&gt;&lt;p&gt;Your root command is generated by &lt;code&gt;gtb&lt;/code&gt;, so you don&amp;rsquo;t wire signing in by hand-editing
it. You turn the feature on and let the generator do the wiring:&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 &lt;span class="nb"&gt;enable&lt;/span&gt; signing --email release@acme.dev
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That one command does three things, all in generated, regenerable code you never touch:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;scaffolds an &lt;code&gt;internal/trustkeys&lt;/code&gt; package that &lt;code&gt;//go:embed&lt;/code&gt;s your release keys and
hands them to the self-updater;&lt;/li&gt;
&lt;li&gt;wires &lt;code&gt;Signing: props.SigningConfig{EmbeddedKeys: trustkeys.Keys()}&lt;/code&gt; into your
generated root command;&lt;/li&gt;
&lt;li&gt;writes a &lt;code&gt;signing.go&lt;/code&gt; holding the enforcement defaults, generated from a &lt;code&gt;signing&lt;/code&gt;
block it adds to your &lt;code&gt;.gtb/manifest.yaml&lt;/code&gt;. You change posture by re-running the
command, never by editing the file.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img alt="Enabling signing on a generated project with gtb enable signing" class="gallery-image" data-flex-basis="360px" data-flex-grow="150" height="800" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/demo-enable-signing.gif" width="1200"&gt;
&lt;/p&gt;
&lt;p&gt;Signing is off until you run this, on purpose: it needs a key and a published WKD
endpoint, so a freshly generated tool doesn&amp;rsquo;t carry it uninvited. (If you&amp;rsquo;re curious
what the embed package looks like, it&amp;rsquo;s a small &lt;code&gt;//go:embed all:keys&lt;/code&gt; over an
&lt;code&gt;internal/trustkeys/keys/&lt;/code&gt; directory with a &lt;code&gt;Keys() [][]byte&lt;/code&gt; accessor. The &lt;code&gt;all:&lt;/code&gt;
prefix is load-bearing: a plain &lt;code&gt;//go:embed keys&lt;/code&gt; won&amp;rsquo;t compile over a directory that
holds only a dotfile, so the scaffold keeps a &lt;code&gt;.gitkeep&lt;/code&gt; there. You don&amp;rsquo;t write any of
it.)&lt;/p&gt;
&lt;h2 id="drop-your-key-in"&gt;Drop your key in
&lt;/h2&gt;&lt;p&gt;The scaffold gives you an empty &lt;code&gt;internal/trustkeys/keys/&lt;/code&gt;. Put the public key you
minted in Part 4 into it, alongside the break-glass key:&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;cp signing-key-v1.asc internal/trustkeys/keys/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cp rotation-authority.asc internal/trustkeys/keys/
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;-v1&lt;/code&gt; in the filename isn&amp;rsquo;t decoration: you&amp;rsquo;ll rotate one day and embed
&lt;code&gt;signing-key-v2.asc&lt;/code&gt; alongside it for a release or two (Part 7). The rotation-authority
key rides along the same way. Rebuild, and &lt;code&gt;trustkeys.Keys()&lt;/code&gt; now returns them. With no
&lt;code&gt;.asc&lt;/code&gt; present it returns nothing and verification stays dormant, so enabling signing
before you have a key breaks nothing.&lt;/p&gt;
&lt;h2 id="the-wkd-cross-check-via---email"&gt;The WKD cross-check, via &lt;code&gt;--email&lt;/code&gt;
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;--email&lt;/code&gt; you passed is doing real work. The embedded key alone is a static anchor
(&lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;how the verification works&lt;/a&gt;):
it only ever says &amp;ldquo;this was the key on the day I was built.&amp;rdquo; Pairing it with the live
WKD copy you published in Part 4 gives a second, independent source the release platform
can&amp;rsquo;t reach. The default key source is &lt;code&gt;both&lt;/code&gt;, an embedded-plus-WKD &lt;code&gt;CompositeResolver&lt;/code&gt;,
and the email is what lets the updater derive the WKD URL. Leave &lt;code&gt;--email&lt;/code&gt; off and
&lt;code&gt;both&lt;/code&gt; quietly degrades to embedded-only.&lt;/p&gt;
&lt;p&gt;For a locked-down tool that should refuse to update when it &lt;em&gt;can&amp;rsquo;t&lt;/em&gt; reach WKD, rather
than fall back to the embedded key, enable the strict cross-check:&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 &lt;span class="nb"&gt;enable&lt;/span&gt; signing --email release@acme.dev --require-external-crosscheck
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Most tools want the softer default, where WKD strengthens verification when it&amp;rsquo;s
reachable and the embedded key still works when it isn&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="confirm-the-cross-check-is-actually-firing"&gt;Confirm the cross-check is actually firing
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s easy to get this wrong by leaving the email out, and when you do, nothing
complains: the updater quietly verifies against the embedded key alone and carries
on. The way to know which anchors were actually consulted is to read the log. Every
update prints a line naming the resolver it used:&lt;/p&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 signature verified resolver=composite[embedded,wkd:openpgpkey.acme.dev]
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That &lt;code&gt;resolver=&lt;/code&gt; field is the whole tell:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;composite[embedded,wkd:...]&lt;/code&gt; is what you want: both the embedded key and the
WKD-served key were fetched, their fingerprints agreed, and the signature checked
against the result. The cross-check is live.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;embedded&lt;/code&gt; means only the baked-in key was used and WKD was never consulted.
That&amp;rsquo;s the silent-degrade trap: &lt;code&gt;key_source&lt;/code&gt; is &lt;code&gt;&amp;quot;both&amp;quot;&lt;/code&gt;, but with no external
email there was no WKD URL to derive, so it fell back to a single anchor. If you
see this after passing &lt;code&gt;--email&lt;/code&gt;, the value didn&amp;rsquo;t take.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;wkd:...&lt;/code&gt; on its own is the reverse: WKD was consulted but nothing was embedded.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There&amp;rsquo;s a matching &lt;code&gt;update signature verification configured resolver=...&lt;/code&gt; line at
the very start of an update, before any network call, if you&amp;rsquo;d rather see the choice
before the fetch. Two failure shapes are worth recognising too.
&lt;code&gt;WARN composite resolver failed (RequireAll=false, continuing)&lt;/code&gt; means the WKD fetch
fell over (a 404, a flaky network) and the update carried on against the embedded key
alone, the soft default you can harden with &lt;code&gt;--require-external-crosscheck&lt;/code&gt;.
&lt;code&gt;ERROR ErrKeyResolverMismatch&lt;/code&gt; is the one you &lt;em&gt;want&lt;/em&gt; to see fire in anger: the
embedded and WKD keys disagreed, which is exactly the tamper alarm the whole scheme
exists to raise (the same mismatch the
&lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;verification deep-dive&lt;/a&gt;
walks through).&lt;/p&gt;
&lt;h2 id="require-it-in-the-right-order"&gt;Require it, in the right order
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the part everyone trips over. Enabling signing does not yet &lt;em&gt;require&lt;/em&gt; it:
&lt;code&gt;require_signature&lt;/code&gt; stays off, and that&amp;rsquo;s deliberate. Turning it on too early breaks
self-update for everyone who already has your tool.&lt;/p&gt;
&lt;p&gt;Think about what an existing install holds. A user on an old version has a binary built
&lt;em&gt;before&lt;/em&gt; you enabled signing. It has no trust anchor. If the first thing it ever sees
is a signed, signature-required release, it has nothing to check the signature against,
and the update is refused. You&amp;rsquo;ve locked out exactly the people you were protecting.&lt;/p&gt;
&lt;p&gt;The fix is to ship the key ahead of the requirement, so the anchor is already on the
user&amp;rsquo;s machine by the time the first mandatory signature arrives. gtb follows the
rollout in its own &lt;code&gt;docs/development/phase2-signing-prep.md&lt;/code&gt;, across three releases:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;Ship the key before you require the signature, or you lock out every install that
predates it.&lt;/strong&gt; In order:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Release N+1.&lt;/strong&gt; Enable signing (above) and ship it, with &lt;code&gt;require_signature&lt;/code&gt; off.
Existing installs pull this update on checksum alone and pick up the embedded key
as a side effect.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Release N+2.&lt;/strong&gt; Ship your first &lt;em&gt;signed&lt;/em&gt; release (Part 6 wires GoReleaser). Still
not required: the signature is verified when present but not enforced, so nothing
breaks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Release N+3.&lt;/strong&gt; Now, and only now, turn enforcement on.&lt;/li&gt;
&lt;/ul&gt;

 &lt;/blockquote&gt;
&lt;p&gt;When you reach N+3, it&amp;rsquo;s one command, not a code edit:&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 &lt;span class="nb"&gt;enable&lt;/span&gt; signing --require-signature
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That flips &lt;code&gt;require_signature&lt;/code&gt; in the manifest and regenerates &lt;code&gt;signing.go&lt;/code&gt;, so the
change is tracked and reproducible. Skipping the middle release, requiring a signature
before you&amp;rsquo;ve shipped one, is the exact mistake the ordering exists to prevent; gtb&amp;rsquo;s
own rollout cites v0.12.2 as its first signed release for precisely this reason.&lt;/p&gt;
&lt;p&gt;The checksum floor from Phase 1 sits underneath all this and is already on; signature
verification adds to it, it doesn&amp;rsquo;t replace it. And even with signatures required, an
end user genuinely stuck on a legacy release can escape with the &lt;code&gt;update.require_signature&lt;/code&gt;
config key or your tool&amp;rsquo;s &lt;code&gt;&amp;lt;PREFIX&amp;gt;_UPDATE_REQUIRE_SIGNATURE=false&lt;/code&gt;. It&amp;rsquo;s an escape
hatch, not the front door, but it means a requirement you turn on can&amp;rsquo;t permanently
strand anyone.&lt;/p&gt;
&lt;h2 id="where-this-leaves-you"&gt;Where this leaves you
&lt;/h2&gt;&lt;p&gt;Your binary now carries the key it expects, checks every update against that key and
its live WKD twin, and refuses anything that doesn&amp;rsquo;t match, all without a &lt;code&gt;gpg&lt;/code&gt;
install on the user&amp;rsquo;s side and without stranding the installs that came before the key.
The trust loop you built by hand in Part 1 now runs on its own, inside a stranger&amp;rsquo;s
copy of your tool.&lt;/p&gt;
&lt;p&gt;The one thing still missing is the signature itself on each release. Right now nothing
is actually &lt;em&gt;producing&lt;/em&gt; &lt;code&gt;checksums.txt.sig&lt;/code&gt;. &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-6/" &gt;Part 6&lt;/a&gt;
wires the KMS signing from Parts 2 and 3 into a real GoReleaser pipeline, so every
tagged release comes out signed without you touching a key.&lt;/p&gt;</description></item><item><title>Sign your own binaries with go-tool-base, part 4: mint and publish your public key</title><link>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-4/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-4/</guid><description>&lt;img src="https://phpboyscout.uk/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://phpboyscout.uk/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://phpboyscout.uk/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://phpboyscout.uk/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://phpboyscout.uk/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="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-7/" &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://phpboyscout.uk/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://phpboyscout.uk/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="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/" &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><item><title>Sign your own binaries with go-tool-base, part 3: keyless CI signing with OIDC</title><link>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-3/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-3/</guid><description>&lt;img src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-3/cover-sign-your-own-binaries-with-go-tool-base-part-3.png" alt="Featured image of post Sign your own binaries with go-tool-base, part 3: keyless CI signing with OIDC" /&gt;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-2/" &gt;Part 2&lt;/a&gt;
left you with a KMS key your release pipeline can sign through and a role
(&lt;code&gt;&amp;lt;name&amp;gt;-signer&lt;/code&gt;) that&amp;rsquo;s allowed to call &lt;code&gt;kms:Sign&lt;/code&gt; and nothing else. There&amp;rsquo;s
one obvious question left hanging: how does a CI job &lt;em&gt;become&lt;/em&gt; that role without
an AWS access key stashed in a CI variable? Because a long-lived key sitting in
your settings is exactly the kind of credential that ends up in a breach
write-up. This part wires CI in with no stored credentials at all, on GitLab
&lt;em&gt;and&lt;/em&gt; GitHub.&lt;/p&gt;
&lt;p&gt;The mechanism is OIDC federation, and the one-sentence version is: your CI
platform already proves who it is to AWS, so let it. When a pipeline runs,
GitLab or GitHub can hand the job a short-lived signed token (a JWT) that says
&amp;ldquo;this is a tag pipeline on &lt;code&gt;acme/acme-cli&lt;/code&gt;&amp;rdquo;. AWS trusts that token the same way
a bouncer trusts a passport: it checks who issued it and what it says, and if
the claims match a role&amp;rsquo;s trust policy, it swaps the token for temporary AWS
credentials that live only for the job&amp;rsquo;s run. No key is stored anywhere; the
credentials are minted on the spot and evaporate when the runner stops. The
&lt;a class="link" href="https://phpboyscout.uk/no-access-keys-in-ci/" &gt;deep-dive on keyless CI&lt;/a&gt;
covers the why and the threat model; here we just do the wiring.&lt;/p&gt;
&lt;p&gt;Two things have to line up for this to work: the IAM OIDC &lt;em&gt;identity provider&lt;/em&gt;
(the thing in your AWS account that says &amp;ldquo;I trust tokens from this issuer&amp;rdquo;),
and the signer role&amp;rsquo;s &lt;em&gt;trust policy&lt;/em&gt; (which says &amp;ldquo;and only from these
pipelines&amp;rdquo;). The &lt;code&gt;terraform-aws-signing-kms&lt;/code&gt; module owns the second. The first
comes from a sibling module.&lt;/p&gt;
&lt;h2 id="where-the-identity-provider-comes-from"&gt;Where the identity provider comes from
&lt;/h2&gt;&lt;p&gt;You don&amp;rsquo;t register the OIDC provider in the signing module. That&amp;rsquo;s deliberate:
the same provider is shared across every role in the account that federates
from CI, so it lives one level up, in
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt;&lt;/a&gt;.
It takes a &lt;code&gt;ci_provider&lt;/code&gt; input (&lt;code&gt;github&lt;/code&gt; by default, or &lt;code&gt;gitlab&lt;/code&gt;), registers the
right IAM OIDC identity provider for that forge, and emits its ARN. You feed
that ARN into the signing module. If you ran the bootstrap in Part 2 you already
have it; if not, stand it up first and grab the &lt;code&gt;oidc_provider_arn&lt;/code&gt; output.&lt;/p&gt;
&lt;p&gt;That ARN is the only thing the two modules need to agree on. Everything else
about &lt;em&gt;which&lt;/em&gt; pipelines may sign lives in the signing module&amp;rsquo;s trust policy,
which is what the rest of this part configures.&lt;/p&gt;
&lt;h2 id="the-gitlab-path"&gt;The GitLab path
&lt;/h2&gt;&lt;p&gt;Look the provider up by URL so you never hardcode the ARN, then pass it in along
with the subject filter that scopes who can assume the role:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;aws_iam_openid_connect_provider&amp;#34; &amp;#34;gitlab&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;https://gitlab.com&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;signing_kms&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;gitlab.com/phpboyscout/signing-kms/aws&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;0.1.2&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;acme-release-signing-v1&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; oidc_provider_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;aws_iam_openid_connect_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;gitlab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;arn&lt;/span&gt;&lt;span class="c1"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # oidc_issuer_host defaults to &amp;#34;gitlab.com&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # oidc_audience defaults to &amp;#34;sts.amazonaws.com&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; ci_subject_filters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;project_path:acme/acme-cli:ref_type:tag:ref:v*&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; key_administrator_arns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="cm"&gt;/* ... from Part 2 ... */&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; automation_role_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;automation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;arn&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The interesting line is &lt;code&gt;ci_subject_filters&lt;/code&gt;. GitLab stamps each CI token&amp;rsquo;s
&lt;code&gt;sub&lt;/code&gt; claim with the project path, the ref type and the ref. The pattern above
reads as &amp;ldquo;tag pipelines on &lt;code&gt;acme/acme-cli&lt;/code&gt;, for any ref starting &lt;code&gt;v&lt;/code&gt;&amp;rdquo;. A branch
pipeline or a merge-request pipeline carries &lt;code&gt;ref_type:branch&lt;/code&gt; instead, so it
simply doesn&amp;rsquo;t match, and the role refuses to be assumed. Your signer can only
be driven from a release tag, which is the whole point: a dependency author
opening an MR can&amp;rsquo;t trick CI into minting a signature.&lt;/p&gt;
&lt;p&gt;On the pipeline side, the release job declares an &lt;code&gt;id_tokens&lt;/code&gt; block so GitLab
issues a token with the right audience, writes it to a file, and the AWS SDK
picks it up:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;goreleaser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;if&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;AWS_WEB_IDENTITY_TOKEN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;sts.amazonaws.com&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;AWS_REGION&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;eu-west-2&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;AWS_ROLE_ARN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;${SIGNER_ROLE_ARN}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;AWS_WEB_IDENTITY_TOKEN_FILE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/tmp/aws-token&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;before_script&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;echo &amp;#34;$AWS_WEB_IDENTITY_TOKEN&amp;#34; &amp;gt; /tmp/aws-token&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;script&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;aws sts get-caller-identity&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;goreleaser release --clean&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;AWS_ROLE_ARN&lt;/code&gt; plus &lt;code&gt;AWS_WEB_IDENTITY_TOKEN_FILE&lt;/code&gt; is the convention the SDK
recognises: it sees the two together and calls &lt;code&gt;assume-role-with-web-identity&lt;/code&gt;
for you, so by the time &lt;code&gt;goreleaser&lt;/code&gt; runs it&amp;rsquo;s already the signer role. The
actual signing job is Part 6; the &lt;code&gt;aws sts get-caller-identity&lt;/code&gt; line is just a
sanity check that federation worked. It should print the signer role&amp;rsquo;s ARN.&lt;/p&gt;
&lt;!-- VHS: clip of a GitLab tag pipeline running `aws sts get-caller-identity` and printing the federated signer-role ARN, against a real install --&gt;
&lt;h2 id="the-github-path"&gt;The GitHub path
&lt;/h2&gt;&lt;p&gt;Same shape, different issuer and a different &lt;code&gt;sub&lt;/code&gt; format. GitHub&amp;rsquo;s &lt;code&gt;sub&lt;/code&gt;
support landed in module &lt;strong&gt;v0.1.2&lt;/strong&gt;: earlier versions validated
&lt;code&gt;ci_subject_filters&lt;/code&gt; against GitLab&amp;rsquo;s format only and would &lt;em&gt;reject&lt;/em&gt; a GitHub
subject outright, so pin the version.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;aws_iam_openid_connect_provider&amp;#34; &amp;#34;github&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;https://token.actions.githubusercontent.com&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;signing_kms&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;gitlab.com/phpboyscout/signing-kms/aws&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;0.1.2&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;acme-release-signing-v1&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; oidc_provider_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;aws_iam_openid_connect_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;github&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;arn&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; oidc_issuer_host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;token.actions.githubusercontent.com&amp;#34;&lt;/span&gt;&lt;span class="c1"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # oidc_audience still defaults to &amp;#34;sts.amazonaws.com&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; ci_subject_filters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;repo:acme/acme-cli:ref:refs/tags/v*&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; key_administrator_arns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; automation_role_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;automation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;arn&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Two differences from GitLab. The &lt;code&gt;oidc_issuer_host&lt;/code&gt; has to change, because it&amp;rsquo;s
the prefix on the trust-policy condition keys (&lt;code&gt;token.actions.githubusercontent.com:sub&lt;/code&gt;
rather than &lt;code&gt;gitlab.com:sub&lt;/code&gt;). And the subject format is GitHub&amp;rsquo;s own:
&lt;code&gt;repo:&amp;lt;owner&amp;gt;/&amp;lt;repo&amp;gt;:ref:refs/tags/v*&lt;/code&gt; scopes the same way the GitLab pattern
did, to tag refs only. The audience stays &lt;code&gt;sts.amazonaws.com&lt;/code&gt;, because that&amp;rsquo;s
the default &lt;code&gt;aws-actions/configure-aws-credentials&lt;/code&gt; requests, so there&amp;rsquo;s nothing
to override.&lt;/p&gt;
&lt;p&gt;The workflow side is the official AWS action. It needs &lt;code&gt;id-token: write&lt;/code&gt;
permission to ask GitHub for the token in the first place, and &lt;code&gt;contents: write&lt;/code&gt;
so GoReleaser can create the release:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id-token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;write&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;write&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;release&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;runs-on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;ubuntu-latest&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/checkout@v4&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;role-to-assume&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;${{ secrets.SIGNER_ROLE_ARN }}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;aws-region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;eu-west-2&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;sts.amazonaws.com&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;aws sts get-caller-identity&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;goreleaser release --clean&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If you forget &lt;code&gt;id-token: write&lt;/code&gt;, GitHub never issues a token and the action
fails before it reaches AWS. It&amp;rsquo;s the most common GitHub-side trip-up.&lt;/p&gt;
&lt;h2 id="the-gotcha-that-wastes-an-afternoon-token-audience"&gt;The gotcha that wastes an afternoon: token audience
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the one worth burning into memory, because the error message points you
at the wrong thing. An IAM OIDC provider carries a &lt;code&gt;client_id_list&lt;/code&gt;, and it
rejects any token whose &lt;code&gt;aud&lt;/code&gt; claim isn&amp;rsquo;t on that list &lt;em&gt;before AWS even looks at
the role&amp;rsquo;s trust policy&lt;/em&gt;. So if your CI token&amp;rsquo;s audience and the provider&amp;rsquo;s
client ID don&amp;rsquo;t match, you get:&lt;/p&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;InvalidIdentityToken: Incorrect token audience
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That reads like a role-trust problem, and you&amp;rsquo;ll go round in circles editing
&lt;code&gt;ci_subject_filters&lt;/code&gt;, but the trust policy was never consulted. The fix is to
keep the audience aligned to &lt;code&gt;sts.amazonaws.com&lt;/code&gt; everywhere: it&amp;rsquo;s the module&amp;rsquo;s
&lt;code&gt;oidc_audience&lt;/code&gt; default, it&amp;rsquo;s what &lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; puts on the
provider&amp;rsquo;s &lt;code&gt;client_id_list&lt;/code&gt;, it&amp;rsquo;s the &lt;code&gt;aud&lt;/code&gt; in the GitLab &lt;code&gt;id_tokens&lt;/code&gt; block, and
it&amp;rsquo;s the default &lt;code&gt;aws-actions/configure-aws-credentials&lt;/code&gt; requests. Leave all
four alone and they agree by default. The moment you override one, override all
of them, or you&amp;rsquo;ll meet that error.&lt;/p&gt;
&lt;h2 id="where-this-leaves-you"&gt;Where this leaves you
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s now no AWS access key anywhere in either forge. The pipeline proves it&amp;rsquo;s
a release tag on your project, AWS hands it the signer role for the length of
the run, and the credentials are gone the moment the runner stops. The signing
module&amp;rsquo;s trust policy is the gate; the bootstrap module&amp;rsquo;s OIDC provider is the
lock it hangs on. Both are verifiable in the public modules:
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-signing-kms" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-signing-kms&lt;/code&gt;&lt;/a&gt;
for the role and
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt;&lt;/a&gt;
for the provider.&lt;/p&gt;
&lt;p&gt;The role can sign, but you still can&amp;rsquo;t &lt;em&gt;verify&lt;/em&gt; anything yet, because nobody has
the public half of that KMS key. &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-4/" &gt;Part 4&lt;/a&gt;
fixes that: &lt;code&gt;gtb keys mint&lt;/code&gt; pulls the public key straight out of KMS, and &lt;code&gt;gtb keys wkd&lt;/code&gt; publishes it somewhere the release platform can&amp;rsquo;t touch.&lt;/p&gt;</description></item><item><title>Sign your own binaries with go-tool-base, part 2: a signing key in AWS KMS</title><link>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-2/</link><pubDate>Wed, 17 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-2/</guid><description>&lt;img src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-2/cover-sign-your-own-binaries-with-go-tool-base-part-2.png" alt="Featured image of post Sign your own binaries with go-tool-base, part 2: a signing key in AWS KMS" /&gt;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-1/" &gt;Part 1&lt;/a&gt;
left you with a working signing loop and one glaring weakness: the private key
was a &lt;code&gt;.pem&lt;/code&gt; on your laptop, and files get copied. This part fixes that. You&amp;rsquo;ll
generate the production signing key inside AWS KMS, where it&amp;rsquo;s created and never
comes out, and stand up a role that can sign with it. The key itself is the only
thing we build here; wiring CI in over OIDC is Part 3.&lt;/p&gt;
&lt;p&gt;The big idea is the same one the
&lt;a class="link" href="https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/" &gt;deep-dive&lt;/a&gt;
spends its whole length on, so I&amp;rsquo;ll keep it short: you never hold the private
key and you never sign &lt;em&gt;with&lt;/em&gt; it. You ask KMS to sign on your behalf with
&lt;code&gt;kms:Sign&lt;/code&gt;, and the private half stays inside the HSM for its entire life. There
is no export, no download, no &amp;ldquo;just this once&amp;rdquo; copy onto a runner. An attacker
who owns your CI still can&amp;rsquo;t walk away with the key, because the key was never on
the runner to begin with.&lt;/p&gt;
&lt;h2 id="what-youll-need-first"&gt;What you&amp;rsquo;ll need first
&lt;/h2&gt;&lt;p&gt;This part is OpenTofu (or Terraform, the module is fine with either). You&amp;rsquo;ll
need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An AWS account you can apply infrastructure into.&lt;/li&gt;
&lt;li&gt;An IAM OIDC identity provider already registered in that account. KMS doesn&amp;rsquo;t
need it, but the signer role we create trusts it, so it has to exist. If you
haven&amp;rsquo;t got one, the sibling module
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt;&lt;/a&gt;
provisions it (it&amp;rsquo;s the same family as the
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-security-baseline" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-security-baseline&lt;/code&gt;&lt;/a&gt;
module). Its &lt;code&gt;oidc_provider_arn&lt;/code&gt; output is exactly what we feed in below.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You don&amp;rsquo;t need a CLI, a pipeline or a public key yet. This is just the vault and
the key that lives in it.&lt;/p&gt;
&lt;h2 id="the-module"&gt;The module
&lt;/h2&gt;&lt;p&gt;The key, its alias, the signer role and the full key policy come from one public
module,
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-signing-kms" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-signing-kms&lt;/code&gt;&lt;/a&gt;.
Here&amp;rsquo;s the whole consumer block:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;aws_iam_openid_connect_provider&amp;#34; &amp;#34;gitlab&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;https://gitlab.com&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;signing_kms&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;gitlab.com/phpboyscout/signing-kms/aws&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;0.1.2&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;acme-release-signing-v1&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Acme release binary signing&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; oidc_provider_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;aws_iam_openid_connect_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;gitlab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;arn&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; ci_subject_filters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;project_path:acme/acme-cli:ref_type:tag:ref:v*&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; key_administrator_arns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="cm"&gt; /* operator role + account root */&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; automation_role_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;automation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;arn&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}&lt;span class="c1"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Carry these forward: Part 3 wires the signer role into CI, Part 4 mints
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# the public key from the alias.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;signer_role_arn&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;signing_kms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;signer_role_arn&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;signing_key_alias&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;signing_kms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;key_alias_name&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A few of those values are doing more than they look, so let&amp;rsquo;s walk them.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;name&lt;/code&gt; is &lt;code&gt;acme-release-signing-v1&lt;/code&gt;, with the &lt;code&gt;v1&lt;/code&gt; on the end deliberately. The
name derives the role (&lt;code&gt;&amp;lt;name&amp;gt;-signer&lt;/code&gt;) and the alias (&lt;code&gt;alias/&amp;lt;name&amp;gt;&lt;/code&gt;), and both
of those want to outlive the key. When you rotate to a new key in Part 7 you&amp;rsquo;ll
mint a &lt;code&gt;-v2&lt;/code&gt; and repoint things, so bake the version in now rather than wishing
you had.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ci_subject_filters&lt;/code&gt; is in GitLab&amp;rsquo;s OIDC &lt;code&gt;sub&lt;/code&gt; format here, and it&amp;rsquo;s the line
that says &lt;em&gt;which&lt;/em&gt; pipeline is allowed to assume the signer role: tag pipelines
for any &lt;code&gt;v*&lt;/code&gt; ref on &lt;code&gt;acme/acme-cli&lt;/code&gt;, and nothing else (no branch builds, no merge
requests). It&amp;rsquo;s the heart of Part 3, so I&amp;rsquo;ll leave the full explanation there.
For now, know it&amp;rsquo;s not optional: an empty list would trust every token from the
issuer, and the module refuses to let you do that.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;key_administrator_arns&lt;/code&gt; and &lt;code&gt;automation_role_arn&lt;/code&gt; are the two roles that &lt;em&gt;manage&lt;/em&gt;
the key, and the distinction between them matters. More on that in a moment.&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;tofu init
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tofu apply
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="what-you-just-built"&gt;What you just built
&lt;/h2&gt;&lt;p&gt;A single asymmetric KMS key, plus the IAM scaffolding around it. The key is
created with &lt;code&gt;key_usage = SIGN_VERIFY&lt;/code&gt; and the spec &lt;code&gt;RSA_4096&lt;/code&gt;, which is the
module&amp;rsquo;s default and the one you want.&lt;/p&gt;
&lt;p&gt;The obvious question, if you&amp;rsquo;ve signed things before, is why RSA-4096 and not
Ed25519, which is smaller and faster. Two reasons, and neither is preference. The
first is that AWS KMS simply doesn&amp;rsquo;t offer Ed25519 for asymmetric signing, so
it&amp;rsquo;s off the table the moment you decide the key lives in KMS. The second is that
OpenPGP, the format your signatures end up in, ties its packet encoding to the
signing algorithm: the algorithm isn&amp;rsquo;t a detail you can swap underneath, it&amp;rsquo;s
written into the bytes. RSA-4096 is the spec that satisfies both constraints, so
it&amp;rsquo;s the secure default and you shouldn&amp;rsquo;t need to touch it.&lt;/p&gt;
&lt;p&gt;Two more things to note about the key itself:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;enable_key_rotation&lt;/code&gt; is off, and that&amp;rsquo;s intentional. AWS&amp;rsquo;s automatic yearly
rotation only works on symmetric keys; an asymmetric &lt;code&gt;SIGN_VERIFY&lt;/code&gt; key can&amp;rsquo;t be
auto-rotated, because a new key would mean a new public half and every embedded
trust anchor breaking at once. Rotation for signing keys is a deliberate,
staged operation (mint a new key, publish it, repoint the alias), which is its
own part later in the series.&lt;/li&gt;
&lt;li&gt;The deletion window defaults to 30 days, the longest AWS allows. For a key this
important, the longest possible &amp;ldquo;oops, undo&amp;rdquo; window is the safe choice.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The module also creates a stable alias, &lt;code&gt;alias/acme-release-signing-v1&lt;/code&gt;. Always
reference the key through its alias, never the raw key ID. The alias is what
survives rotation: when v2 arrives, the alias gets repointed and everything
calling through it keeps working.&lt;/p&gt;
&lt;h2 id="four-principals-one-key-policy"&gt;Four principals, one key policy
&lt;/h2&gt;&lt;p&gt;This is the part worth slowing down for. A KMS key is governed by its &lt;em&gt;key
policy&lt;/em&gt;, and this module writes one policy that names four classes of principal,
each with deliberately different reach:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The account root&lt;/strong&gt; keeps &lt;code&gt;kms:*&lt;/code&gt;. That&amp;rsquo;s AWS&amp;rsquo;s recommended break-glass: if
every other path is locked out, the account owner can still recover.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The key administrators&lt;/strong&gt; (&lt;code&gt;key_administrator_arns&lt;/code&gt;, typically your operator
role plus root) can administer the key, schedule its deletion, that sort of
thing. They are &lt;em&gt;not&lt;/em&gt; signers, and they can&amp;rsquo;t sign.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The automation role&lt;/strong&gt; (&lt;code&gt;automation_role_arn&lt;/code&gt;, the role your infra apply
pipeline assumes) can manage the key as a Terraform resource, read it, tag it,
even change its policy. What it deliberately &lt;em&gt;cannot&lt;/em&gt; do is &lt;code&gt;kms:Sign&lt;/code&gt;. Think
about why: the role that applies your infrastructure runs on every change, so
if owning that role let an attacker mint signatures, you&amp;rsquo;d have handed the
whole point of the exercise back. Managing the key and using the key are two
different powers, and only the signer gets the second one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The signer role&lt;/strong&gt; can call &lt;code&gt;kms:Sign&lt;/code&gt;, &lt;code&gt;kms:GetPublicKey&lt;/code&gt; and
&lt;code&gt;kms:DescribeKey&lt;/code&gt;, on this one key, and that&amp;rsquo;s the entire list. It can&amp;rsquo;t read
other keys, can&amp;rsquo;t administer this one, can&amp;rsquo;t delete anything.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Here&amp;rsquo;s the detail that catches people out: the signer role has &lt;strong&gt;no attached IAM
policy at all&lt;/strong&gt;. Its permissions live entirely in the key policy. That&amp;rsquo;s not an
oversight, it&amp;rsquo;s the design. One document, the key policy, is the single source of
truth for who can do what to this key, so there&amp;rsquo;s no second place to check and no
way for an attached role policy to drift out of sync with the key policy and
quietly grant something nobody intended. If you want to know who can sign, you
read one file.&lt;/p&gt;
&lt;p&gt;The signer role is assumable via OIDC (those &lt;code&gt;ci_subject_filters&lt;/code&gt; again), which
is what lets a CI job step into it without any stored credential. That federation
is Part 3&amp;rsquo;s whole job.&lt;/p&gt;
&lt;h2 id="the-outputs-youll-carry-forward"&gt;The outputs you&amp;rsquo;ll carry forward
&lt;/h2&gt;&lt;p&gt;The module hands back everything the later parts consume. The ones you&amp;rsquo;ll
actually use:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;key_id&lt;/code&gt; and &lt;code&gt;key_arn&lt;/code&gt;, the bare ID and full ARN of the key.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;key_alias_name&lt;/code&gt; and &lt;code&gt;key_alias_arn&lt;/code&gt;, the stable alias. &lt;code&gt;key_alias_name&lt;/code&gt; is
the one you&amp;rsquo;ll pass to &lt;code&gt;gtb&lt;/code&gt; as the KMS key reference, because it survives
rotation.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;signer_role_arn&lt;/code&gt; and &lt;code&gt;signer_role_name&lt;/code&gt;, the role CI assumes to sign.
&lt;code&gt;signer_role_arn&lt;/code&gt; becomes an environment variable in your pipeline next part.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Keep these to hand. Part 3 needs &lt;code&gt;signer_role_arn&lt;/code&gt;; Part 4 needs
&lt;code&gt;key_alias_name&lt;/code&gt; to mint the public key out of KMS.&lt;/p&gt;
&lt;h2 id="what-this-costs"&gt;What this costs
&lt;/h2&gt;&lt;p&gt;Worth a quick word, because this is the one part of the series that puts a line
on an AWS bill. An asymmetric KMS key runs about a dollar a month, plus a tiny
per-signature charge on the &lt;code&gt;kms:Sign&lt;/code&gt; calls. For release signing, where you sign
a handful of checksums files a month, the per-signature cost rounds to nothing.
A dollar a month for a key that can&amp;rsquo;t be stolen off a laptop is the cheapest
security control in this entire series.&lt;/p&gt;
&lt;h2 id="where-this-leaves-you"&gt;Where this leaves you
&lt;/h2&gt;&lt;p&gt;The key exists, it lives somewhere it can never leave, and there&amp;rsquo;s a role that
can sign with it (and a separate role that pointedly can&amp;rsquo;t). What there &lt;em&gt;isn&amp;rsquo;t&lt;/em&gt;
yet is any way for your release pipeline to become that role without a
long-lived credential sitting in CI.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the gap &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-3/" &gt;Part 3&lt;/a&gt;
closes: federating GitLab and GitHub into the signer role over OIDC, so a tagged
release can assume it for the length of one job and nothing is stored anywhere.
The &lt;code&gt;ci_subject_filters&lt;/code&gt; line we glossed over here is where it starts.&lt;/p&gt;</description></item><item><title>Sign your own binaries with go-tool-base, part 1: sign and verify on your laptop</title><link>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-1/</link><pubDate>Mon, 15 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-1/</guid><description>&lt;img src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-1/cover-sign-your-own-binaries-with-go-tool-base-part-1.png" alt="Featured image of post Sign your own binaries with go-tool-base, part 1: sign and verify on your laptop" /&gt;&lt;p&gt;The quickest way to understand release signing is to do it once, by hand, with
nothing but a key on disk. No cloud account, no CI, no cost. This first part of
the &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base/" &gt;signing series&lt;/a&gt;
walks the whole loop on your laptop: make a key, sign a file, and verify the
signature, including with plain &lt;code&gt;gpg&lt;/code&gt; so you can see it isn&amp;rsquo;t locked to anything
of ours. Everything you learn here maps one-to-one onto the AWS KMS workflow in
the later parts; only &lt;em&gt;where the key lives&lt;/em&gt; changes.&lt;/p&gt;
&lt;p&gt;You&amp;rsquo;ll need the &lt;code&gt;gtb&lt;/code&gt; CLI (&lt;a class="link" href="https://gtb.phpboyscout.uk/installation/" target="_blank" rel="noopener"
 &gt;installation docs&lt;/a&gt;).
Make a scratch directory to work in, because we&amp;rsquo;re going to create a few files.&lt;/p&gt;
&lt;h2 id="make-a-signing-key"&gt;Make a signing key
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;gtb keys generate&lt;/code&gt; creates a keypair entirely inside the process and writes
both halves to disk:&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 rsa &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 &lt;span class="s2"&gt;&amp;#34;release@acme.dev&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; --created &lt;span class="s2"&gt;&amp;#34;2026-06-01T00: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.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=rsa public_output=signing.asc private_output=signing.pem creation_time=2026-06-01T00: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=signing.pem
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Two files come out: &lt;code&gt;signing.asc&lt;/code&gt; (the public half) and &lt;code&gt;signing.pem&lt;/code&gt; (the
private half, a PKCS#1 PEM). The private half is the thing you guard. There&amp;rsquo;s no
on-disk passphrase in this version of &lt;code&gt;gtb&lt;/code&gt;, so keep it under filesystem
encryption (LUKS, FileVault, or wrap it with &lt;code&gt;age&lt;/code&gt;) rather than leaving it lying
about.&lt;/p&gt;
&lt;p&gt;One flag is doing quiet but important work: &lt;code&gt;--created&lt;/code&gt;. An OpenPGP key&amp;rsquo;s
fingerprint is derived partly from its creation time, so if you let it default
to &amp;ldquo;now&amp;rdquo;, every run produces a &lt;em&gt;different&lt;/em&gt; fingerprint. Pin it to a fixed
instant and the key is reproducible, which matters the moment you start
embedding it in a binary. Get in the habit now.&lt;/p&gt;
&lt;h2 id="mint-the-public-key-youll-actually-publish"&gt;Mint the public key you&amp;rsquo;ll actually publish
&lt;/h2&gt;&lt;p&gt;You could hand &lt;code&gt;signing.asc&lt;/code&gt; around as-is, but we&amp;rsquo;re going to produce the public
key a slightly different way, with &lt;code&gt;gtb keys mint&lt;/code&gt;:&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 &lt;span class="nb"&gt;local&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; --key-id signing.pem &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 &lt;span class="s2"&gt;&amp;#34;release@acme.dev&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; --created &lt;span class="s2"&gt;&amp;#34;2026-06-01T00: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 release.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=local key_id=signing.pem output=release.asc creation_time=2026-06-01T00:00:00Z fingerprint=...
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;mint&lt;/code&gt; wraps a &lt;em&gt;signing backend&lt;/em&gt; in OpenPGP framing and writes out the armored
public key. Here the backend is &lt;code&gt;local&lt;/code&gt; (a PEM file on disk), but in production
it&amp;rsquo;ll be &lt;code&gt;aws-kms&lt;/code&gt; pointing at a key you can&amp;rsquo;t hold. Minting the public key from
the backend is the one habit worth forming early: it&amp;rsquo;s the only way to get the
public half of a KMS key, so doing it the same way locally means the rest of the
series is identical bar one flag. &lt;code&gt;release.asc&lt;/code&gt; is the key you publish and embed
from here on. (Because we pinned the same &lt;code&gt;--created&lt;/code&gt;, its fingerprint matches
the generated one exactly.)&lt;/p&gt;
&lt;h2 id="sign-something"&gt;Sign something
&lt;/h2&gt;&lt;p&gt;A real release signs its &lt;code&gt;checksums.txt&lt;/code&gt;, so make a stand-in and sign it:&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;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;abc123 acme_linux_amd64\ndef456 acme_darwin_arm64\n&amp;#39;&lt;/span&gt; &amp;gt; checksums.txt
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gtb sign &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 &lt;span class="nb"&gt;local&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; --key-id signing.pem &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --public-key release.asc &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; checksums.txt
&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 Signed file backend=local key_id=signing.pem public_key=release.asc input=checksums.txt output=checksums.txt.sig ...
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That writes &lt;code&gt;checksums.txt.sig&lt;/code&gt;, a detached, ASCII-armored OpenPGP signature.
Note &lt;code&gt;gtb sign&lt;/code&gt; takes &lt;code&gt;--public-key&lt;/code&gt;: it cross-checks that the backend key
matches the public key you claim to be signing as, and refuses if they diverge,
so you can&amp;rsquo;t accidentally sign with the wrong key.&lt;/p&gt;
&lt;p&gt;&lt;img alt="Signing a checksums file and verifying it, then a tampered copy failing" class="gallery-image" data-flex-basis="360px" data-flex-grow="150" height="800" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-1/demo-sign-verify.gif" width="1200"&gt;
&lt;/p&gt;
&lt;h2 id="verify-it-two-ways"&gt;Verify it, two ways
&lt;/h2&gt;&lt;p&gt;First, the way your tool will do it on every self-update: against the public
key. That path is the subject of &lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;a signature the platform can&amp;rsquo;t
forge&lt;/a&gt; and we
wire it into a real binary in part 5. For now, prove the signature is sound with
something every machine already has, &lt;code&gt;gpg&lt;/code&gt;:&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;gpg --import release.asc
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gpg --verify checksums.txt.sig checksums.txt
&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;gpg: Signature made ...
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gpg: using RSA key ...
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gpg: Good signature from &amp;#34;Acme Releases &amp;lt;release@acme.dev&amp;gt;&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Good signature&lt;/code&gt; is the whole point. The signature &lt;code&gt;gtb sign&lt;/code&gt; produced is an
ordinary OpenPGP detached signature, so anyone can verify it with the standard
tool, no go-tool-base required. (&lt;code&gt;gpg&lt;/code&gt; will warn the key isn&amp;rsquo;t certified in its
web of trust; that&amp;rsquo;s expected and unrelated to whether the signature is valid.)&lt;/p&gt;
&lt;p&gt;Now change a byte of &lt;code&gt;checksums.txt&lt;/code&gt; and run the verify again. &lt;code&gt;gpg&lt;/code&gt; reports
&lt;code&gt;BAD signature&lt;/code&gt;. That failure is the entire reason any of this exists: a tampered
manifest no longer matches the signature, and a tool that &lt;em&gt;requires&lt;/em&gt; a valid
signature will refuse the update.&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 signed a file with a key you made and verified it independently. That&amp;rsquo;s
the complete trust loop in miniature, and the shape never changes: a private key
signs, a public key verifies, and the two are produced and checked the same way
whether the private half is a &lt;code&gt;.pem&lt;/code&gt; on your laptop or an HSM-held key in AWS.&lt;/p&gt;
&lt;p&gt;The local key was the easy bit, and also the weakest: it&amp;rsquo;s a file, and files get
copied. &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-2/" &gt;Part 2&lt;/a&gt;
moves the private key somewhere it can&amp;rsquo;t be copied at all, AWS KMS, and the only
command that changes is the &lt;code&gt;--backend&lt;/code&gt; flag.&lt;/p&gt;</description></item><item><title>Sign your own binaries with go-tool-base</title><link>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base/</link><pubDate>Sat, 13 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base/</guid><description>&lt;img src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base/cover-sign-your-own-binaries-with-go-tool-base.png" alt="Featured image of post Sign your own binaries with go-tool-base" /&gt;&lt;p&gt;If your CLI tool can update itself, it has a decision to make that nobody is
watching: when it pulls down a new version, should it trust what just landed?
A checksum tells it the bytes match a manifest. It does not tell it who wrote
the manifest. Close that gap and your users get updates they can actually
trust; leave it open and a compromised release host can hand them anything it
likes. This series is the end-to-end &amp;ldquo;how&amp;rdquo;, using the signing tooling built
into go-tool-base.&lt;/p&gt;
&lt;p&gt;By the end you&amp;rsquo;ll have a CLI that ships releases signed by a key you control,
verifies its own updates against that key, and does the whole thing with no
&lt;code&gt;gpg&lt;/code&gt; wrangling and no long-lived secrets sitting in CI. We did the &lt;em&gt;why&lt;/em&gt; and
the &lt;em&gt;how it works&lt;/em&gt; in two deep-dives already, &lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;a signature the platform can&amp;rsquo;t
forge&lt;/a&gt; and
&lt;a class="link" href="https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/" &gt;a signing key that never leaves KMS&lt;/a&gt;.
This is the use-it counterpart.&lt;/p&gt;
&lt;h2 id="what-youre-protecting-against"&gt;What you&amp;rsquo;re protecting against
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/nobody-is-coming-to-clean-your-supply-chain/" &gt;Nobody&amp;rsquo;s coming to clean your supply chain&lt;/a&gt;,
so it&amp;rsquo;s worth being clear about the threat before you spend an afternoon on the
fix. A checksum file sits next to the binary on the same release page. Whoever
can swap the binary can swap the checksum in the same breath, and the hash still
matches. A signature is different: it&amp;rsquo;s made by a private key the release
platform never holds, and verified against a public key your tool fetches from
somewhere the platform can&amp;rsquo;t reach. To forge a release that passes, an attacker
would have to steal a key that, done right, was never anywhere they could get at
it.&lt;/p&gt;
&lt;p&gt;That &amp;ldquo;done right&amp;rdquo; is the whole series.&lt;/p&gt;
&lt;h2 id="two-paths-through-it"&gt;Two paths through it
&lt;/h2&gt;&lt;p&gt;You don&amp;rsquo;t need a cloud account to start. The series runs in two stages:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Learn it locally.&lt;/strong&gt; Part 1 signs and verifies on your laptop with a plain
key on disk. No AWS, no CI, no cost. It&amp;rsquo;s the fastest way to see every moving
part for real.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Do it for production.&lt;/strong&gt; Parts 2 onward move the private key into AWS KMS,
where it&amp;rsquo;s generated and never leaves, and wire your release pipeline to sign
through it over short-lived OIDC credentials.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each part stands on its own and ends with something that works. They build in
order, but you can stop after Part 1 with a genuinely useful skill and come back
for the cloud parts when you need them.&lt;/p&gt;
&lt;h2 id="before-you-start"&gt;Before you start
&lt;/h2&gt;&lt;p&gt;You&amp;rsquo;ll want a CLI built on go-tool-base to sign. If you haven&amp;rsquo;t got one, the
&lt;a class="link" href="https://phpboyscout.uk/building-a-cli-with-go-tool-base-part-1/" &gt;Building a CLI with go-tool-base&lt;/a&gt;
series gets you there in an afternoon; this one picks up where releases come in.
You&amp;rsquo;ll also need the &lt;code&gt;gtb&lt;/code&gt; CLI installed (the &lt;a class="link" href="https://gtb.phpboyscout.uk/installation/" target="_blank" rel="noopener"
 &gt;installation
docs&lt;/a&gt; have the one-liner), and for the
cloud parts, an AWS account and a GitLab or GitHub project to release from.&lt;/p&gt;
&lt;h2 id="the-parts"&gt;The parts
&lt;/h2&gt;&lt;ol&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-1/" &gt;Sign and verify on your laptop&lt;/a&gt;:
&lt;code&gt;gtb keys generate&lt;/code&gt;, &lt;code&gt;gtb sign&lt;/code&gt;, and &lt;code&gt;gpg --verify&lt;/code&gt;, the whole loop with a
local key.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-2/" &gt;A signing key in AWS KMS&lt;/a&gt;:
stand up an asymmetric KMS key with the &lt;code&gt;terraform-aws-signing-kms&lt;/code&gt; module.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-3/" &gt;Keyless CI signing with OIDC&lt;/a&gt;:
federate GitLab &lt;em&gt;and&lt;/em&gt; GitHub into the signer role, no stored credentials.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-4/" &gt;Mint and publish your public key&lt;/a&gt;:
&lt;code&gt;gtb keys mint&lt;/code&gt; from KMS, then &lt;code&gt;gtb keys wkd&lt;/code&gt; to publish it off-platform.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/" &gt;Embed the key and require verification&lt;/a&gt;:
bake the trust anchor into your binary and turn enforcement on safely.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-6/" &gt;Sign every release with GoReleaser&lt;/a&gt;:
wire signing into a real tagged-release pipeline.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-7/" &gt;Rotation and break-glass&lt;/a&gt;:
the part everyone skips, and how to do it without locking anyone out.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Start with Part 1. By the time you reach the end, the chain runs from a key born
in a vault to a binary on a stranger&amp;rsquo;s machine checking, on its own, that the
update it just fetched is really yours.&lt;/p&gt;</description></item></channel></rss>