<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>PHP Boy Scout — Signing your releases</title><link>https://phpboyscout.uk/topics/signing/</link><description>A complete guide to signing and verifying your own release artifacts: a seven-part tutorial, the design decisions behind it, and why it matters.</description><generator>Hugo</generator><language>en-GB</language><copyright>Matt Cockayne</copyright><lastBuildDate>Wed, 05 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/topics/signing/index.xml" rel="self" type="application/rss+xml"/><item><title>Sign your own binaries with go-tool-base</title><link>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base/</link><pubDate>Sat, 13 Jun 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base/</guid><category>go-tool-base</category><category>signing</category><category>Orienteering</category><description>Whoever controls your release page can swap a binary, and a checksum hosted beside it proves nothing. A seven-part guide to signing releases.</description><content:encoded>&lt;p&gt;If your CLI tool can update itself, it has a decision to make that nobody is
watching: when it pulls down a new version, should it trust what just landed?
A checksum tells it the bytes match a manifest. It does not tell it who wrote
the manifest. Close that gap and your users get updates they can actually
trust; leave it open and a compromised release host can hand them anything it
likes. This series is the end-to-end &amp;ldquo;how&amp;rdquo;, using the signing tooling built
into go-tool-base.&lt;/p&gt;
&lt;p&gt;By the end you&amp;rsquo;ll have a CLI that ships releases signed by a key you control,
verifies its own updates against that key, and does the whole thing with no
&lt;code&gt;gpg&lt;/code&gt; wrangling and no long-lived secrets sitting in CI. We did the &lt;em&gt;why&lt;/em&gt; and
the &lt;em&gt;how it works&lt;/em&gt; in two deep-dives already, &lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;a signature the platform can&amp;rsquo;t
forge&lt;/a&gt; and
&lt;a class="link" href="https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/" &gt;a signing key that never leaves KMS&lt;/a&gt;.
This is the use-it counterpart.&lt;/p&gt;
&lt;h2 id="what-youre-protecting-against"&gt;What you&amp;rsquo;re protecting against
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/nobody-is-coming-to-clean-your-supply-chain/" &gt;Nobody&amp;rsquo;s coming to clean your supply chain&lt;/a&gt;,
so it&amp;rsquo;s worth being clear about the threat before you spend an afternoon on the
fix. A checksum file sits next to the binary on the same release page. Whoever
can swap the binary can swap the checksum in the same breath, and the hash still
matches. A signature is different: it&amp;rsquo;s made by a private key the release
platform never holds, and verified against a public key your tool fetches from
somewhere the platform can&amp;rsquo;t reach. To forge a release that passes, an attacker
would have to steal a key that, done right, was never anywhere they could get at
it.&lt;/p&gt;
&lt;p&gt;That &amp;ldquo;done right&amp;rdquo; is the whole series.&lt;/p&gt;
&lt;h2 id="two-paths-through-it"&gt;Two paths through it
&lt;/h2&gt;&lt;p&gt;You don&amp;rsquo;t need a cloud account to start. The series runs in two stages:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Learn it locally.&lt;/strong&gt; Part 1 signs and verifies on your laptop with a plain
key on disk. No AWS, no CI, no cost. It&amp;rsquo;s the fastest way to see every moving
part for real.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Do it for production.&lt;/strong&gt; Parts 2 onward move the private key into AWS KMS,
where it&amp;rsquo;s generated and never leaves, and wire your release pipeline to sign
through it over short-lived OIDC credentials.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each part stands on its own and ends with something that works. They build in
order, but you can stop after Part 1 with a genuinely useful skill and come back
for the cloud parts when you need them.&lt;/p&gt;
&lt;h2 id="before-you-start"&gt;Before you start
&lt;/h2&gt;&lt;p&gt;You&amp;rsquo;ll want a CLI built on go-tool-base to sign. If you haven&amp;rsquo;t got one, the
&lt;a class="link" href="https://phpboyscout.uk/building-a-cli-with-go-tool-base-part-1/" &gt;Building a CLI with go-tool-base&lt;/a&gt;
series gets you there in an afternoon; this one picks up where releases come in.
You&amp;rsquo;ll also need the &lt;code&gt;gtb&lt;/code&gt; CLI installed (the &lt;a class="link" href="https://gtb.phpboyscout.uk/installation/" target="_blank" rel="noopener"
 &gt;installation
docs&lt;/a&gt; have the one-liner), and for the
cloud parts, an AWS account and a GitLab or GitHub project to release from.&lt;/p&gt;
&lt;h2 id="the-parts"&gt;The parts
&lt;/h2&gt;&lt;ol&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-1/" &gt;Sign and verify on your laptop&lt;/a&gt;:
&lt;code&gt;gtb keys generate&lt;/code&gt;, &lt;code&gt;gtb sign&lt;/code&gt;, and &lt;code&gt;gpg --verify&lt;/code&gt;, the whole loop with a
local key.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-2/" &gt;A signing key in AWS KMS&lt;/a&gt;:
stand up an asymmetric KMS key with the &lt;code&gt;terraform-aws-signing-kms&lt;/code&gt; module.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-3/" &gt;Keyless CI signing with OIDC&lt;/a&gt;:
federate GitLab &lt;em&gt;and&lt;/em&gt; GitHub into the signer role, no stored credentials.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-4/" &gt;Mint and publish your public key&lt;/a&gt;:
&lt;code&gt;gtb keys mint&lt;/code&gt; from KMS, then &lt;code&gt;gtb keys wkd&lt;/code&gt; to publish it off-platform.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/" &gt;Embed the key and require verification&lt;/a&gt;:
bake the trust anchor into your binary and turn enforcement on safely.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-6/" &gt;Sign every release with GoReleaser&lt;/a&gt;:
wire signing into a real tagged-release pipeline.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-7/" &gt;Rotation and break-glass&lt;/a&gt;:
the part everyone skips, and how to do it without locking anyone out.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Start with Part 1. By the time you reach the end, the chain runs from a key born
in a vault to a binary on a stranger&amp;rsquo;s machine checking, on its own, that the
update it just fetched is really yours.&lt;/p&gt;</content:encoded></item><item><title>Nobody's coming to clean your supply chain</title><link>https://phpboyscout.uk/nobody-is-coming-to-clean-your-supply-chain/</link><pubDate>Fri, 29 May 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/nobody-is-coming-to-clean-your-supply-chain/</guid><category>security</category><category>signing</category><category>Soapbox</category><description>Signed but poisoned packages show a signature proves who sent something, not what is in it. Nobody is coming to clean this up for you.</description><content:encoded>&lt;p&gt;Pick a week in May 2026 and there&amp;rsquo;s a supply-chain attack in it. On the 11th
someone owned TanStack&amp;rsquo;s CI and pushed
&lt;a class="link" href="https://www.wiz.io/blog/mini-shai-hulud-strikes-again-tanstack-more-npm-packages-compromised" target="_blank" rel="noopener"
 &gt;84 poisoned package versions in six minutes&lt;/a&gt;.
On the 14th, three malicious versions of
&lt;a class="link" href="https://www.stepsecurity.io/blog/node-ipc-npm-supply-chain-attack" target="_blank" rel="noopener"
 &gt;node-ipc&lt;/a&gt;,
a library with ten million weekly downloads, shipped an identical
credential-stealer. Days later it was
&lt;a class="link" href="https://www.microsoft.com/en-us/security/blog/2026/05/20/mini-shai-hulud-compromised-antv-npm-packages-enable-ci-cd-credential-theft/" target="_blank" rel="noopener"
 &gt;@antv&lt;/a&gt;,
cascading down into a charting library a million projects depend on. Each one
runs its payload the moment you install it, then quietly tries to publish
itself from your machine.&lt;/p&gt;
&lt;p&gt;You&amp;rsquo;ve heard this story so many times the outrage has worn smooth. So let me
point at the one detail that should still make you sit up: the TanStack
packages carried &lt;em&gt;valid signing provenance&lt;/em&gt;. Real attestation, pointing at the
real pipeline. The seal was genuine. The contents were poison.&lt;/p&gt;
&lt;h2 id="a-signature-proves-the-sender-not-the-contents"&gt;A signature proves the sender, not the contents
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;ve spent a fair while &lt;a class="link" href="https://phpboyscout.uk/verifying-your-own-downloads/" &gt;building integrity and signing into my own
tools&lt;/a&gt;, so this one
stings a little. Signing is a trust mechanism, and a good one. It&amp;rsquo;s how I prove
a binary you downloaded was built and published by me and nobody else, and in a
world with this many ways to be impersonated, that matters more than ever.&lt;/p&gt;
&lt;p&gt;But TanStack shows the limit in neon. If the pipeline doing the signing is
itself compromised, the signature is still perfectly valid. It just now
certifies a lie. Provenance answers &amp;ldquo;did this come from where it claims?&amp;rdquo; It
does not answer &amp;ldquo;is what&amp;rsquo;s inside safe?&amp;rdquo;, and we have spent a few years quietly
letting people treat those as the same question.&lt;/p&gt;
&lt;p&gt;They aren&amp;rsquo;t. A signature is a promise about the &lt;em&gt;sender&lt;/em&gt;. The thing we actually
need is a promise about the &lt;em&gt;contents&lt;/em&gt;: that whoever signed has done the
diligence, the testing, the vetting, to vouch for what&amp;rsquo;s in the tin. A
signature without that behind it isn&amp;rsquo;t a safety certificate. It&amp;rsquo;s a
tamper-proof seal on a poisoned jar.&lt;/p&gt;
&lt;h2 id="it-was-never-just-npm"&gt;It was never just npm
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s tempting to file all this under &amp;ldquo;npm being npm&amp;rdquo;. Resist it, because it&amp;rsquo;s a
category error. The thing that makes these attacks work, a stranger&amp;rsquo;s code
running on your machine as a side effect of installing or building, is not an
npm bug. It&amp;rsquo;s a near-universal design choice.&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Ecosystem&lt;/th&gt;
 &lt;th&gt;Untrusted code on install/build?&lt;/th&gt;
 &lt;th&gt;Mechanism&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;npm&lt;/strong&gt; (JS)&lt;/td&gt;
 &lt;td&gt;Yes, at install (dependencies too)&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;pre&lt;/code&gt;/&lt;code&gt;postinstall&lt;/code&gt; scripts&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;PyPI&lt;/strong&gt; (Python)&lt;/td&gt;
 &lt;td&gt;sdist yes, wheel no&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;setup.py&lt;/code&gt;; wheels forbid hooks&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;RubyGems&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;Yes, at install&lt;/td&gt;
 &lt;td&gt;native-extension build (&lt;code&gt;extconf.rb&lt;/code&gt;)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;cargo&lt;/strong&gt; (Rust)&lt;/td&gt;
 &lt;td&gt;Yes, at build&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;build.rs&lt;/code&gt; and proc-macros&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;Composer&lt;/strong&gt; (PHP)&lt;/td&gt;
 &lt;td&gt;Dependencies: no&lt;/td&gt;
 &lt;td&gt;only the &lt;em&gt;root&lt;/em&gt; project&amp;rsquo;s scripts run, by design&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;Maven/Gradle&lt;/strong&gt; (JVM)&lt;/td&gt;
 &lt;td&gt;Yes, at build&lt;/td&gt;
 &lt;td&gt;build scripts and plugins&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;NuGet&lt;/strong&gt; (.NET)&lt;/td&gt;
 &lt;td&gt;Modern: no&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;install.ps1&lt;/code&gt;, legacy format only&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;Go&lt;/strong&gt; (modules)&lt;/td&gt;
 &lt;td&gt;No&lt;/td&gt;
 &lt;td&gt;no install or build hooks&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;(Lifecycle hooks across ecosystems are catalogued at
&lt;a class="link" href="https://github.com/ecosyste-ms/package-manager-hooks" target="_blank" rel="noopener"
 &gt;ecosyste.ms&lt;/a&gt; if you want
the receipts.)&lt;/p&gt;
&lt;p&gt;Read that and the lesson isn&amp;rsquo;t &amp;ldquo;npm is uniquely bad&amp;rdquo;, it&amp;rsquo;s &amp;ldquo;this was a choice,
and several ecosystems chose differently&amp;rdquo;. Go runs no install or build hooks at
all. PHP&amp;rsquo;s Composer flatly refuses to run a dependency&amp;rsquo;s scripts, only your own
project&amp;rsquo;s. Python&amp;rsquo;s wheel format forbids install hooks. The hook was never
inevitable.&lt;/p&gt;
&lt;p&gt;And yes, that includes my own back yard. cargo&amp;rsquo;s &lt;code&gt;build.rs&lt;/code&gt; is the same gun
fired at build time instead of install time, and the
&lt;a class="link" href="https://socket.dev/blog/trapdoor-crypto-stealer-npm-pypi-crates" target="_blank" rel="noopener"
 &gt;TrapDoor campaign&lt;/a&gt;
