<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Aws-Kms on PHP Boy Scout</title><link>https://phpboyscout.uk/tags/aws-kms/</link><description>Recent content in Aws-Kms 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/aws-kms/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 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>A signing key that never leaves KMS</title><link>https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/</link><pubDate>Thu, 11 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/</guid><description>&lt;img src="https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/cover-a-signing-key-that-never-leaves-kms.png" alt="Featured image of post A signing key that never leaves KMS" /&gt;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;The last post in this series&lt;/a&gt;
walked through how a tool &lt;em&gt;verifies&lt;/em&gt; a release signature the platform can&amp;rsquo;t forge.
That post had a loose end dangling off the back of it, and I knew it the whole time I
was writing. Because a signature has to be produced by a private key&amp;hellip; and a private
signing key is the single worst thing in this entire story to lose. Steal it, and you
sign malware that sails through every check I spent two posts building, signature and
all. So where does that key live? The answer I landed on is the one this whole post is
about: inside AWS KMS, and it never comes out.&lt;/p&gt;
&lt;h2 id="the-only-key-you-cant-steal"&gt;The only key you can&amp;rsquo;t steal
&lt;/h2&gt;&lt;p&gt;Think about where a signing key normally ends up. A file on a build server. A secret
in CI. A key on the release engineer&amp;rsquo;s laptop, &amp;ldquo;just for the release, I&amp;rsquo;ll delete it
after&amp;rdquo;. Every one of those is a copy, and every copy is one more thing somebody can
read, exfiltrate, or quietly clone while your back is turned. You can wrap them in
passphrases and vaults and rotation policies all you like, and you&amp;rsquo;re still standing
guard over a thing that &lt;em&gt;exists in a place you don&amp;rsquo;t fully control&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The way out is almost annoyingly simple to state: the only key nobody can steal is
the one that was never anywhere to be stolen from. So don&amp;rsquo;t hold the key at all. Let
something else hold it, somewhere it has no export path, and ask that thing to sign
&lt;em&gt;for&lt;/em&gt; you.&lt;/p&gt;
&lt;p&gt;That thing is AWS KMS. This is the infrastructure side of &lt;a class="link" href="https://phpboyscout.uk/a-signing-key-needs-somewhere-to-live/" &gt;the question I opened the
signing series with&lt;/a&gt;,
finally answered with real Terraform.&lt;/p&gt;
&lt;h2 id="a-key-thats-born-in-the-box-and-stays-there"&gt;A key that&amp;rsquo;s born in the box and stays there
&lt;/h2&gt;&lt;p&gt;The signing key is an asymmetric KMS key, and the
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-signing-kms/-/blob/v0.1.0/main.tf#L86-L100" target="_blank" rel="noopener"
 &gt;module that provisions it&lt;/a&gt;