used exactly that to rifle through keystores on crates.io this year. Rust isn&amp;rsquo;t
safe here. It&amp;rsquo;s a smaller, better-policed target, which is a different thing,
and I&amp;rsquo;d rather say so than pretend one of my favourite languages is above it.&lt;/p&gt;
&lt;h2 id="no-registry-can-hand-you-a-clean-package"&gt;No registry can hand you a clean package
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the uncomfortable core. Not one of these registries can guarantee the
package you pull is clean. They can sign it, scan it, attest its origin and
mandate 2FA on maintainers, and they should do all of that. But none of it is a
guarantee, because the failure modes are endless and attackers keep finding new
ones. A maintainer account gets phished. A CI token leaks. A trusted
contributor turns. A dependency four levels down quietly changes hands.&lt;/p&gt;
&lt;p&gt;So the onus lands, and will keep landing for a good while yet, on the consuming
engineer. That isn&amp;rsquo;t a comfortable answer or a clever one. It&amp;rsquo;s the true one.&lt;/p&gt;
&lt;p&gt;And it&amp;rsquo;s a genuinely rotten spot to stand in, because the advice contradicts
itself. Patch slowly and you&amp;rsquo;re scolded for running known-vulnerable
dependencies. Patch the instant a release drops and you&amp;rsquo;ve skipped the
bedding-in that might have caught a poisoned one. There&amp;rsquo;s no setting on that
dial that&amp;rsquo;s safe, only trade-offs you have to actually think about. Add CI that
leaks credentials it never needed, and a dependency tree thousands of strangers
deep, and you can see why there&amp;rsquo;s no single villain to blame and no single
switch to flip.&lt;/p&gt;
&lt;h2 id="the-boring-discipline-that-actually-helps"&gt;The boring discipline that actually helps
&lt;/h2&gt;&lt;p&gt;What&amp;rsquo;s left isn&amp;rsquo;t heroic, it&amp;rsquo;s hygiene, and it&amp;rsquo;s the boring, necessary stuff I keep
banging on about.
&lt;a class="link" href="https://phpboyscout.uk/openssf-scorecard-graded-my-supply-chain/" &gt;Pin your CI actions to commit SHAs&lt;/a&gt;
so a moved tag can&amp;rsquo;t swap code under you. Commit your lockfiles. Run the
auditors, &lt;code&gt;cargo-audit&lt;/code&gt;, &lt;code&gt;pip-audit&lt;/code&gt;, &lt;code&gt;govulncheck&lt;/code&gt;, &lt;code&gt;npm audit&lt;/code&gt;, or Google&amp;rsquo;s
cross-ecosystem &lt;a class="link" href="https://github.com/google/osv-scanner" target="_blank" rel="noopener"
 &gt;OSV-Scanner&lt;/a&gt;, on every
build. Gate the dependency tree and
&lt;a class="link" href="https://phpboyscout.uk/waivers-with-an-expiry-date/" &gt;give every exception an expiry date&lt;/a&gt;
so &amp;ldquo;we&amp;rsquo;ll deal with it later&amp;rdquo; can&amp;rsquo;t quietly become &amp;ldquo;never&amp;rdquo;. Keep the tree
small: every crate you don&amp;rsquo;t add is a stranger you don&amp;rsquo;t have to trust.&lt;/p&gt;
&lt;p&gt;None of that is a solution. All of it is diligence, and diligence is the only
thing that was ever going to stand behind the signature. When I sign a release,
the cryptography is the easy part. The promise underneath it, that I pinned,
locked, audited, vetted and tested before I put my name on it, is the part
worth anything. That&amp;rsquo;s the contract. The signature is just how I countersign
it.&lt;/p&gt;
&lt;p&gt;The encouraging note is that the structural defences exist and they work. Go&amp;rsquo;s
checksum database and its refusal to run hooks. Composer declining to trust a
dependency&amp;rsquo;s scripts. Python&amp;rsquo;s wheels. &lt;code&gt;cargo-vet&lt;/code&gt; and &lt;code&gt;cargo-deny&lt;/code&gt; giving you
somewhere to record human judgement at scale. More ecosystems should steal
these shamelessly, because a registry that makes the safe path the &lt;em&gt;default&lt;/em&gt;
does the working engineer a far bigger favour than one that leaves it all to
discipline.&lt;/p&gt;
&lt;h2 id="the-same-shape-a-third-time"&gt;The same shape, a third time
&lt;/h2&gt;&lt;p&gt;If this feels familiar, it should. I wrote recently about
&lt;a class="link" href="https://phpboyscout.uk/ai-didnt-kill-curls-bug-bounty/" &gt;a bug bounty that collapsed because the cost of slop was deferred&lt;/a&gt;,
and about &lt;a class="link" href="https://phpboyscout.uk/the-greybeards-edge-was-never-typing/" &gt;a junior pipeline being cut because the bill lands years
later&lt;/a&gt;.
Supply-chain security is the same shape a third time. The convenience is now,
the catastrophe is later, and the only thing standing in the gap is an engineer
paying attention, doing the dull work, refusing to be rushed into trusting
something they haven&amp;rsquo;t checked.&lt;/p&gt;
&lt;p&gt;There is no clean package waiting to be found, no registry about to solve this
for us, no signature that means &amp;ldquo;safe&amp;rdquo; all on its own. There&amp;rsquo;s the diligence
you do before you put your name to something, and the judgement to know when an
install is asking you to trust more than you should. For a good while yet, that
is the whole job. Boring, unfashionable, and the only thing that works.&lt;/p&gt;</content:encoded></item><item><title>Sign your own binaries with go-tool-base, part 1: sign and verify on your laptop</title><link>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-1/</link><pubDate>Mon, 15 Jun 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-1/</guid><category>go-tool-base</category><category>signing</category><category>Orienteering</category><description>The laptop-only quick start: make a signing key, mint the public key you publish, sign a file, and verify it with both gtb and plain gpg.</description><content:encoded>&lt;p&gt;The quickest way to understand release signing is to do it once, by hand, with
nothing but a key on disk. No cloud account, no CI, no cost. This first part of
the &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base/" &gt;signing series&lt;/a&gt;
walks the whole loop on your laptop: make a key, sign a file, and verify the
signature, including with plain &lt;code&gt;gpg&lt;/code&gt; so you can see it isn&amp;rsquo;t locked to anything
of ours. Everything you learn here maps one-to-one onto the AWS KMS workflow in
the later parts; only &lt;em&gt;where the key lives&lt;/em&gt; changes.&lt;/p&gt;
&lt;p&gt;You&amp;rsquo;ll need the &lt;code&gt;gtb&lt;/code&gt; CLI (&lt;a class="link" href="https://gtb.phpboyscout.uk/installation/" target="_blank" rel="noopener"
 &gt;installation docs&lt;/a&gt;).
Make a scratch directory to work in, because we&amp;rsquo;re going to create a few files.&lt;/p&gt;
&lt;h2 id="make-a-signing-key"&gt;Make a signing key
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;gtb keys generate&lt;/code&gt; creates a keypair entirely inside the process and writes
both halves to disk:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gtb keys generate &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --algorithm rsa &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --name &lt;span class="s2"&gt;&amp;#34;Acme Releases&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --email &lt;span class="s2"&gt;&amp;#34;release@acme.dev&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --created &lt;span class="s2"&gt;&amp;#34;2026-06-01T00:00:00Z&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --output signing.asc
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;INFO Generated OpenPGP keypair algorithm=rsa public_output=signing.asc private_output=signing.pem creation_time=2026-06-01T00:00:00Z fingerprint=...
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;WARN Move the private-half file to offline storage now. private_output=signing.pem
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Two files come out: &lt;code&gt;signing.asc&lt;/code&gt; (the public half) and &lt;code&gt;signing.pem&lt;/code&gt; (the
private half, a PKCS#1 PEM). The private half is the thing you guard. There&amp;rsquo;s no
on-disk passphrase in this version of &lt;code&gt;gtb&lt;/code&gt;, so keep it under filesystem
encryption (LUKS, FileVault, or wrap it with &lt;code&gt;age&lt;/code&gt;) rather than leaving it lying
about.&lt;/p&gt;
&lt;p&gt;One flag is doing quiet but important work: &lt;code&gt;--created&lt;/code&gt;. An OpenPGP key&amp;rsquo;s
fingerprint is derived partly from its creation time, so if you let it default
to &amp;ldquo;now&amp;rdquo;, every run produces a &lt;em&gt;different&lt;/em&gt; fingerprint. Pin it to a fixed
instant and the key is reproducible, which matters the moment you start
embedding it in a binary. Get in the habit now.&lt;/p&gt;
&lt;h2 id="mint-the-public-key-youll-actually-publish"&gt;Mint the public key you&amp;rsquo;ll actually publish
&lt;/h2&gt;&lt;p&gt;You could hand &lt;code&gt;signing.asc&lt;/code&gt; around as-is, but we&amp;rsquo;re going to produce the public
key a slightly different way, with &lt;code&gt;gtb keys mint&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gtb keys mint &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --backend &lt;span class="nb"&gt;local&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --key-id signing.pem &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --name &lt;span class="s2"&gt;&amp;#34;Acme Releases&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --email &lt;span class="s2"&gt;&amp;#34;release@acme.dev&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --created &lt;span class="s2"&gt;&amp;#34;2026-06-01T00:00:00Z&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --output release.asc
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;INFO Minted OpenPGP key backend=local key_id=signing.pem output=release.asc creation_time=2026-06-01T00:00:00Z fingerprint=...
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;mint&lt;/code&gt; wraps a &lt;em&gt;signing backend&lt;/em&gt; in OpenPGP framing and writes out the armored
public key. Here the backend is &lt;code&gt;local&lt;/code&gt; (a PEM file on disk), but in production
it&amp;rsquo;ll be &lt;code&gt;aws-kms&lt;/code&gt; pointing at a key you can&amp;rsquo;t hold. Minting the public key from
the backend is the one habit worth forming early: it&amp;rsquo;s the only way to get the
public half of a KMS key, so doing it the same way locally means the rest of the
series is identical bar one flag. &lt;code&gt;release.asc&lt;/code&gt; is the key you publish and embed
from here on. (Because we pinned the same &lt;code&gt;--created&lt;/code&gt;, its fingerprint matches
the generated one exactly.)&lt;/p&gt;
&lt;h2 id="sign-something"&gt;Sign something
&lt;/h2&gt;&lt;p&gt;A real release signs its &lt;code&gt;checksums.txt&lt;/code&gt;, so make a stand-in and sign it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;abc123 acme_linux_amd64\ndef456 acme_darwin_arm64\n&amp;#39;&lt;/span&gt; &amp;gt; checksums.txt
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gtb sign &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --backend &lt;span class="nb"&gt;local&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --key-id signing.pem &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --public-key release.asc &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; checksums.txt
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;INFO Signed file backend=local key_id=signing.pem public_key=release.asc input=checksums.txt output=checksums.txt.sig ...
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That writes &lt;code&gt;checksums.txt.sig&lt;/code&gt;, a detached, ASCII-armored OpenPGP signature.
Note &lt;code&gt;gtb sign&lt;/code&gt; takes &lt;code&gt;--public-key&lt;/code&gt;: it cross-checks that the backend key
matches the public key you claim to be signing as, and refuses if they diverge,
so you can&amp;rsquo;t accidentally sign with the wrong key.&lt;/p&gt;
&lt;p&gt;&lt;img alt="Signing a checksums file and verifying it, then a tampered copy failing" class="gallery-image" data-flex-basis="360px" data-flex-grow="150" height="800" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-1/demo-sign-verify.gif" width="1200"&gt;

&lt;/p&gt;
&lt;h2 id="verify-it-two-ways"&gt;Verify it, two ways
&lt;/h2&gt;&lt;p&gt;First, the way your tool will do it on every self-update: against the public
key. That path is the subject of &lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;a signature the platform can&amp;rsquo;t
forge&lt;/a&gt; and we
wire it into a real binary in part 5. For now, prove the signature is sound with
something every machine already has, &lt;code&gt;gpg&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gpg --import release.asc
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gpg --verify checksums.txt.sig checksums.txt
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gpg: Signature made ...
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gpg: using RSA key ...
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gpg: Good signature from &amp;#34;Acme Releases &amp;lt;release@acme.dev&amp;gt;&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Good signature&lt;/code&gt; is the whole point. The signature &lt;code&gt;gtb sign&lt;/code&gt; produced is an
ordinary OpenPGP detached signature, so anyone can verify it with the standard
tool, no go-tool-base required. (&lt;code&gt;gpg&lt;/code&gt; will warn the key isn&amp;rsquo;t certified in its
web of trust; that&amp;rsquo;s expected and unrelated to whether the signature is valid.)&lt;/p&gt;
&lt;p&gt;Now change a byte of &lt;code&gt;checksums.txt&lt;/code&gt; and run the verify again. &lt;code&gt;gpg&lt;/code&gt; reports
&lt;code&gt;BAD signature&lt;/code&gt;. That failure is the entire reason any of this exists: a tampered
manifest no longer matches the signature, and a tool that &lt;em&gt;requires&lt;/em&gt; a valid
signature will refuse the update.&lt;/p&gt;
&lt;h2 id="where-this-leaves-you"&gt;Where this leaves you
&lt;/h2&gt;&lt;p&gt;You&amp;rsquo;ve signed a file with a key you made and verified it independently. That&amp;rsquo;s
the complete trust loop in miniature, and the shape never changes: a private key
signs, a public key verifies, and the two are produced and checked the same way
whether the private half is a &lt;code&gt;.pem&lt;/code&gt; on your laptop or an HSM-held key in AWS.&lt;/p&gt;
&lt;p&gt;The local key was the easy bit, and also the weakest: it&amp;rsquo;s a file, and files get
copied. &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-2/" &gt;Part 2&lt;/a&gt;
moves the private key somewhere it can&amp;rsquo;t be copied at all, AWS KMS, and the only
command that changes is the &lt;code&gt;--backend&lt;/code&gt; flag.&lt;/p&gt;</content:encoded></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 isPermaLink="true">https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-2/</guid><category>go-tool-base</category><category>signing</category><category>Orienteering</category><description>Stand up a production signing key in AWS KMS with Terraform, so the private half is generated inside KMS and can never be exported.</description><content:encoded>&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;</content:encoded></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 isPermaLink="true">https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-3/</guid><category>go-tool-base</category><category>signing</category><category>Orienteering</category><description>Let CI sign with a KMS key without storing any AWS credentials, using OIDC federation. Covers both the GitLab and the GitHub path.</description><content:encoded>&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;</content:encoded></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 isPermaLink="true">https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-4/</guid><category>go-tool-base</category><category>signing</category><category>Orienteering</category><description>Mint an OpenPGP public key from a KMS-held private key you cannot hold, and publish it somewhere the release platform cannot reach.</description><content:encoded>&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;</content:encoded></item><item><title>Sign your own binaries with go-tool-base, part 5: embed the key and require verification</title><link>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/</link><pubDate>Sun, 21 Jun 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/</guid><category>go-tool-base</category><category>signing</category><category>Orienteering</category><description>Embed the trust anchor in your binary and require signature verification on update, without bricking anyone already running an old build.</description><content:encoded>&lt;p&gt;By now you&amp;rsquo;ve got a public key your tool can publish off-platform: minted from a
KMS-held private key in &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-4/" &gt;Part 4&lt;/a&gt;
and served over WKD. That&amp;rsquo;s half the trust loop. The other half lives inside the
binary itself: the tool has to &lt;em&gt;hold a copy of the key it expects&lt;/em&gt; so that when an
update lands, it can check the signature against something an attacker who owns the
release page can&amp;rsquo;t quietly swap. This part bakes that trust anchor in, wires the
self-updater to use it, and turns enforcement on without locking out the people who
already have your tool installed.&lt;/p&gt;
&lt;p&gt;That last clause is the one that bites, so we&amp;rsquo;ll come to it slowly. First, turning
signing on.&lt;/p&gt;
&lt;h2 id="enable-signing-with-one-command"&gt;Enable signing with one command
&lt;/h2&gt;&lt;p&gt;Your root command is generated by &lt;code&gt;gtb&lt;/code&gt;, so you don&amp;rsquo;t wire signing in by hand-editing
it. You turn the feature on and let the generator do the wiring:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gtb &lt;span class="nb"&gt;enable&lt;/span&gt; signing --email release@acme.dev
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That one command does three things, all in generated, regenerable code you never touch:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;scaffolds an &lt;code&gt;internal/trustkeys&lt;/code&gt; package that &lt;code&gt;//go:embed&lt;/code&gt;s your release keys and
hands them to the self-updater;&lt;/li&gt;
&lt;li&gt;wires &lt;code&gt;Signing: props.SigningConfig{EmbeddedKeys: trustkeys.Keys()}&lt;/code&gt; into your
generated root command;&lt;/li&gt;
&lt;li&gt;writes a &lt;code&gt;signing.go&lt;/code&gt; holding the enforcement defaults, generated from a &lt;code&gt;signing&lt;/code&gt;
block it adds to your &lt;code&gt;.gtb/manifest.yaml&lt;/code&gt;. You change posture by re-running the
command, never by editing the file.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img alt="Enabling signing on a generated project with gtb enable signing" class="gallery-image" data-flex-basis="360px" data-flex-grow="150" height="800" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/demo-enable-signing.gif" width="1200"&gt;

&lt;/p&gt;
&lt;p&gt;Signing is off until you run this, on purpose: it needs a key and a published WKD
endpoint, so a freshly generated tool doesn&amp;rsquo;t carry it uninvited. (If you&amp;rsquo;re curious
what the embed package looks like, it&amp;rsquo;s a small &lt;code&gt;//go:embed all:keys&lt;/code&gt; over an
&lt;code&gt;internal/trustkeys/keys/&lt;/code&gt; directory with a &lt;code&gt;Keys() [][]byte&lt;/code&gt; accessor. The &lt;code&gt;all:&lt;/code&gt;
prefix is load-bearing: a plain &lt;code&gt;//go:embed keys&lt;/code&gt; won&amp;rsquo;t compile over a directory that
holds only a dotfile, so the scaffold keeps a &lt;code&gt;.gitkeep&lt;/code&gt; there. You don&amp;rsquo;t write any of
it.)&lt;/p&gt;
&lt;h2 id="drop-your-key-in"&gt;Drop your key in
&lt;/h2&gt;&lt;p&gt;The scaffold gives you an empty &lt;code&gt;internal/trustkeys/keys/&lt;/code&gt;. Put the public key you
minted in Part 4 into it, alongside the break-glass key:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cp signing-key-v1.asc internal/trustkeys/keys/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cp rotation-authority.asc internal/trustkeys/keys/
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;-v1&lt;/code&gt; in the filename isn&amp;rsquo;t decoration: you&amp;rsquo;ll rotate one day and embed
&lt;code&gt;signing-key-v2.asc&lt;/code&gt; alongside it for a release or two (Part 7). The rotation-authority
key rides along the same way. Rebuild, and &lt;code&gt;trustkeys.Keys()&lt;/code&gt; now returns them. With no
&lt;code&gt;.asc&lt;/code&gt; present it returns nothing and verification stays dormant, so enabling signing
before you have a key breaks nothing.&lt;/p&gt;
&lt;h2 id="the-wkd-cross-check-via---email"&gt;The WKD cross-check, via &lt;code&gt;--email&lt;/code&gt;
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;--email&lt;/code&gt; you passed is doing real work. The embedded key alone is a static anchor
(&lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;how the verification works&lt;/a&gt;):
it only ever says &amp;ldquo;this was the key on the day I was built.&amp;rdquo; Pairing it with the live
WKD copy you published in Part 4 gives a second, independent source the release platform
can&amp;rsquo;t reach. The default key source is &lt;code&gt;both&lt;/code&gt;, an embedded-plus-WKD &lt;code&gt;CompositeResolver&lt;/code&gt;,
and the email is what lets the updater derive the WKD URL. Leave &lt;code&gt;--email&lt;/code&gt; off and
&lt;code&gt;both&lt;/code&gt; quietly degrades to embedded-only.&lt;/p&gt;
&lt;p&gt;For a locked-down tool that should refuse to update when it &lt;em&gt;can&amp;rsquo;t&lt;/em&gt; reach WKD, rather
than fall back to the embedded key, enable the strict cross-check:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gtb &lt;span class="nb"&gt;enable&lt;/span&gt; signing --email release@acme.dev --require-external-crosscheck
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Most tools want the softer default, where WKD strengthens verification when it&amp;rsquo;s
reachable and the embedded key still works when it isn&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="confirm-the-cross-check-is-actually-firing"&gt;Confirm the cross-check is actually firing
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s easy to get this wrong by leaving the email out, and when you do, nothing
complains: the updater quietly verifies against the embedded key alone and carries
on. The way to know which anchors were actually consulted is to read the log. Every
update prints a line naming the resolver it used:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;INFO signature verified resolver=composite[embedded,wkd:openpgpkey.acme.dev]
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That &lt;code&gt;resolver=&lt;/code&gt; field is the whole tell:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;composite[embedded,wkd:...]&lt;/code&gt; is what you want: both the embedded key and the
WKD-served key were fetched, their fingerprints agreed, and the signature checked
against the result. The cross-check is live.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;embedded&lt;/code&gt; means only the baked-in key was used and WKD was never consulted.
That&amp;rsquo;s the silent-degrade trap: &lt;code&gt;key_source&lt;/code&gt; is &lt;code&gt;&amp;quot;both&amp;quot;&lt;/code&gt;, but with no external
email there was no WKD URL to derive, so it fell back to a single anchor. If you
see this after passing &lt;code&gt;--email&lt;/code&gt;, the value didn&amp;rsquo;t take.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;wkd:...&lt;/code&gt; on its own is the reverse: WKD was consulted but nothing was embedded.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There&amp;rsquo;s a matching &lt;code&gt;update signature verification configured resolver=...&lt;/code&gt; line at
the very start of an update, before any network call, if you&amp;rsquo;d rather see the choice
before the fetch. Two failure shapes are worth recognising too.
&lt;code&gt;WARN composite resolver failed (RequireAll=false, continuing)&lt;/code&gt; means the WKD fetch
fell over (a 404, a flaky network) and the update carried on against the embedded key
alone, the soft default you can harden with &lt;code&gt;--require-external-crosscheck&lt;/code&gt;.
&lt;code&gt;ERROR ErrKeyResolverMismatch&lt;/code&gt; is the one you &lt;em&gt;want&lt;/em&gt; to see fire in anger: the
embedded and WKD keys disagreed, which is exactly the tamper alarm the whole scheme
exists to raise (the same mismatch the
&lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;verification deep-dive&lt;/a&gt;
walks through).&lt;/p&gt;
&lt;h2 id="require-it-in-the-right-order"&gt;Require it, in the right order
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the part everyone trips over. Enabling signing does not yet &lt;em&gt;require&lt;/em&gt; it:
&lt;code&gt;require_signature&lt;/code&gt; stays off, and that&amp;rsquo;s deliberate. Turning it on too early breaks
self-update for everyone who already has your tool.&lt;/p&gt;
&lt;p&gt;Think about what an existing install holds. A user on an old version has a binary built
&lt;em&gt;before&lt;/em&gt; you enabled signing. It has no trust anchor. If the first thing it ever sees
is a signed, signature-required release, it has nothing to check the signature against,
and the update is refused. You&amp;rsquo;ve locked out exactly the people you were protecting.&lt;/p&gt;
&lt;p&gt;The fix is to ship the key ahead of the requirement, so the anchor is already on the
user&amp;rsquo;s machine by the time the first mandatory signature arrives. gtb follows the
rollout in its own &lt;code&gt;docs/development/phase2-signing-prep.md&lt;/code&gt;, across three releases:&lt;/p&gt;

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

 &lt;/blockquote&gt;
&lt;p&gt;When you reach N+3, it&amp;rsquo;s one command, not a code edit:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gtb &lt;span class="nb"&gt;enable&lt;/span&gt; signing --require-signature
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That flips &lt;code&gt;require_signature&lt;/code&gt; in the manifest and regenerates &lt;code&gt;signing.go&lt;/code&gt;, so the
change is tracked and reproducible. Skipping the middle release, requiring a signature
before you&amp;rsquo;ve shipped one, is the exact mistake the ordering exists to prevent; gtb&amp;rsquo;s
own rollout cites v0.12.2 as its first signed release for precisely this reason.&lt;/p&gt;
&lt;p&gt;The checksum floor from Phase 1 sits underneath all this and is already on; signature
verification adds to it, it doesn&amp;rsquo;t replace it. And even with signatures required, an
end user genuinely stuck on a legacy release can escape with the &lt;code&gt;update.require_signature&lt;/code&gt;
config key or your tool&amp;rsquo;s &lt;code&gt;&amp;lt;PREFIX&amp;gt;_UPDATE_REQUIRE_SIGNATURE=false&lt;/code&gt;. It&amp;rsquo;s an escape
hatch, not the front door, but it means a requirement you turn on can&amp;rsquo;t permanently
strand anyone.&lt;/p&gt;
&lt;h2 id="where-this-leaves-you"&gt;Where this leaves you
&lt;/h2&gt;&lt;p&gt;Your binary now carries the key it expects, checks every update against that key and
its live WKD twin, and refuses anything that doesn&amp;rsquo;t match, all without a &lt;code&gt;gpg&lt;/code&gt;
install on the user&amp;rsquo;s side and without stranding the installs that came before the key.
The trust loop you built by hand in Part 1 now runs on its own, inside a stranger&amp;rsquo;s
copy of your tool.&lt;/p&gt;
&lt;p&gt;The one thing still missing is the signature itself on each release. Right now nothing
is actually &lt;em&gt;producing&lt;/em&gt; &lt;code&gt;checksums.txt.sig&lt;/code&gt;. &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-6/" &gt;Part 6&lt;/a&gt;
wires the KMS signing from Parts 2 and 3 into a real GoReleaser pipeline, so every
tagged release comes out signed without you touching a key.&lt;/p&gt;</content:encoded></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 isPermaLink="true">https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-6/</guid><category>go-tool-base</category><category>signing</category><category>Orienteering</category><description>Make a tagged release sign itself: record the KMS key and regenerate GoReleaser config with a signs block that calls gtb sign directly.</description><content:encoded>&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;</content:encoded></item><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 isPermaLink="true">https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-7/</guid><category>go-tool-base</category><category>signing</category><category>Orienteering</category><description>Rotate a release signing key and recover from a bad one without locking users out. Why asymmetric KMS keys have no auto-rotate button.</description><content:encoded>&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;</content:encoded></item><item><title>A signature the platform can't forge</title><link>https://phpboyscout.uk/a-signature-the-platform-cant-forge/</link><pubDate>Tue, 09 Jun 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/a-signature-the-platform-cant-forge/</guid><category>go-tool-base</category><category>security</category><category>signing</category><category>Pioneering</category><description>A checksum proves the bytes match the manifest, but says nothing about who wrote the manifest. Why self-update needs a signature instead.</description><content:encoded>&lt;p&gt;A self-updating tool has a chicken-and-egg problem baked into it. The thing doing the
updating is the thing being updated, so when it reaches out and pulls down a newer
version of itself, it&amp;rsquo;s the one that has to decide whether to trust what just landed.
No human in the loop, nobody to ask. I&amp;rsquo;ve been closing that gap in go-tool-base&amp;rsquo;s
self-updater in two phases. The
&lt;a class="link" href="https://phpboyscout.uk/verifying-your-own-downloads/" &gt;first&lt;/a&gt; gave it a
checksum: download the new binary, hash it, compare it against the release&amp;rsquo;s
&lt;code&gt;checksums.txt&lt;/code&gt;. That catches the accidents, the truncated download, the flipped bit
on a dodgy mirror. And I said at the time, plainly, that it does nothing about a
determined attacker who owns the release platform&amp;hellip; the checksums file sits right
next to the binary, so whoever can swap one can swap both. I left that as an IOU.
This second phase is me paying it.&lt;/p&gt;
&lt;h2 id="the-thing-a-checksum-cant-do"&gt;The thing a checksum can&amp;rsquo;t do
&lt;/h2&gt;&lt;p&gt;A checksum is a promise that the bytes you got match the manifest. It says nothing
about &lt;em&gt;who wrote the manifest&lt;/em&gt;. So if GitLab, or my account, or a leaked CI token
gets compromised, the attacker rewrites the binary and the &lt;code&gt;checksums.txt&lt;/code&gt; in the
same breath, and the hash matches perfectly, because they&amp;rsquo;re the one who computed it.
It&amp;rsquo;s the same wall I keep walking into whenever I think about
&lt;a class="link" href="https://phpboyscout.uk/nobody-is-coming-to-clean-your-supply-chain/" &gt;supply-chain trust&lt;/a&gt;:
a checksum is only ever as good as whatever&amp;rsquo;s standing behind it, and the thing
standing behind a checksum is the very platform that just handed you the file. Same
hands, both times.&lt;/p&gt;
&lt;p&gt;To get past that, you need a signature whose root of trust lives somewhere the
platform can&amp;rsquo;t reach.&lt;/p&gt;
&lt;h2 id="the-crypto-is-the-easy-part"&gt;The crypto is the easy part
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the bit that caught me slightly off guard while I was building this: the
cryptography is the easy part. Verifying a detached OpenPGP signature is a library
call, and go-tool-base&amp;rsquo;s &lt;code&gt;TrustSet&lt;/code&gt; wraps it up in &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/v0.12.2/pkg/setup/signing.go#L237-L257" target="_blank" rel="noopener"
 &gt;one method&lt;/a&gt;:&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;TrustSet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;VerifyManifestSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&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="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="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;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;err&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;openpgp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CheckArmoredDetachedSignature&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;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entities&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&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="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&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="kc"&gt;nil&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="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Wrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ErrSignatureInvalid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Error&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="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="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;signer&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="kc"&gt;nil&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="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Wrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ErrSignatureInvalid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;no signer in trust set matched&amp;#34;&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="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="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&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;Hand it the manifest, the detached signature, and a set of trusted public keys (the
&lt;code&gt;entities&lt;/code&gt;), and it tells you whether any one of them signed it. That&amp;rsquo;s the whole of
the cryptography, and it&amp;rsquo;s genuinely not where the hard work lives.&lt;/p&gt;
&lt;p&gt;The hard work is that set of trusted public keys. Where do they come from? Because if
the answer is &amp;ldquo;we ship them right next to the binary&amp;rdquo;, well&amp;hellip; you&amp;rsquo;re straight back
to the checksum problem. Whoever can swap the binary can swap the key too, sign with
their own, and the check waves it through none the wiser.&lt;/p&gt;
&lt;h2 id="pulling-the-two-questions-apart"&gt;Pulling the two questions apart
&lt;/h2&gt;&lt;p&gt;So the design splits along exactly that seam. The verification half is fixed, and
deliberately boring (the method above). The trust anchor, the actual keys, comes from
a swappable
&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/v0.12.2/pkg/setup/signing.go#L259-L274" target="_blank" rel="noopener"
 &gt;&lt;code&gt;KeyResolver&lt;/code&gt;&lt;/a&gt;:&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="c1"&gt;// The interface separates &amp;#34;where the trust anchor comes from&amp;#34; from &amp;#34;how a&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="c1"&gt;// signature is verified against it&amp;#34;, so SelfUpdater can be wired with&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="c1"&gt;// whichever resolver chain a tool needs without changing verification logic.&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="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;KeyResolver&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;interface&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="nf"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&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="nf"&gt;Resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&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;TrustSet&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&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;That little seam is really the whole game. Everything interesting about standing up