is small enough to read in one sitting:&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;resource&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;aws_kms_key&amp;#34; &amp;#34;this&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="k"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;description&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; key_usage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;SIGN_VERIFY&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; customer_master_key_spec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;key_spec&lt;/span&gt;&lt;span class="c1"&gt; # default RSA_4096
&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; # Asymmetric SIGN_VERIFY keys do not support KMS-managed rotation;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # rotation is handled by minting a new key (alias = `&amp;lt;name&amp;gt;-v2`) and
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # publishing the v2 public key alongside the v1 key (dual-sign window).
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; enable_key_rotation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;false&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; policy&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_policy_document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;key_policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;json&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 private half of that key is generated inside KMS and there is no API that hands
it back to you. You don&amp;rsquo;t sign &lt;em&gt;with&lt;/em&gt; it the way you&amp;rsquo;d sign with a file. You call
&lt;code&gt;kms:Sign&lt;/code&gt;: the bytes you want signed go up, a signature comes back down, and the key
itself never moves. An attacker who completely owns my CI, my account, my laptop, can
ask KMS to sign things for as long as their access lasts&amp;hellip; but they can&amp;rsquo;t walk off
with the key and keep signing forever. The blast radius is &amp;ldquo;while I&amp;rsquo;m compromised&amp;rdquo;,
not &amp;ldquo;until I rotate a key I didn&amp;rsquo;t know had leaked three years ago&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;Why RSA-4096 and not the Ed25519 I&amp;rsquo;d normally reach for? Because KMS asymmetric
signing doesn&amp;rsquo;t offer Ed25519, and OpenPGP&amp;rsquo;s packet format is tied to the algorithm
that signed it, so the choice of key spec ripples all the way out to the signature on
the wire. RSA-4096 is the strong option KMS does offer, so RSA-4096 is what the
workflow is built around. A constraint of the box shaped the cryptography, not the
other way round, and I&amp;rsquo;d rather say so than pretend I picked RSA on purpose.&lt;/p&gt;
&lt;h2 id="minting-an-openpgp-key-from-a-key-you-cant-hold"&gt;Minting an OpenPGP key from a key you can&amp;rsquo;t hold
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the part I find genuinely neat. OpenPGP wants a private key to self-sign its
own public key when you generate it. And I don&amp;rsquo;t &lt;em&gt;have&lt;/em&gt; a private key in any form I
can hand to a library&amp;hellip; it&amp;rsquo;s sitting in KMS, behind a door with no handle on my side.
So how do you produce a valid OpenPGP public key at all?&lt;/p&gt;
&lt;p&gt;go-tool-base leans on a small Go interface, &lt;code&gt;crypto.Signer&lt;/code&gt;: anything that can return
its public key and sign a digest. A KMS-backed signer satisfies it by turning each
&lt;code&gt;Sign&lt;/code&gt; call into a &lt;code&gt;kms:Sign&lt;/code&gt; request. Then
&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/v0.12.2/pkg/openpgpkey/openpgpkey.go#L114-L126" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/openpgpkey&lt;/code&gt;&lt;/a&gt;
builds the OpenPGP entity around that signer:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Signer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;creationTime&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;openpgp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &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="nx"&gt;rsaPub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Public&lt;/span&gt;&lt;span class="p"&gt;().(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;rsa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PublicKey&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="c1"&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="nx"&gt;pubPkt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;packet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewRSAPublicKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;creationTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;rsaPub&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="c1"&gt;// Construct the private-key packet directly (rather than&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="c1"&gt;// packet.NewSignerPrivateKey, which panics on opaque signers):&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="c1"&gt;// the crypto.Signer drives the actual signing, so a KMS-backed&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="c1"&gt;// signer works here.&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="nx"&gt;privPkt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;packet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PrivateKey&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;PublicKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;pubPkt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;PrivateKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;signer&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="c1"&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="p"&gt;}&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;Look at that &lt;code&gt;PrivateKey&lt;/code&gt; packet. The field where OpenPGP expects the secret key
material holds the &lt;code&gt;crypto.Signer&lt;/code&gt; instead, which is to say, a remote handle to KMS.
When the entity self-signs its public key, that self-signature is computed by KMS.
&lt;code&gt;gtb keys mint&lt;/code&gt; runs exactly this and writes out an ASCII-armored OpenPGP public key,
and at no point did a single byte of private key material exist on the machine that
minted it. The OpenPGP &amp;ldquo;private key&amp;rdquo; is a phone line to a vault, not a key.&lt;/p&gt;
&lt;p&gt;That public key is what gets published off-platform over WKD and baked into the
binary, the two trust anchors that post cross-checks.&lt;/p&gt;
&lt;h2 id="access-without-a-human-and-without-a-standing-key"&gt;Access without a human and without a standing key
&lt;/h2&gt;&lt;p&gt;A key that never leaves KMS is only as good as the rules about who may call
&lt;code&gt;kms:Sign&lt;/code&gt;. The
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-signing-kms/-/blob/v0.1.0/main.tf#L49-L82" target="_blank" rel="noopener"
 &gt;signer role&lt;/a&gt;
is deliberately narrow: it can call &lt;code&gt;kms:Sign&lt;/code&gt; and &lt;code&gt;kms:GetPublicKey&lt;/code&gt; on this one
key and nothing else, and it is assumable only over OIDC from specific CI subjects,
the same &lt;a class="link" href="https://phpboyscout.uk/no-access-keys-in-ci/" &gt;keyless federation&lt;/a&gt;
the rest of the estate runs on. No human holds it. No long-lived access key sits in
a CI variable waiting to leak. A release job federates in for its few minutes,
signs, and the credentials evaporate with the runner.&lt;/p&gt;
&lt;p&gt;So the chain of &amp;ldquo;who can sign a release&amp;rdquo; has no standing secret in it anywhere. Not a
key file, not an access key, not a console user. Just a short-lived token, scoped to
two API calls, on a key that can&amp;rsquo;t be exported.&lt;/p&gt;
&lt;h2 id="the-real-cost-rotation-is-manual"&gt;The real cost: rotation is manual
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t free, and the bit it taxes you on is rotation. KMS won&amp;rsquo;t auto-rotate an
asymmetric &lt;code&gt;SIGN_VERIFY&lt;/code&gt; key, which is why the module sets &lt;code&gt;enable_key_rotation = false&lt;/code&gt; rather than leaving a default on. Rotating means minting a &lt;em&gt;new&lt;/em&gt; key (a &lt;code&gt;-v2&lt;/code&gt;
alias), publishing its public key alongside the old one, and running a dual-sign
window long enough that clients have picked up the new anchor before you retire the
old. It&amp;rsquo;s manual, it&amp;rsquo;s a runbook, and pretending otherwise would be the kind of thing
this series exists to argue against. The trade I made was: a key with no exfiltration
path, in exchange for rotation I have to do by hand. For a release-signing key, that&amp;rsquo;s
the right side of the trade.&lt;/p&gt;
&lt;h2 id="why-this-is-a-command-and-not-a-script-i-hid"&gt;Why this is a command and not a script I hid
&lt;/h2&gt;&lt;p&gt;The origin of all this is a good deal less tidy than the result. I was working through
the key-generation runbook, creating the offline rotation key with a &lt;code&gt;gpg&lt;/code&gt; command I&amp;rsquo;d
copied straight off my own page&amp;hellip; and it just hung. No error, no prompt, just a cursor
blinking while gpg waited on something it never bothered to mention.&lt;/p&gt;
&lt;p&gt;My first instinct was the lazy one: drop the minting script into a &lt;code&gt;scripts&lt;/code&gt; folder in
my infra repo and never speak of it again. Then it nagged. That repo&amp;rsquo;s private, so the
recipe would live somewhere nobody else could ever reach, and I&amp;rsquo;d already half-promised
myself a tutorial walking people through this exact setup. So it shouldn&amp;rsquo;t sit in infra
at all. It should be a &lt;code&gt;gtb&lt;/code&gt; command, with a pluggable backend so anyone can swap my KMS
for whatever provider they happen to run.&lt;/p&gt;
&lt;p&gt;The deeper objection is the one that actually shaped it, though. I didn&amp;rsquo;t want to be
shelling out to &lt;code&gt;gpg&lt;/code&gt; by hand in the first place. gtb is a tool I hand to other people,
and every time it drops to the shell for some gpg incantation, that&amp;rsquo;s an environment
I&amp;rsquo;m asking the next person to reproduce, a dependency to install, a fiddly step to get
subtly wrong, all before they can sign a single thing. The aim was to keep as much of
this &lt;em&gt;inside the box&lt;/em&gt; as I could: mint the key, build the WKD tree, produce the
signature, all in pure Go, with no &lt;code&gt;gpg&lt;/code&gt; on the path and no &lt;code&gt;gpg-wks-client&lt;/code&gt; either.&lt;/p&gt;
&lt;p&gt;So &lt;code&gt;gtb keys mint&lt;/code&gt; pulls the public half out of your KMS key and frames it as OpenPGP,
the trick from earlier; &lt;code&gt;gtb keys wkd&lt;/code&gt; builds the tree ready to upload; and &lt;code&gt;gtb sign&lt;/code&gt;
produces the detached signature through that same remote round-trip. What comes out is
an entirely ordinary OpenPGP signature &lt;code&gt;gpg --verify&lt;/code&gt; is happy with, so you&amp;rsquo;re not
locked into anything of mine. And none of it is just for me: build your tool on
go-tool-base and the same handful of commands stands you up with this exact model,
pointed at your own KMS. No cloud KMS to hand? There&amp;rsquo;s a &lt;code&gt;local&lt;/code&gt; backend, a plain key
on disk, to wire the whole thing together on your laptop first. These are commands for
you, the person shipping the tool. Your users never run &lt;code&gt;mytool keys mint&lt;/code&gt;&amp;hellip; they just
get updates that quietly check themselves, which was the whole idea two posts ago.&lt;/p&gt;
&lt;p&gt;That setup deserves a walkthrough of its own, and it&amp;rsquo;ll get one. For now, the
ergonomics were the point, not a nicety bolted on afterwards. The safest setup in the
world is no use to anyone if it takes a PhD to stand up.&lt;/p&gt;
&lt;h2 id="where-this-leaves-the-whole-story"&gt;Where this leaves the whole story
&lt;/h2&gt;&lt;p&gt;Step back and the full loop is finally closed. The private key is born in KMS and
never leaves it. Its public key is minted &lt;em&gt;from&lt;/em&gt; it, with KMS computing its own
self-signature. That public key is published off-platform and embedded in the binary.
Releases are signed by KMS, reached only through short-lived OIDC federation. And the
client verifies against the embedded and WKD keys &lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;cross-checked against each
other&lt;/a&gt;. At no
single point in that chain is there a thing an attacker can grab that lets them forge
a release, and the most dangerous thing of all, the private key, has no theft path
because it has no export path.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the thread running through the whole signing series, from &lt;a class="link" href="https://phpboyscout.uk/verifying-your-own-downloads/" &gt;the very first
checksum&lt;/a&gt; to here: the
strongest control isn&amp;rsquo;t a better lock on the key. It&amp;rsquo;s arranging things so the key
was never somewhere you could lose it. &lt;a class="link" href="https://phpboyscout.uk/nobody-is-coming-to-clean-your-supply-chain/" &gt;Nobody is coming to clean your supply
chain&lt;/a&gt;,
so the least you can do is leave it nothing worth stealing.&lt;/p&gt;</description></item></channel></rss>