to a compromised platform comes down to which resolver you hand the updater, and the
verification code never has to know the difference.&lt;/p&gt;
&lt;h2 id="three-answers-to-where-does-the-key-live"&gt;Three answers to &amp;ldquo;where does the key live&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;The first option is to embed it. Bake the public key straight into the binary at
build time (&lt;code&gt;NewEmbeddedResolver&lt;/code&gt;), so it rides along inside a release you already
trusted enough to run. Tidy and self-contained. The catch is that a &lt;em&gt;future&lt;/em&gt;
malicious release could embed a different key, so on its own, embedding really just
trusts whoever cut the most recent binary.&lt;/p&gt;
&lt;p&gt;The second is WKD, the Web Key Directory. Fetch the key over HTTPS from a well-known
path on a domain you control (&lt;code&gt;NewWKDResolver&lt;/code&gt;), nothing to do with where the release
itself is hosted. Now the key isn&amp;rsquo;t in the binary at all, so poisoning a release
doesn&amp;rsquo;t touch it. You haven&amp;rsquo;t made the problem disappear, mind&amp;hellip; you&amp;rsquo;ve moved the
trust onto your domain&amp;rsquo;s host and its DNS. A different blast radius, but a blast
radius all the same.&lt;/p&gt;
&lt;p&gt;The third option is to do both, and make them agree. Run embedded &lt;em&gt;and&lt;/em&gt; WKD, and
insist they &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/v0.12.2/pkg/setup/signing_composite.go#L61-L82" target="_blank" rel="noopener"
 &gt;agree&lt;/a&gt;:&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;CompositeResolver&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&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;TrustSet&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="c1"&gt;// ... run each child resolver concurrently ...&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="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&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="nf"&gt;checkAgreement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;successes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&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="kc"&gt;nil&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="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// ErrKeyResolverMismatch&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="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="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;successes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&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;Think of it as the two-key rule on a safe deposit box, or two witnesses who&amp;rsquo;ve never
met telling you the same story. One source on its own you might quietly doubt. But if
the key baked into the binary and the key sitting on my domain hand back the same
fingerprint, that agreement is worth a great deal more than either of them alone. And
if they ever come back different, that&amp;rsquo;s not a maybe, that&amp;rsquo;s an alarm:
&lt;code&gt;ErrKeyResolverMismatch&lt;/code&gt;. Poison one source and the mismatch is the thing that gives
the game away.&lt;/p&gt;
&lt;p&gt;That composite is the real answer, and it&amp;rsquo;s why the interface exists at all. There&amp;rsquo;s
nothing a single attacker can get their hands on that holds the whole thing up by
itself. The key is baked into a release you trusted, &lt;em&gt;and&lt;/em&gt; fetched from a domain well
off the release platform, &lt;em&gt;and&lt;/em&gt; the two have to match before a single byte of the
update is allowed through.&lt;/p&gt;
&lt;h2 id="the-separation-is-the-whole-point"&gt;The separation is the whole point
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s easy to nod along at &amp;ldquo;two sources&amp;rdquo; and miss the part that actually does the work.
The agreement between the embedded key and the WKD key is only worth something if an
attacker can&amp;rsquo;t reach both of them from the same place. If the key I bake into the
binary and the key I serve over WKD both came out of the same release pipeline,
whoever owns that pipeline swaps the pair of them, the fingerprints still match, and
the cross-check happily waves the forgery through. Same hands, both times. Again.&lt;/p&gt;
&lt;p&gt;So they don&amp;rsquo;t share a pipeline, and that&amp;rsquo;s the entire design, not an accident of how
things ended up. The binary, and the key embedded in it, are built and signed in
GitLab CI, which federates into AWS KMS to do the signing itself. The WKD key lives
somewhere else completely: a Cloudflare Pages site serving &lt;code&gt;openpgpkey.phpboyscout.uk&lt;/code&gt;,
deployed by hand at rotation time with the Wrangler CLI and a token allowed to do
nothing but edit that one Pages project. No Git integration, no webhook, nothing that
lets a push to the repo or a run of the release pipeline so much as touch it. The
Cloudflare account is even administered under a different email and a different second
factor from the GitLab and AWS ones, so the three anchors really are independent
rather than just feeling that way.&lt;/p&gt;
&lt;p&gt;Which is what makes them fail independently, and that independence is the only thing
that makes the agreement worth checking. To forge a release that survives the
cross-check, an attacker doesn&amp;rsquo;t have to beat one system, they have to beat two
unrelated ones, on different platforms, behind different credentials, in the same
window, without either of them noticing.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a quieter benefit in the cadence, too. Releases go out constantly and
automatically; the WKD key changes rarely, and only ever by hand. So the busy,
automated path, the one an attacker is most likely to prise open, is exactly the one
with no power to rewrite the key everyone checks against.&lt;/p&gt;
&lt;h2 id="requiring-it-without-breaking-everyone"&gt;Requiring it, without breaking everyone
&lt;/h2&gt;&lt;p&gt;Now, a check nobody ever switches on is just theatre. But switch it on before the
keys are actually out there in people&amp;rsquo;s installs, and you&amp;rsquo;ve handed everyone a
self-inflicted outage instead. So the default is deliberately timid. The framework
ships
&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/v0.12.2/pkg/setup/signing.go#L47-L57" target="_blank" rel="noopener"
 &gt;&lt;code&gt;DefaultRequireSignature = false&lt;/code&gt;&lt;/a&gt;:
a tool built on go-tool-base doesn&amp;rsquo;t suddenly start rejecting its own updates the day
its author bumps the framework version.&lt;/p&gt;
&lt;p&gt;The tool author flips it to &lt;code&gt;true&lt;/code&gt; in &lt;code&gt;main()&lt;/code&gt;, but only &lt;em&gt;after&lt;/em&gt; they&amp;rsquo;ve shipped a
release that embeds the key, so every install out there already holds the trust
anchor before the first release that insists on one. Ship the key, then turn the
lock: the same leave-yourself-a-way-back discipline as any migration you&amp;rsquo;d like to
still have a job after. And the end user still gets an override
(&lt;code&gt;update.require_signature&lt;/code&gt;, or an env var) for the day it all goes sideways and they
need out.&lt;/p&gt;
&lt;h2 id="what-it-actually-buys"&gt;What it actually buys
&lt;/h2&gt;&lt;p&gt;The first phase stopped accidents. This one stops the platform. And not because the
cryptography is clever, OpenPGP checks the signature in a single call, but because the
trust anchor is arranged so that nothing the attacker can actually reach holds the
whole thing up on its own. A signature only ever proves the sender, never the
contents. All of this is really about making &amp;ldquo;the sender&amp;rdquo; something a compromised
release host can&amp;rsquo;t quietly fake its way into being.&lt;/p&gt;
&lt;p&gt;Which leaves one last thread dangling. The &lt;em&gt;verifying&lt;/em&gt; key gets fetched from
somewhere, fine&amp;hellip; but the &lt;em&gt;signing&lt;/em&gt; key, the private half that actually produces
these signatures, has to live somewhere the platform can&amp;rsquo;t reach either, or none of
the rest holds up. That&amp;rsquo;s the capstone, and where this series ends: where that key
lives, and why it never leaves the box it&amp;rsquo;s born in.&lt;/p&gt;</content:encoded></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 isPermaLink="true">https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/</guid><category>go-tool-base</category><category>security</category><category>signing</category><category>Pioneering</category><description>Generate an asymmetric RSA-4096 signing key inside AWS KMS with no export path, then sign releases by calling kms:Sign instead of holding the key.</description><content:encoded>&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;</content:encoded></item><item><title>The gpg command that hung (so I built signing into the tool)</title><link>https://phpboyscout.uk/the-gpg-command-that-hung/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/the-gpg-command-that-hung/</guid><category>go-tool-base</category><category>signing</category><category>Pioneering</category><description>A gpg call that hung and never returned, and why release signing ended up built into the Go tool itself rather than shelled out to the gpg binary.</description><content:encoded>&lt;p&gt;This one starts at the day job, not the side projects. My employer ships Go products and wants to move quickly on them, which is reasonable, but nobody wants to hand-maintain a fresh pile of the same boilerplate every time a new service spins up. I&amp;rsquo;d had go-tool-base running as a product of my own for a while by then, solving exactly that, so I put my hand up: use this, and the boilerplate stops being your problem.&lt;/p&gt;
&lt;p&gt;There was a condition, and it was the right one. To go anywhere near our codebase it had to clear the security bar, and ours is not a low bar. So I set about hardening the thing properly, layer by layer, until it could pass the regulations the business actually has to answer to rather than the ones a side project can wave away. One of those layers, unavoidably, is signing your releases. If you&amp;rsquo;re shipping binaries into an enterprise, &amp;ldquo;trust me, I built it&amp;rdquo; is not a supply-chain story anyone signs off on.&lt;/p&gt;
&lt;p&gt;Which is how I came to be generating a signing key by hand with gpg. Or trying to.&lt;/p&gt;
&lt;p&gt;The incantation is the sort of thing you copy from your own notes from the last time, feed it the right flags, and walk away from while it does its business. So I did. And it just&amp;hellip; hung. No key. I went round the houses. &lt;code&gt;--list-secret-keys&lt;/code&gt; came back empty while &lt;code&gt;--list-keys&lt;/code&gt; cheerfully showed the public half. &lt;code&gt;GNUPGHOME&lt;/code&gt; had the public part and not the secret one, which is a contradiction in terms if you think about it for more than a second. I was, in short, fighting gpg&amp;rsquo;s environment rather than doing the actual work, and getting nowhere.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ll spare you the precise sequence of me being wrong, because the sequence isn&amp;rsquo;t the point. The point arrived somewhere in the middle of it, and it changed the whole approach.&lt;/p&gt;
&lt;p&gt;If &lt;em&gt;I&amp;rsquo;m&lt;/em&gt; fighting gpg here, on my own machine, with my own notes, then every single team that adopts this framework is going to fight gpg too.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the thing about distributing a tool, especially into a place where ten teams will pick it up rather than one. The boilerplate they didn&amp;rsquo;t want to maintain&amp;hellip; that wasn&amp;rsquo;t just the HTTP wiring and the config plumbing. It was this. The fiddly, environment-dependent, fails-in-a-different-way-on-every-machine business of producing a signing key. Push that onto every team and you haven&amp;rsquo;t given them a framework, you&amp;rsquo;ve given them a homework assignment with a security deadline.&lt;/p&gt;
&lt;p&gt;So it moved inside the box. Key generation and minting became commands in the binary itself: &lt;code&gt;gtb keys generate&lt;/code&gt; to produce a fresh keypair, &lt;code&gt;gtb keys mint&lt;/code&gt; to turn it into the signing key the release process wants, no shell-out to gpg anywhere in the path. The gpg backend I&amp;rsquo;d started with got dropped entirely rather than papered over. The tool now does the awkward part the same way on every machine, because it&amp;rsquo;s the same code doing it every time, not a conversation with whatever gpg thinks &lt;code&gt;GNUPGHOME&lt;/code&gt; means today.&lt;/p&gt;
&lt;p&gt;The actual cryptography underneath is still real OpenPGP, and the question of how you mint a key whose private half never leaves a hardware module is &lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;its own story&lt;/a&gt;. This isn&amp;rsquo;t really about the crypto. It&amp;rsquo;s about where you put the sharp edges.&lt;/p&gt;
&lt;p&gt;When you build a tool for yourself, you can live with the dangerous bits. You learn where they are, you work around them, and they become invisible. When you hand that tool to an enterprise full of people who&amp;rsquo;ve never met it, everything you left out in the open is a support ticket, a stalled rollout, or worse, a team deciding signing is too much hassle and skipping it. Keeping the sharp edges inside the box isn&amp;rsquo;t polish. For this, it was the difference between a security control people use and one they route around.&lt;/p&gt;
&lt;p&gt;The best thing I shipped that week wasn&amp;rsquo;t a cleverer algorithm. It was a key that couldn&amp;rsquo;t fail to generate. Unexciting, I&amp;rsquo;ll grant you. But an unexciting key you actually have beats a clever one you&amp;rsquo;re still fighting at 11pm.&lt;/p&gt;</content:encoded></item><item><title>The framework doesn't know where your signing key lives</title><link>https://phpboyscout.uk/the-framework-doesnt-know-where-your-signing-key-lives/</link><pubDate>Tue, 04 Aug 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/the-framework-doesnt-know-where-your-signing-key-lives/</guid><category>go-tool-base</category><category>signing</category><category>Pioneering</category><description>Where a signing key lives is the most provider-specific decision in the whole business, so the framework deliberately refuses to have an opinion.</description><content:encoded>&lt;p&gt;A consumer of go-tool-base asked me where their signing key was supposed to live, and the answer was: that&amp;rsquo;s deliberately not my call. There&amp;rsquo;s a deliberate hole in go-tool-base&amp;rsquo;s signing, and it&amp;rsquo;s the most important part of the design. The framework will sign your releases, mint you an OpenPGP key, produce a detached signature a consumer can verify. What it will not do is have an opinion about where your private key actually lives. It genuinely doesn&amp;rsquo;t know, and that&amp;rsquo;s on purpose.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s why that hole has to be there. Of all the decisions wrapped up in &amp;ldquo;sign your releases,&amp;rdquo; the one that varies most wildly between one shop and the next is where the signing key is kept. One team has standardised on AWS KMS and everything has to go through it. The next is a Google shop and it&amp;rsquo;s GCP KMS or nothing. A third runs Vault. A fourth, the security-conscious solo dev, wants the private half sitting on a YubiKey in a drawer and never anywhere else. These aren&amp;rsquo;t preferences you can talk someone out of, they&amp;rsquo;re usually a compliance boundary or an existing investment, and they&amp;rsquo;re load-bearing.&lt;/p&gt;
&lt;p&gt;So if the framework hard-codes one of them, say it bakes in AWS KMS, then every shop that isn&amp;rsquo;t an AWS shop has to fork it to sign anything. And forking a framework to change one provider integration is the worst of all worlds: you inherit the maintenance, you fall off the upgrade path, and you&amp;rsquo;ve taken a clean dependency and turned it into a patched copy you now own. The place your users diverge most is the exact place a framework must not be opinionated.&lt;/p&gt;
&lt;p&gt;The way out is an interface Go hands you for free: &lt;code&gt;crypto.Signer&lt;/code&gt;. If you don&amp;rsquo;t write Go daily, the idea is simple and worth a sentence. &lt;code&gt;crypto.Signer&lt;/code&gt; is the standard library&amp;rsquo;s name for &amp;ldquo;a thing that can sign,&amp;rdquo; and the trick is what it deliberately leaves out. It exposes a public key and a single &lt;code&gt;Sign&lt;/code&gt; method, and it says nothing whatsoever about where the private key is or how the signing happens. The same interface covers a key sitting in memory and a key in a hardware module the program can never read, because from the caller&amp;rsquo;s side both look identical: you hand over a digest, you get back a signature, and you are never given the private key. It&amp;rsquo;s the same abstraction the standard library&amp;rsquo;s own TLS stack uses to serve HTTPS from an HSM-held key.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s what the whole design hangs on. The actual signing code, the part in &lt;code&gt;pkg/openpgpkey&lt;/code&gt; that mints OpenPGP signatures, asks for a &lt;code&gt;crypto.Signer&lt;/code&gt; and uses it. It has no idea, and no way to find out, whether the signer it&amp;rsquo;s holding talks to a local key, fires an API call at KMS, or lights up a YubiKey. It just signs. The dangerous, provider-specific bit and the boring, universal bit have been cleanly separated, and the only thing passing between them is &amp;ldquo;here&amp;rsquo;s something that can sign.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Which leaves the question of how the right signer gets chosen, and that&amp;rsquo;s a small registry. A backend implements one method, &lt;code&gt;NewSigner(ctx, keyID) (crypto.Signer, error)&lt;/code&gt;, hands back a signer for whatever provider it speaks, and registers itself at startup, the usual Go pattern where a package&amp;rsquo;s &lt;code&gt;init()&lt;/code&gt; calls &lt;code&gt;Register&lt;/code&gt; and you pull the backend in with a blank import. &lt;code&gt;gtb&lt;/code&gt; ships with two compiled in, a local one and an AWS KMS one. If your shop is the GCP or the Vault or the YubiKey case, you write a backend that satisfies that one interface, blank-import it, and you&amp;rsquo;re signing through it, without editing a line of framework code. You add a provider, you don&amp;rsquo;t fork a framework.&lt;/p&gt;
&lt;p&gt;A couple of details fall out of doing it this way that I like more than I expected to. Backends don&amp;rsquo;t own credential resolution, the caller configures how you authenticate to the provider and the backend just turns a key ID into a signer, so the credential story stays where it belongs and the backend stays tiny. And because backends are compiled in rather than discovered at runtime, a binary built with none of them fails loudly and specifically, telling you it &amp;ldquo;was built without any signing backends compiled in&amp;rdquo; rather than mysteriously refusing to sign. You only carry the providers you actually asked for.&lt;/p&gt;
&lt;p&gt;This sits underneath the more concrete signing stories, the one about &lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;a signature the platform that hosts you can&amp;rsquo;t forge&lt;/a&gt; and the one about &lt;a class="link" href="https://phpboyscout.uk/the-gpg-command-that-hung/" &gt;why key generation moved into the tool&lt;/a&gt; in the first place. Those are about what signing buys you and why it has to be painless. This is the quieter architectural decision sitting beneath both: pick the one part of the problem that&amp;rsquo;s genuinely different at every org, refuse to decide it for them, and put a single clean interface where the decision would have gone. The framework signs everything the same way. It just never asks where the key sleeps at night.&lt;/p&gt;</content:encoded></item><item><title>Bought, not stolen</title><link>https://phpboyscout.uk/bought-not-stolen/</link><pubDate>Tue, 23 Jun 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/bought-not-stolen/</guid><category>security</category><category>signing</category><category>Soapbox</category><description>The philosophical end of the signing series: a stolen key and a bought one produce the same signature, and trust has to survive both.</description><content:encoded>&lt;p&gt;The malware that spent months wearing Microsoft&amp;rsquo;s trust didn&amp;rsquo;t steal a thing. No cracked certificate authority, no private key lifted off some breached vendor. There was, in effect, a shop: you uploaded your malware to a website, paid somewhere between five and nine thousand dollars, and got it back signed with a real, valid certificate. The same kind that vouches for the software you actually want on your machine.&lt;/p&gt;
&lt;p&gt;The crew running that shop is one Microsoft&amp;rsquo;s Digital Crimes Unit tracks as Fox Tempest, and the certificates weren&amp;rsquo;t forged. They were minted through Microsoft&amp;rsquo;s own code-signing service, which Fox Tempest abused under a pile of fake identities and impersonated companies, then resold to anyone with the money. The malware they dressed up was the usual rogues&amp;rsquo; gallery, the Oyster backdoor, the Lumma and Vidar infostealers, often got up as spoofed Teams or AnyDesk installers. In May, Microsoft pulled the operation apart: more than a thousand fraudulent certificates revoked, the infrastructure seized, a lawsuit filed against Fox Tempest and the ransomware crew behind Rhysida that had been paying for the service. For the better part of a year, a valid Microsoft signature was a product you could check out of a basket.&lt;/p&gt;
&lt;p&gt;The reflex is to file this under &amp;ldquo;another supply-chain breach&amp;rdquo;. It isn&amp;rsquo;t one. Nothing was breached. The system did exactly what it was built to do, for a paying customer who&amp;rsquo;d lied about who they were. That&amp;rsquo;s the part worth sitting with.&lt;/p&gt;
&lt;h2 id="we-have-been-here-before"&gt;We have been here before
&lt;/h2&gt;&lt;p&gt;None of this is new. I went back and read a Trend Micro write-up on code-signing abuse from 2018, and it could have gone out last week. Stuxnet carried valid signatures from stolen Realtek and JMicron certificates. After Sony Pictures was ransacked in 2014, the attackers signed their Destover malware with Sony&amp;rsquo;s own keys. The names rotate and the methods rotate, the story doesn&amp;rsquo;t: there is a trust mark, there is money in wearing it, so somebody works out how to wear it.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve started thinking of them as honest villains. Not honest with their victims, obviously. Honest in their incentives: a rational operator who finds a loophole and works it for everything it&amp;rsquo;s worth, because there&amp;rsquo;s profit on the other side of it. You can be as furious at Fox Tempest as you like, and I am, prosecution is exactly right, but the honest villain is never the surprise. The honest villain is a constant. Build a gate worth getting through and one will turn up to test it&amp;hellip; every single time! That isn&amp;rsquo;t cynicism. It might be the most dependable law we&amp;rsquo;ve got.&lt;/p&gt;
&lt;h2 id="a-seal-was-never-a-promise"&gt;A seal was never a promise
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the thing we keep forgetting about a signature, and it goes back a very long way.&lt;/p&gt;
&lt;p&gt;For most of human history, trust was blood. You trusted the people you were bound to, by birth and by the pacts of kinship and marriage that held a tribe together. It worked, for tens of thousands of years, because the circle was small enough that everyone you needed to trust was someone you knew, or someone known to someone you knew. The bond and the accountability were the same thing. Betray the tribe and the tribe knew precisely whose door to come to.&lt;/p&gt;
&lt;p&gt;Then we outgrew the tribe. We started trading with strangers, across distances and across lifetimes, with people we would never meet and could never vouch for by blood. And blood stopped being enough. So we built stand-ins for it. The wax seal pressed into a letter. The signature at the foot of a contract. The notary, the stamp, the certificate. Each one a small portable proxy for the bond of kinship we&amp;rsquo;d walked away from.&lt;/p&gt;
&lt;p&gt;But look at what a seal actually did. A seal on a letter never told you the letter was true. It told you whose seal it was, which is to say it told you who to hold responsible if the letter turned out to be a lie. It was never a guarantee of honesty. It was a marker of accountability. It pointed at a person.&lt;/p&gt;
&lt;p&gt;A code signature is the newest seal in that very old line, and it does the same single job. It does not tell you the software is safe. It was never built to. It tells you who signed it, which is to say who to come to when it isn&amp;rsquo;t. That&amp;rsquo;s the whole of it. The padlock in the browser, the green tick on the installer, the verified signature on a binary, none of them ever meant &amp;ldquo;this is good&amp;rdquo;. They meant &amp;ldquo;here is a name attached to this&amp;rdquo;.&lt;/p&gt;
&lt;h2 id="the-part-you-cannot-sell"&gt;The part you cannot sell
&lt;/h2&gt;&lt;p&gt;Which brings me to the uncomfortable bit, the one that points back at me as much as at Redmond.&lt;/p&gt;
&lt;p&gt;If a signature is a name accepting responsibility, then the power to sign and the blame for what you sign are the same object. You don&amp;rsquo;t get one without the other. You cannot hold out the seal and quietly keep back the accountability behind it, because the accountability is the only thing the seal was ever made of.&lt;/p&gt;
&lt;p&gt;That is exactly what came apart here. Microsoft holds enormous power as a signing authority, the power to make code look trustworthy to millions of machines. With that power comes the plain duty to check who you&amp;rsquo;re handing it to. They took the first part and skimped on the second. They sold the seal and skipped the diligence the seal is supposed to stand for, and the honest villain simply walked through the gap between the two.&lt;/p&gt;
&lt;p&gt;And I have to hold myself to that very same rule, or I&amp;rsquo;ve no business naming it. I sign my own releases. Every go-tool-base release carries an OpenPGP signature over its checksums, made by &lt;a class="link" href="https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/" &gt;a key that never leaves AWS KMS&lt;/a&gt;, with the public key &lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;published off-platform&lt;/a&gt; so the release host can&amp;rsquo;t quietly swap it. I do it so the people who use my tools, and they&amp;rsquo;re a varied bunch, can trust that what they&amp;rsquo;re running genuinely came from me.&lt;/p&gt;
&lt;p&gt;But trusting me was never really the question. Here&amp;rsquo;s the one that keeps me up: what happens the day I&amp;rsquo;m the weak point? If a contributor slips something rotten into my code, or one of my own AI agents writes something it shouldn&amp;rsquo;t, and my automation dutifully signs it, then the signature does its job perfectly and the whole house of cards comes down. My users can trust me all they like. The seal will still say &amp;ldquo;Matt&amp;rdquo;, and it will be telling the truth, and that is precisely the problem.&lt;/p&gt;
&lt;p&gt;So the accountability can&amp;rsquo;t be delegated, and I don&amp;rsquo;t try to. Nothing reaches my releasable branches without my own eyes on it first. No merge request, no commit, not from a contributor and not from one of my agents. The vigilance is mine, singularly, and that&amp;rsquo;s deliberate, because the blame is mine too, singularly, and they&amp;rsquo;re the same coin. It&amp;rsquo;s an easier thing to say as a one-man outfit than it&amp;rsquo;ll be if that ever changes. But the principle doesn&amp;rsquo;t get cheaper at scale. It just gets harder to honour, which is a very different thing from optional.&lt;/p&gt;
&lt;h2 id="what-standing-behind-it-looks-like"&gt;What standing behind it looks like
&lt;/h2&gt;&lt;p&gt;If you want to see the duty done properly, look at how OpenAI handled the Axios incident earlier this year. A poisoned dependency had got at the material used to sign their macOS app. They had every reason to believe their certificates were fine. They revoked them anyway, and rebuilt, because the cost of being wrong was their users&amp;rsquo; machines and their own name on the door. That&amp;rsquo;s what holding the power looks like. You act on the possibility of compromise, not the proof of it, because by the time you&amp;rsquo;ve got proof it&amp;rsquo;s already on somebody&amp;rsquo;s laptop.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s also why I&amp;rsquo;ve come to treat trust as layers rather than a single tick in a box. The release signature is one layer. Notarisation is another: every macOS binary go-tool-base ships is notarised by Apple as well, and has been for a good while. There will be more, and the Rust side of my tooling has its own signing coming very soon. None of them is the answer on its own. Each one is just another check, another name standing behind the thing. And the part I care about most, as someone who builds tools other people build on, is handing that same machinery to them, so they can stand behind their own releases for their own customers instead of &lt;a class="link" href="https://phpboyscout.uk/nobody-is-coming-to-clean-your-supply-chain/" &gt;trusting that someone, somewhere up the chain, did the diligence&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="vigilance-still"&gt;Vigilance, still
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;d love to tell you there&amp;rsquo;s a clean technical fix on the way, a trust system the honest villain can&amp;rsquo;t game&amp;hellip; There isn&amp;rsquo;t. We&amp;rsquo;ve tried plenty, and some have aged better than others. (I&amp;rsquo;m looking at you, blockchain, the confident answer to a question almost nobody was asking!) Whatever we build next, the same loop runs: the mechanism gets more elaborate, so the attacks on it get more insidious, so the mechanism gets more elaborate again. We have spent the whole of human history moving trust further and further from the blood bond that used to ground it, and every step we take away from a person who will answer for it, the honest villain takes right alongside us.&lt;/p&gt;
&lt;p&gt;Maybe there&amp;rsquo;s a version of the future where this stops&amp;hellip; some distant place where nobody needs to cheat because nobody wants for anything, and greed has quietly retired. I&amp;rsquo;m not holding my breath. Until then the job is the plodding one it has always been: stay vigilant, act before you&amp;rsquo;re certain, and keep trust as close as you can to a person who&amp;rsquo;ll stand behind it. Because that is the only thing a seal ever was. Not a promise that the thing is good. A name, and someone willing to answer to it.&lt;/p&gt;</content:encoded></item><item><title>Release trust without the framework</title><link>https://phpboyscout.uk/release-trust-without-the-framework/</link><pubDate>Sun, 05 Jul 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/release-trust-without-the-framework/</guid><category>signing</category><category>First Light</category><description>Two new public modules extracting the OpenPGP signing and verification model, so you can use it without adopting the whole framework.</description><content:encoded>&lt;p&gt;A few days ago I shipped &lt;a class="link" href="https://phpboyscout.uk/introducing-afmpeg-and-ffmpeg-wasi/" &gt;afmpeg and ffmpeg-wasi&lt;/a&gt;, a way to run FFmpeg as a WebAssembly module straight from Go with nothing installed on the host. afmpeg fetches that wasm module at runtime and then&amp;hellip; runs it. A lovely trick, right up until you ask the obvious question: how does afmpeg know the wasm it just pulled off the internet is the one &lt;em&gt;I&lt;/em&gt; built, and not something swapped in on the way down?&lt;/p&gt;
&lt;p&gt;It has to check a signature. And I already had the machinery to do exactly that&amp;hellip; it was just bolted to the inside of go-tool-base.&lt;/p&gt;
&lt;p&gt;So I pulled it out. Two new modules, both public: &lt;a class="link" href="https://gitlab.com/phpboyscout/signing" target="_blank" rel="noopener"
 &gt;&lt;code&gt;signing&lt;/code&gt;&lt;/a&gt; and &lt;a class="link" href="https://gitlab.com/phpboyscout/signing-aws-kms" target="_blank" rel="noopener"
 &gt;&lt;code&gt;signing-aws-kms&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="the-part-i-lifted-out"&gt;The part I lifted out
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;signing&lt;/code&gt; is the OpenPGP/WKD signing-and-verification model the rest of my tools already lean on. It&amp;rsquo;s the same code behind &lt;code&gt;gtb update&lt;/code&gt; when it checks its own releases, lifted out into a standalone module you can drop into any project. Verify a signed release, or sign your own, and you do it &lt;em&gt;without&lt;/em&gt; dragging the whole go-tool-base framework (and its dependency tree, which is not small) in behind it.&lt;/p&gt;
&lt;p&gt;That last bit is the whole reason it&amp;rsquo;s a separate module and not just a package. Go has a rule here that trips people up: when your code imports a package, you inherit that package&amp;rsquo;s &lt;em&gt;entire&lt;/em&gt; module dependency list&amp;hellip; the full &lt;code&gt;go.mod&lt;/code&gt;, not just the corner you actually touched. So lifting one tidy little package out of go-tool-base into a sub-package would still have handed every consumer viper, OpenTelemetry, the whole charm stack, the lot. Only a separate module, with its own minimal &lt;code&gt;go.mod&lt;/code&gt;, keeps that weight off your build. The core here leans on ProtonMail&amp;rsquo;s go-crypto and cockroachdb/errors, and nothing else.&lt;/p&gt;
&lt;h2 id="the-backend-you-inject"&gt;The backend you inject
&lt;/h2&gt;&lt;p&gt;Signing needs a key, and keys live in awkward places: a PEM file on disk, a YubiKey, AWS KMS, GCP, Azure, a Vault. The remote ones drag in heavy SDKs. The AWS KMS client &lt;em&gt;alone&lt;/em&gt; pulls in 57 modules, and I really didn&amp;rsquo;t fancy that sitting in the core where everyone pays for it whether they ever touch KMS or not.&lt;/p&gt;
&lt;p&gt;So &lt;code&gt;signing&lt;/code&gt; doesn&amp;rsquo;t implement those backends at all. It defines an interface (a thing that hands back a &lt;code&gt;crypto.Signer&lt;/code&gt;) and lets you inject whichever backend you actually use. A light &lt;code&gt;local&lt;/code&gt; backend (a PEM key on disk) ships in the box, as a sensible default and a worked example of the shape. The heavy ones are separate modules you opt into, one at a time.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://gitlab.com/phpboyscout/signing-aws-kms" target="_blank" rel="noopener"
 &gt;&lt;code&gt;signing-aws-kms&lt;/code&gt;&lt;/a&gt; is the first of them. It wraps a KMS-held key as a signer, so &lt;a class="link" href="https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/" &gt;the private half never leaves AWS&lt;/a&gt; and every signature is a round-trip to the cloud. Blank-import it and it registers itself. GCP, Azure and Vault will follow the same pattern, and because each is its own module, your binary only ever carries the one you reached for. And if none of them fit? The interface is right there for you to write your own.&lt;/p&gt;
&lt;h2 id="the-proof-already-running"&gt;The proof, already running
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t a library out looking for a user. ffmpeg-wasi signs its release assets in CI right now, with a KMS key driven through the go-tool-base CLI, and afmpeg verifies that signature against an embedded key before it hands a single byte to the runtime.&lt;/p&gt;
&lt;p&gt;No valid signature, no run.&lt;/p&gt;
&lt;p&gt;The thing that needed the trust and the thing that provides it are two separate projects, talking to each other through a signature.&lt;/p&gt;
&lt;p&gt;The verifying key isn&amp;rsquo;t only baked into afmpeg, either. It&amp;rsquo;s published to WKD on my own domain, so the anchor you check against lives somewhere my git host has no say over. A signature is only ever worth as much as the key you check it against, and &lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;pinning that to my own domain rather than the platform that hosts my code&lt;/a&gt; is a deliberate bit of the posture, not an accident of wherever a file happened to be convenient to drop.&lt;/p&gt;
&lt;h2 id="where-it-leaves-things"&gt;Where it leaves things
&lt;/h2&gt;&lt;p&gt;Two more modules out in the world, both public, both documented over at &lt;a class="link" href="https://signing.phpboyscout.uk" target="_blank" rel="noopener"
 &gt;signing.phpboyscout.uk&lt;/a&gt;. The selfish win is that afmpeg got to trust its own downloads without me reinventing a single thing to do it. The broader one is that the signing model I keep banging on about is no longer something you have to swallow my entire framework to use. Take the part you want, and leave the rest on the shelf.&lt;/p&gt;</content:encoded></item><item><title>One key, or two?</title><link>https://phpboyscout.uk/one-key-or-two/</link><pubDate>Wed, 05 Aug 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/one-key-or-two/</guid><category>signing</category><category>First Light</category><description>Launching sigillum, a standalone artefact signing and verification CLI, and the Rust signing problem that made it necessary.</description><content:encoded>&lt;p&gt;The Rust toolkit needed to sign its own release binaries, and I had this pencilled in as an afternoon. Precompiled &lt;code&gt;cargo-bin&lt;/code&gt; crate assets are conventionally signed with Ed25519, everything I own is signed with an RSA key that never leaves AWS KMS, and so the question was just&amp;hellip; does Rust get its own key, or can it use the one I already have?&lt;/p&gt;
&lt;p&gt;It was not an afternoon.&lt;/p&gt;
&lt;p&gt;Because two keys means two places a private key lives, two rotation procedures, and two things to remember. One of which is definitely going to be the one I forget.&lt;/p&gt;
&lt;h2 id="why-ed25519-in-the-first-place"&gt;Why Ed25519 in the first place
&lt;/h2&gt;&lt;p&gt;Worth saying, because it never made it into the original ticket and it&amp;rsquo;s the thing that makes the rest of this make sense. Ed25519 is what the Rust ecosystem&amp;rsquo;s tooling is built around, and conventions are rather the point when the person doing the verifying isn&amp;rsquo;t you.&lt;/p&gt;
&lt;p&gt;You &lt;em&gt;can&lt;/em&gt; bend RSA into that shape, mind. It&amp;rsquo;s a faff, it isn&amp;rsquo;t considered best practice, and you end up standing outside every assumption the tooling makes&amp;hellip; which is a lovely way to build something nobody can check without ringing you up first.&lt;/p&gt;
&lt;h2 id="three-walls-stacked"&gt;Three walls, stacked
&lt;/h2&gt;&lt;p&gt;They turned up one behind the other, the way these things do, and each one looked final on its own.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The backend simply refused.&lt;/strong&gt; Our KMS signing backend parses the public key and hard-rejects anything that isn&amp;rsquo;t RSA. Which is fine, until you look at &lt;em&gt;why&lt;/em&gt;: sitting right next to that check were two comments explaining that KMS doesn&amp;rsquo;t expose Ed25519 at all.&lt;/p&gt;
&lt;p&gt;And that was true. Once. I picked it up somewhere years back, from a source I couldn&amp;rsquo;t name for you now, and I wrote it into our own documentation as settled fact. Then I did the thing everybody does with their own documentation, which is believe it. Meanwhile AWS got on with being a competent cloud provider and shipped Ed25519 support at the tail end of 2025, and I never went back to look. Why would you? You wrote it down.&lt;/p&gt;
&lt;p&gt;So the guard in my code was defending against a limitation that had stopped existing, and the tidy explanation sitting next to it was the exact reason nobody ever poked at it. My own note, in my own repo, in my own words, wrong for months.&lt;/p&gt;
&lt;p&gt;RTFM, kids! Then read it again next year, because the M keeps changing and your notes don&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;The proof turns up the moment you mint a key and try to use it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript3" data-lang="gdscript3"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;ERRO&lt;/span&gt; &lt;span class="n"&gt;constructing&lt;/span&gt; &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;KMS&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="n"&gt;is&lt;/span&gt; &lt;span class="n"&gt;ed25519&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PublicKey&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;KMS&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="n"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;RSA&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;only&lt;/span&gt; &lt;span class="n"&gt;RSA&lt;/span&gt; &lt;span class="n"&gt;SIGN_VERIFY&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="n"&gt;are&lt;/span&gt; &lt;span class="n"&gt;supported&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;The containers disagreed.&lt;/strong&gt; Our signing stack for Go was built around OpenPGP, and emits it armoured. The Rust side wants a raw 64-byte signature or a minisign file, and hasn&amp;rsquo;t got an OpenPGP parser anywhere in it. Same maths, completely different envelope, and no amount of goodwill gets one to read the other.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;And then the size cap.&lt;/strong&gt; KMS will sign a message of at most 4096 bytes. Release artefacts are megabytes. There&amp;rsquo;s a prehashed Ed25519 mode that looks for all the world like the way round it, and isn&amp;rsquo;t: it&amp;rsquo;s a &lt;em&gt;different algorithm&lt;/em&gt;, not a size escape, and the two can&amp;rsquo;t be swapped for each other.&lt;/p&gt;
&lt;p&gt;Stack all that up and the conclusion rather writes itself. KMS can&amp;rsquo;t sign the artefact, so the Rust key has to be a software key, sat somewhere less protected than the HSM everything else lives in.&lt;/p&gt;
&lt;p&gt;That was the design. Written up, cited, and about ten minutes from agreed.&lt;/p&gt;
&lt;h2 id="the-bit-i-was-stubborn-about"&gt;The bit I was stubborn about
&lt;/h2&gt;&lt;p&gt;Two things stopped it, and neither was clever. First, I asked for the receipt on the one claim holding the whole thing up: the 4 KB cap. Show me that in the docs. Second, and this is the one that mattered, I refused to treat everything around the problem as fixed. Was signing the binary really the only mechanism open to us? We own the updater. We can change how it works&amp;hellip; nobody handed that down on a stone tablet.&lt;/p&gt;
&lt;p&gt;That second question is what cracked it, because answering it meant somebody had to write down what minisign actually &lt;em&gt;does&lt;/em&gt;, rather than what it&amp;rsquo;s for. And there it was, sat in one sentence:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;The file is first hashed with BLAKE2b-512, then Ed25519 signs that 64-byte digest.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;Sixty-four bytes.&lt;/p&gt;
&lt;p&gt;Nobody signs the file. Nobody has &lt;em&gt;ever&lt;/em&gt; signed the file! The thing that goes to the signer is a digest, and a digest is three orders of magnitude inside the limit that had spent two days doing a very convincing impression of a brick wall.&lt;/p&gt;
&lt;p&gt;And here&amp;rsquo;s the bit that still bothers me: every fact in the way was true. The 4 KB cap is real. The prehashed variant really is a different algorithm. The artefacts really are megabytes. The error was in the &lt;em&gt;join&lt;/em&gt;, applying a constraint to the file when the file was never the thing crossing the boundary. That&amp;rsquo;s a lot harder to spot than something invented, because every citation you chase comes back clean and you end up more convinced, not less. I&amp;rsquo;ll write that one up on its own, it deserves more room than I can give it here.&lt;/p&gt;
&lt;p&gt;So: one key, or two? One. Go binaries, wasm modules, ONNX models and Rust crates all sign against the same root, and there&amp;rsquo;s one rotation to remember instead of two.&lt;/p&gt;
&lt;h2 id="so-sigillum"&gt;So, sigillum
&lt;/h2&gt;&lt;p&gt;Which left a tool to build, because none of the above helps a Rust pipeline if the only way to run it is to install a Go CLI framework first.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://gitlab.com/phpboyscout/sigillum" target="_blank" rel="noopener"
 &gt;sigillum&lt;/a&gt; is a single binary that signs things and manages the keys it signs with.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sigillum sign &amp;lt;file&amp;gt; # detached, ASCII-armoured OpenPGP signature
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sigillum sign --format minisign # ...or minisign, for the Rust side
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sigillum keys generate # a fresh Ed25519 or RSA keypair
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sigillum keys mint # an OpenPGP public key from an existing signer
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sigillum keys wkd # a Web Key Directory tree from your public keys
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The private key never leaves the KMS, the HSM or the local PEM file, and the &lt;a class="link" href="https://phpboyscout.uk/the-framework-doesnt-know-where-your-signing-key-lives/" &gt;backend registry&lt;/a&gt; underneath is the same one the framework has always used.&lt;/p&gt;
&lt;h2 id="the-commands-didnt-move-they-got-a-second-front-door"&gt;The commands didn&amp;rsquo;t move. They got a second front door.
&lt;/h2&gt;&lt;p&gt;This is the bit I&amp;rsquo;d have got wrong reading it from the outside, so let me be precise about it.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;gtb sign&lt;/code&gt; and &lt;code&gt;gtb keys&lt;/code&gt; still exist. Not deprecated, not relocated, not reimplemented. If you use them, nothing has changed and you can stop reading this section.&lt;/p&gt;
&lt;p&gt;What moved is where the command &lt;em&gt;definitions&lt;/em&gt; live. They came out of the framework and into &lt;code&gt;go/signing-cli&lt;/code&gt;, a small module sat between &lt;a class="link" href="https://phpboyscout.uk/release-trust-without-the-framework/" &gt;&lt;code&gt;go/signing&lt;/code&gt;&lt;/a&gt; (where all the real logic has always lived) and anything fancying a command surface on top of it. It exports constructors, &lt;code&gt;NewCmdSign&lt;/code&gt;, &lt;code&gt;NewCmdKeys&lt;/code&gt; and friends, each taking a logger and handing you back a plain cobra command.&lt;/p&gt;
&lt;p&gt;go-tool-base then mounts them, in its own root:&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="nx"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Wrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;signingcli&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewCmdKeys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetLogger&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="nx"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Wrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;signingcli&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewCmdSign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetLogger&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And sigillum mounts the same constructors, at the top level, as its whole reason for existing.&lt;/p&gt;
&lt;p&gt;So these aren&amp;rsquo;t two implementations held in step by discipline and hope. They&amp;rsquo;re the same commands, compiled into two binaries, versioned as a module. Fix a flag once and both get it.&lt;/p&gt;
&lt;p&gt;Why its own module, rather than a package tucked inside the framework? The dependency graph, basically. In gtb those were internal commands because gtb is both a library &lt;em&gt;and&lt;/em&gt; a CLI, so it could comfortably carry the pair. sigillum is a pure CLI with shareable commands and all the real logic stays down in &lt;code&gt;go/signing&lt;/code&gt;. Sticking the definitions in the middle is what heads off the cyclical reference nightmare every other arrangement walks straight into.&lt;/p&gt;
&lt;p&gt;Commands feel like glue. Like the bit of your binary that isn&amp;rsquo;t really code, just wiring. Pull them out and version them and it turns out they&amp;rsquo;re a library like anything else&amp;hellip; and once they are, the second tool costs you almost nothing.&lt;/p&gt;
&lt;h2 id="the-name"&gt;The name
&lt;/h2&gt;&lt;p&gt;The working title was &lt;code&gt;signctl&lt;/code&gt;. Descriptive, forgettable, and about the four hundredth thing in this space named after what it does.&lt;/p&gt;
&lt;p&gt;Signing tools are not exactly an empty field! Between cosign, minisign, signify, notary, gpg and everything orbiting sigstore, there&amp;rsquo;s no arrangement of &amp;ldquo;sign&amp;rdquo; and a suffix left that isn&amp;rsquo;t either taken or one letter off something taken. You start reaching for the second syllable and every door&amp;rsquo;s got a name on it already.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sigillum&lt;/strong&gt; is Latin for a seal. It sits with the other things round here that got a name rather than a label (keryx the herald, krites the judge), and a wax seal was already the visual language every signing post on this blog had been leaning on anyway, so mostly I was catching up with my own cover art.&lt;/p&gt;
&lt;p&gt;There is, I found out afterwards, already a &lt;code&gt;sigillum&lt;/code&gt; on crates.io. Different ecosystem, different problem, and I&amp;rsquo;m relaxed about it. Naming is mostly the art of finding something that isn&amp;rsquo;t &lt;em&gt;too&lt;/em&gt; taken.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;sigillum is at &lt;a class="link" href="https://gitlab.com/phpboyscout/sigillum" target="_blank" rel="noopener"
 &gt;v0.2.2&lt;/a&gt;, with docs at &lt;a class="link" href="https://sigillum.phpboyscout.uk" target="_blank" rel="noopener"
 &gt;sigillum.phpboyscout.uk&lt;/a&gt;. It&amp;rsquo;s a rubber stamp. After a fortnight of arguing about elliptic curves, that is precisely what I wanted it to be.&lt;/p&gt;</content:encoded></item></channel></rss>