<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Self-Update on PHP Boy Scout</title><link>https://phpboyscout.uk/tags/self-update/</link><description>Recent content in Self-Update on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Sun, 21 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/tags/self-update/index.xml" rel="self" type="application/rss+xml"/><item><title>Sign your own binaries with go-tool-base, part 5: embed the key and require verification</title><link>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/</link><pubDate>Sun, 21 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/</guid><description>&lt;img src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/cover-sign-your-own-binaries-with-go-tool-base-part-5.png" alt="Featured image of post Sign your own binaries with go-tool-base, part 5: embed the key and require verification" /&gt;&lt;p&gt;By now you&amp;rsquo;ve got a public key your tool can publish off-platform: minted from a
KMS-held private key in &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-4/" &gt;Part 4&lt;/a&gt;
and served over WKD. That&amp;rsquo;s half the trust loop. The other half lives inside the
binary itself: the tool has to &lt;em&gt;hold a copy of the key it expects&lt;/em&gt; so that when an
update lands, it can check the signature against something an attacker who owns the
release page can&amp;rsquo;t quietly swap. This part bakes that trust anchor in, wires the
self-updater to use it, and turns enforcement on without locking out the people who
already have your tool installed.&lt;/p&gt;
&lt;p&gt;That last clause is the one that bites, so we&amp;rsquo;ll come to it slowly. First, turning
signing on.&lt;/p&gt;
&lt;h2 id="enable-signing-with-one-command"&gt;Enable signing with one command
&lt;/h2&gt;&lt;p&gt;Your root command is generated by &lt;code&gt;gtb&lt;/code&gt;, so you don&amp;rsquo;t wire signing in by hand-editing
it. You turn the feature on and let the generator do the wiring:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gtb &lt;span class="nb"&gt;enable&lt;/span&gt; signing --email release@acme.dev
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That one command does three things, all in generated, regenerable code you never touch:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;scaffolds an &lt;code&gt;internal/trustkeys&lt;/code&gt; package that &lt;code&gt;//go:embed&lt;/code&gt;s your release keys and
hands them to the self-updater;&lt;/li&gt;
&lt;li&gt;wires &lt;code&gt;Signing: props.SigningConfig{EmbeddedKeys: trustkeys.Keys()}&lt;/code&gt; into your
generated root command;&lt;/li&gt;
&lt;li&gt;writes a &lt;code&gt;signing.go&lt;/code&gt; holding the enforcement defaults, generated from a &lt;code&gt;signing&lt;/code&gt;
block it adds to your &lt;code&gt;.gtb/manifest.yaml&lt;/code&gt;. You change posture by re-running the
command, never by editing the file.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img alt="Enabling signing on a generated project with gtb enable signing" class="gallery-image" data-flex-basis="360px" data-flex-grow="150" height="800" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/demo-enable-signing.gif" width="1200"&gt;
&lt;/p&gt;
&lt;p&gt;Signing is off until you run this, on purpose: it needs a key and a published WKD
endpoint, so a freshly generated tool doesn&amp;rsquo;t carry it uninvited. (If you&amp;rsquo;re curious
what the embed package looks like, it&amp;rsquo;s a small &lt;code&gt;//go:embed all:keys&lt;/code&gt; over an
&lt;code&gt;internal/trustkeys/keys/&lt;/code&gt; directory with a &lt;code&gt;Keys() [][]byte&lt;/code&gt; accessor. The &lt;code&gt;all:&lt;/code&gt;
prefix is load-bearing: a plain &lt;code&gt;//go:embed keys&lt;/code&gt; won&amp;rsquo;t compile over a directory that
holds only a dotfile, so the scaffold keeps a &lt;code&gt;.gitkeep&lt;/code&gt; there. You don&amp;rsquo;t write any of
it.)&lt;/p&gt;
&lt;h2 id="drop-your-key-in"&gt;Drop your key in
&lt;/h2&gt;&lt;p&gt;The scaffold gives you an empty &lt;code&gt;internal/trustkeys/keys/&lt;/code&gt;. Put the public key you
minted in Part 4 into it, alongside the break-glass key:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cp signing-key-v1.asc internal/trustkeys/keys/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cp rotation-authority.asc internal/trustkeys/keys/
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;-v1&lt;/code&gt; in the filename isn&amp;rsquo;t decoration: you&amp;rsquo;ll rotate one day and embed
&lt;code&gt;signing-key-v2.asc&lt;/code&gt; alongside it for a release or two (Part 7). The rotation-authority
key rides along the same way. Rebuild, and &lt;code&gt;trustkeys.Keys()&lt;/code&gt; now returns them. With no
&lt;code&gt;.asc&lt;/code&gt; present it returns nothing and verification stays dormant, so enabling signing
before you have a key breaks nothing.&lt;/p&gt;
&lt;h2 id="the-wkd-cross-check-via---email"&gt;The WKD cross-check, via &lt;code&gt;--email&lt;/code&gt;
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;--email&lt;/code&gt; you passed is doing real work. The embedded key alone is a static anchor
(&lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;how the verification works&lt;/a&gt;):
it only ever says &amp;ldquo;this was the key on the day I was built.&amp;rdquo; Pairing it with the live
WKD copy you published in Part 4 gives a second, independent source the release platform
can&amp;rsquo;t reach. The default key source is &lt;code&gt;both&lt;/code&gt;, an embedded-plus-WKD &lt;code&gt;CompositeResolver&lt;/code&gt;,
and the email is what lets the updater derive the WKD URL. Leave &lt;code&gt;--email&lt;/code&gt; off and
&lt;code&gt;both&lt;/code&gt; quietly degrades to embedded-only.&lt;/p&gt;
&lt;p&gt;For a locked-down tool that should refuse to update when it &lt;em&gt;can&amp;rsquo;t&lt;/em&gt; reach WKD, rather
than fall back to the embedded key, enable the strict cross-check:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gtb &lt;span class="nb"&gt;enable&lt;/span&gt; signing --email release@acme.dev --require-external-crosscheck
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Most tools want the softer default, where WKD strengthens verification when it&amp;rsquo;s
reachable and the embedded key still works when it isn&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="confirm-the-cross-check-is-actually-firing"&gt;Confirm the cross-check is actually firing
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s easy to get this wrong by leaving the email out, and when you do, nothing
complains: the updater quietly verifies against the embedded key alone and carries
on. The way to know which anchors were actually consulted is to read the log. Every
update prints a line naming the resolver it used:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;INFO signature verified resolver=composite[embedded,wkd:openpgpkey.acme.dev]
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That &lt;code&gt;resolver=&lt;/code&gt; field is the whole tell:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;composite[embedded,wkd:...]&lt;/code&gt; is what you want: both the embedded key and the
WKD-served key were fetched, their fingerprints agreed, and the signature checked
against the result. The cross-check is live.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;embedded&lt;/code&gt; means only the baked-in key was used and WKD was never consulted.
That&amp;rsquo;s the silent-degrade trap: &lt;code&gt;key_source&lt;/code&gt; is &lt;code&gt;&amp;quot;both&amp;quot;&lt;/code&gt;, but with no external
email there was no WKD URL to derive, so it fell back to a single anchor. If you
see this after passing &lt;code&gt;--email&lt;/code&gt;, the value didn&amp;rsquo;t take.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;wkd:...&lt;/code&gt; on its own is the reverse: WKD was consulted but nothing was embedded.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There&amp;rsquo;s a matching &lt;code&gt;update signature verification configured resolver=...&lt;/code&gt; line at
the very start of an update, before any network call, if you&amp;rsquo;d rather see the choice
before the fetch. Two failure shapes are worth recognising too.
&lt;code&gt;WARN composite resolver failed (RequireAll=false, continuing)&lt;/code&gt; means the WKD fetch
fell over (a 404, a flaky network) and the update carried on against the embedded key
alone, the soft default you can harden with &lt;code&gt;--require-external-crosscheck&lt;/code&gt;.
&lt;code&gt;ERROR ErrKeyResolverMismatch&lt;/code&gt; is the one you &lt;em&gt;want&lt;/em&gt; to see fire in anger: the
embedded and WKD keys disagreed, which is exactly the tamper alarm the whole scheme
exists to raise (the same mismatch the
&lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;verification deep-dive&lt;/a&gt;
walks through).&lt;/p&gt;
&lt;h2 id="require-it-in-the-right-order"&gt;Require it, in the right order
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the part everyone trips over. Enabling signing does not yet &lt;em&gt;require&lt;/em&gt; it:
&lt;code&gt;require_signature&lt;/code&gt; stays off, and that&amp;rsquo;s deliberate. Turning it on too early breaks
self-update for everyone who already has your tool.&lt;/p&gt;
&lt;p&gt;Think about what an existing install holds. A user on an old version has a binary built
&lt;em&gt;before&lt;/em&gt; you enabled signing. It has no trust anchor. If the first thing it ever sees
is a signed, signature-required release, it has nothing to check the signature against,
and the update is refused. You&amp;rsquo;ve locked out exactly the people you were protecting.&lt;/p&gt;
&lt;p&gt;The fix is to ship the key ahead of the requirement, so the anchor is already on the
user&amp;rsquo;s machine by the time the first mandatory signature arrives. gtb follows the
rollout in its own &lt;code&gt;docs/development/phase2-signing-prep.md&lt;/code&gt;, across three releases:&lt;/p&gt;

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

 &lt;/blockquote&gt;
&lt;p&gt;When you reach N+3, it&amp;rsquo;s one command, not a code edit:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gtb &lt;span class="nb"&gt;enable&lt;/span&gt; signing --require-signature
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That flips &lt;code&gt;require_signature&lt;/code&gt; in the manifest and regenerates &lt;code&gt;signing.go&lt;/code&gt;, so the
change is tracked and reproducible. Skipping the middle release, requiring a signature
before you&amp;rsquo;ve shipped one, is the exact mistake the ordering exists to prevent; gtb&amp;rsquo;s
own rollout cites v0.12.2 as its first signed release for precisely this reason.&lt;/p&gt;
&lt;p&gt;The checksum floor from Phase 1 sits underneath all this and is already on; signature
verification adds to it, it doesn&amp;rsquo;t replace it. And even with signatures required, an
end user genuinely stuck on a legacy release can escape with the &lt;code&gt;update.require_signature&lt;/code&gt;
config key or your tool&amp;rsquo;s &lt;code&gt;&amp;lt;PREFIX&amp;gt;_UPDATE_REQUIRE_SIGNATURE=false&lt;/code&gt;. It&amp;rsquo;s an escape
hatch, not the front door, but it means a requirement you turn on can&amp;rsquo;t permanently
strand anyone.&lt;/p&gt;
&lt;h2 id="where-this-leaves-you"&gt;Where this leaves you
&lt;/h2&gt;&lt;p&gt;Your binary now carries the key it expects, checks every update against that key and
its live WKD twin, and refuses anything that doesn&amp;rsquo;t match, all without a &lt;code&gt;gpg&lt;/code&gt;
install on the user&amp;rsquo;s side and without stranding the installs that came before the key.
The trust loop you built by hand in Part 1 now runs on its own, inside a stranger&amp;rsquo;s
copy of your tool.&lt;/p&gt;
&lt;p&gt;The one thing still missing is the signature itself on each release. Right now nothing
is actually &lt;em&gt;producing&lt;/em&gt; &lt;code&gt;checksums.txt.sig&lt;/code&gt;. &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-6/" &gt;Part 6&lt;/a&gt;
wires the KMS signing from Parts 2 and 3 into a real GoReleaser pipeline, so every
tagged release comes out signed without you touching a key.&lt;/p&gt;</description></item><item><title>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>https://phpboyscout.uk/a-signature-the-platform-cant-forge/</guid><description>&lt;img src="https://phpboyscout.uk/a-signature-the-platform-cant-forge/cover-a-signature-the-platform-cant-forge.png" alt="Featured image of post A signature the platform can't forge" /&gt;&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;</description></item><item><title>Building a CLI with go-tool-base, part 5: a CLI that updates itself</title><link>https://phpboyscout.uk/building-a-cli-with-go-tool-base-part-5/</link><pubDate>Sun, 24 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/building-a-cli-with-go-tool-base-part-5/</guid><description>&lt;img src="https://phpboyscout.uk/building-a-cli-with-go-tool-base-part-5/cover-building-a-cli-with-go-tool-base-part-5.png" alt="Featured image of post Building a CLI with go-tool-base, part 5: a CLI that updates itself" /&gt;&lt;p&gt;You ship version one. A week later someone finds a bug, you fix it, you cut version
two. Now for the awkward part: how does the person who installed version one ever
get version two? Email them? Hope they wander back to the install page? For a CLI
that lives on people&amp;rsquo;s machines, &amp;ldquo;go and re-download it&amp;rdquo; is the answer that quietly
strands half your users on old, broken builds. This part closes that gap, and like
most of this series, the work is already done for you: your tool has shipped with an
&lt;code&gt;update&lt;/code&gt; command since &lt;a class="link" href="https://phpboyscout.uk/building-a-cli-with-go-tool-base-part-1/" &gt;part 1&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;As before, this is written against &lt;strong&gt;go-tool-base v0.6.0&lt;/strong&gt; (&lt;code&gt;gtb version&lt;/code&gt;).&lt;/p&gt;
&lt;h2 id="the-command-is-already-there"&gt;The command is already there
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;update&lt;/code&gt; is one of the default features, so it&amp;rsquo;s been in your binary all along.
Your users run:&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;mytool update
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;and the tool fetches the newest release, checks it, and replaces itself in place. No
package manager, no re-download, no instructions. The rest of this part is about
what that one command actually does, and how to make sure the binary it pulls down
is the one you shipped.&lt;/p&gt;
&lt;h2 id="where-it-looks-for-releases"&gt;Where it looks for releases
&lt;/h2&gt;&lt;p&gt;A tool can&amp;rsquo;t update itself without knowing where its releases live. That&amp;rsquo;s the
&lt;code&gt;--repo&lt;/code&gt; you passed back in part 1: it filled in your tool&amp;rsquo;s release source, the
platform, owner and repository it checks. For &lt;code&gt;--repo myorg/mytool&lt;/code&gt; that&amp;rsquo;s
&lt;code&gt;github.com/myorg/mytool&lt;/code&gt;, and &lt;code&gt;mytool update&lt;/code&gt; looks at that project&amp;rsquo;s releases.&lt;/p&gt;
&lt;p&gt;go-tool-base speaks more than one platform here, GitHub, GitLab, Gitea, Codeberg,
Bitbucket, or a plain HTTP server, so the same command works whether you publish on
github.com or your own GitLab. If you ever need to point somewhere else (a mirror, a
private host), the
&lt;a class="link" href="https://gtb.phpboyscout.uk/how-to/custom-release-source/" target="_blank" rel="noopener"
 &gt;custom release source how-to&lt;/a&gt;
covers it; for a private repository it reads a token the same way the rest of the
tool does.&lt;/p&gt;
&lt;h2 id="what-update-does-step-by-step"&gt;What &lt;code&gt;update&lt;/code&gt; does, step by step
&lt;/h2&gt;&lt;p&gt;When a user runs it, the command walks a short, careful path:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Resolve the latest release&lt;/strong&gt; from your release source.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compare versions.&lt;/strong&gt; It reads the version baked into the running binary and
compares it, as semver, against the latest. If you&amp;rsquo;re already current, it says
so and stops: &lt;code&gt;already running latest version, v1.2.0&lt;/code&gt;. (If your build somehow
reports a version ahead of the latest published, it tells you off in character:
&lt;code&gt;your tardis travelled too far into the future...&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Download&lt;/strong&gt; the right archive for the user&amp;rsquo;s OS and architecture.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verify it&lt;/strong&gt; before trusting it (the next section).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Replace the running binary&lt;/strong&gt; with the new one, in place.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bring the config along.&lt;/strong&gt; If your tool has the &lt;code&gt;init&lt;/code&gt; feature (it does by
default), the update then runs the &lt;em&gt;new&lt;/em&gt; binary&amp;rsquo;s &lt;code&gt;init&lt;/code&gt; over the user&amp;rsquo;s config
directory to fold in anything the release added.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That last step is easy to miss and matters more than it looks. A new version often
ships new config: a key for a feature you just added, a changed default. Rather than
leave the user a version behind, with code that expects settings their config file
has never heard of, &lt;code&gt;update&lt;/code&gt; re-runs &lt;code&gt;init&lt;/code&gt; against their existing config once the
swap is done, non-interactively (it passes &lt;code&gt;--skip-login --skip-key&lt;/code&gt;, so nobody gets
re-prompted for a token). It&amp;rsquo;s the same initialiser system from
&lt;a class="link" href="https://phpboyscout.uk/building-a-cli-with-go-tool-base-part-2/" &gt;part 2&lt;/a&gt;,
reused: the merge keeps what the user set and adds what the new version introduced,
so the binary and its config move forward together. Turn the &lt;code&gt;init&lt;/code&gt; feature off and
this step is simply skipped, there&amp;rsquo;s no config to keep in step with.&lt;/p&gt;
&lt;p&gt;There are two flags worth knowing. &lt;code&gt;--version v1.3.0&lt;/code&gt; targets a specific release
instead of the latest, handy for pinning or rolling back. And &lt;code&gt;--force&lt;/code&gt; updates even
when the version check thinks you don&amp;rsquo;t need to. Most of the time, a bare &lt;code&gt;mytool update&lt;/code&gt; is the whole story.&lt;/p&gt;
&lt;h2 id="downloaded-isnt-the-same-as-trusted"&gt;Downloaded isn&amp;rsquo;t the same as trusted
&lt;/h2&gt;&lt;p&gt;A binary that arrives over the network is a binary you didn&amp;rsquo;t build on the machine
it&amp;rsquo;s running on, and a self-updater that swaps itself for whatever the server sent
is a lovely way to ship a corrupted or tampered build straight into your users'
hands. So before the swap, &lt;code&gt;update&lt;/code&gt; verifies what it downloaded against a checksum
manifest, the &lt;code&gt;checksums.txt&lt;/code&gt; GoReleaser produces alongside your binaries. If the
hash of the downloaded archive doesn&amp;rsquo;t match the one in the manifest, the update
aborts and nothing gets replaced.&lt;/p&gt;
&lt;p&gt;By default this is best-effort: a release that ships a &lt;code&gt;checksums.txt&lt;/code&gt; is verified,
but a release without one is updated with a warning rather than a hard stop. When you
want the guarantee, make it mandatory:&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="c"&gt;# in your tool&amp;#39;s config&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;update&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;require_checksum&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;Now a missing or mismatched checksum is a refusal, not a shrug. I wrote up why this
matters, and exactly what it does and doesn&amp;rsquo;t buy you, in
&lt;a class="link" href="https://phpboyscout.uk/verifying-your-own-downloads/" &gt;verifying your own downloads&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The limit is worth stating plainly, because it&amp;rsquo;s the whole reason there&amp;rsquo;s a
&amp;ldquo;part two&amp;rdquo; to this story. A checksum proves the binary matches the manifest &lt;em&gt;on the
same release page&lt;/em&gt;. It catches a corrupted download or a botched upload cold. What it
cannot catch is an attacker who owns the release platform and swaps both the binary
and its checksum in the same breath, because then the two still agree. Closing that
gap needs a signature whose trust root the release host can&amp;rsquo;t reach, which is a
different piece of machinery (and
&lt;a class="link" href="https://phpboyscout.uk/a-signing-key-needs-somewhere-to-live/" &gt;a post of its own&lt;/a&gt;).
go-tool-base now does exactly that: self-update signature verification has shipped, the
binary checking a detached signature against a key it both embeds and fetches over WKD
(&lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;how it works&lt;/a&gt;).
Until you turn signing on for your own tool, checksums are the floor, and a worthwhile
one.&lt;/p&gt;
&lt;h2 id="seeing-it-work-without-publishing-anything"&gt;Seeing it work without publishing anything
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the catch with writing about self-update: you can&amp;rsquo;t update from a release you
haven&amp;rsquo;t published, and your tutorial tool isn&amp;rsquo;t on anyone&amp;rsquo;s GitHub. There&amp;rsquo;s a flag
for exactly this, meant for offline and air-gapped installs but perfect for a look
under the hood: &lt;code&gt;--from-file&lt;/code&gt; installs from a local release archive instead of the
network.&lt;/p&gt;
&lt;p&gt;Build a snapshot of your tool the way your release pipeline would (GoReleaser&amp;rsquo;s
&lt;code&gt;--snapshot&lt;/code&gt; builds the archives without publishing), then point &lt;code&gt;update&lt;/code&gt; at one:&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;goreleaser release --snapshot --clean
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;mytool update --from-file ./dist/mytool_Linux_x86_64.tar.gz
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;You&amp;rsquo;ll watch the same extract-and-swap the network path uses, with nothing published
and no release source involved. It&amp;rsquo;s also genuinely useful in its own right, for
shipping into environments that can&amp;rsquo;t reach the internet.&lt;/p&gt;
&lt;h2 id="the-real-loop"&gt;The real loop
&lt;/h2&gt;&lt;p&gt;In production the cycle is the one part 1 already set you up for. The project gtb
scaffolds ships a GoReleaser config and a release pipeline, so the flow is:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Tag a version and push the tag.&lt;/li&gt;
&lt;li&gt;CI builds the binaries for every OS and architecture, generates &lt;code&gt;checksums.txt&lt;/code&gt;,
and publishes them as a release on your source.&lt;/li&gt;
&lt;li&gt;Your users run &lt;code&gt;mytool update&lt;/code&gt; and get it, verified.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;You write &lt;code&gt;git tag v1.3.0 &amp;amp;&amp;amp; git push --tags&lt;/code&gt;; everyone who installed v1.2.0 is one
command away from the fix. That&amp;rsquo;s the whole point of putting the update channel
inside the tool: shipping a fix becomes tagging a release, and nothing else.&lt;/p&gt;
&lt;h2 id="what-this-buys-you"&gt;What this buys you
&lt;/h2&gt;&lt;p&gt;A tool that updates itself turns &amp;ldquo;please go and reinstall&amp;rdquo; into &lt;code&gt;mytool update&lt;/code&gt;, and
a tool that verifies what it updates to turns &amp;ldquo;I hope that download was clean&amp;rdquo; into a
checked guarantee. Both came with the scaffold; the only work was understanding them.
The full reference, including the config keys and the per-platform release sources,
is in the
&lt;a class="link" href="https://gtb.phpboyscout.uk/components/commands/update/" target="_blank" rel="noopener"
 &gt;update command docs&lt;/a&gt; and the
&lt;a class="link" href="https://gtb.phpboyscout.uk/concepts/auto-update/" target="_blank" rel="noopener"
 &gt;auto-update concepts page&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Next part is the last one, and it&amp;rsquo;s about what happens after your tool is out there
doing its job: telemetry and logging, so you can see how it&amp;rsquo;s actually being used
without spying on the people using it. Until then, tag a release and watch your tool
catch up to itself.&lt;/p&gt;</description></item><item><title>A signing key needs somewhere to live</title><link>https://phpboyscout.uk/a-signing-key-needs-somewhere-to-live/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-signing-key-needs-somewhere-to-live/</guid><description>&lt;img src="https://phpboyscout.uk/a-signing-key-needs-somewhere-to-live/cover-a-signing-key-needs-somewhere-to-live.png" alt="Featured image of post A signing key needs somewhere to live" /&gt;&lt;p&gt;I left a door open a couple of posts ago, and it&amp;rsquo;s been quietly bothering me ever since. When I wrote about &lt;a class="link" href="https://phpboyscout.uk/verifying-your-own-downloads/" &gt;verifying your own downloads&lt;/a&gt;, I was honest that a checksum sitting next to the binary only catches accidents. Anyone who can compromise the release platform can swap the binary and the checksum together, and the tool will happily verify one fake against the other.&lt;/p&gt;
&lt;p&gt;Closing that gap needs a signature. And a signature, it turns out, needs a surprising amount of infrastructure standing behind it. This is the first post about building that.&lt;/p&gt;
&lt;h2 id="the-door-the-last-post-left-open"&gt;The door the last post left open
&lt;/h2&gt;&lt;p&gt;A while back I wrote about verifying your own downloads: go-tool-base&amp;rsquo;s self-update command now checks the SHA-256 of every binary it downloads against the release&amp;rsquo;s published &lt;code&gt;checksums.txt&lt;/code&gt; before installing it.&lt;/p&gt;
&lt;p&gt;That post was honest about its own ceiling. A checksum file hosted &lt;em&gt;next to&lt;/em&gt; the binary it describes shares a trust root with that binary. Both come from the same release, on the same platform. Corruption, truncation, a CDN serving a stale object: a same-origin checksum catches all of those, because they&amp;rsquo;re accidents and the checksum wasn&amp;rsquo;t part of the accident. What it can&amp;rsquo;t catch is an attacker who&amp;rsquo;s compromised the release platform itself. Someone who can replace the binary can replace &lt;code&gt;checksums.txt&lt;/code&gt; in the same breath, and the tool will cheerfully verify the malicious download against the malicious checksum and call it good.&lt;/p&gt;
&lt;p&gt;The post named the fix and then deferred it: a signature whose trust root sits somewhere the release platform can&amp;rsquo;t reach. &amp;ldquo;That&amp;rsquo;s the next phase of this work.&amp;rdquo; This series is that phase.&lt;/p&gt;
&lt;h2 id="what-a-signature-actually-needs"&gt;What a signature actually needs
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s worth being precise about why a signature helps where a checksum doesn&amp;rsquo;t, because it&amp;rsquo;s easy to wave the word &amp;ldquo;signature&amp;rdquo; around and assume it settles everything.&lt;/p&gt;
&lt;p&gt;A signature closes the gap only under two conditions. The verifying key, the public half, must reach the user by a path the release platform doesn&amp;rsquo;t control. And the signing key, the private half, must live somewhere the release platform can&amp;rsquo;t reach.&lt;/p&gt;
&lt;p&gt;The second condition is the one people skip. If the signing key sits in the same CI system that builds the release, you&amp;rsquo;ve gained almost nothing. An attacker who owns the CI owns the key, and a key they own will sign whatever they hand it. The signature verifies perfectly and means precisely nothing. A signature is only worth the distance between the signing key and the thing being signed. Put them in the same place and the distance is zero.&lt;/p&gt;
&lt;p&gt;So the signing key has to live in a different security domain from the release pipeline. Not a different folder. A different account, with a different blast radius, that the release platform has no standing access to.&lt;/p&gt;
&lt;h2 id="just-sign-the-binary-is-not-a-small-feature"&gt;&amp;ldquo;Just sign the binary&amp;rdquo; is not a small feature
&lt;/h2&gt;&lt;p&gt;That reframes a line item that sounds tiny. &amp;ldquo;Sign the release binary&amp;rdquo; unpacks into a list:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;there must be a private signing key;&lt;/li&gt;
&lt;li&gt;it must live outside the release platform, in its own security domain;&lt;/li&gt;
&lt;li&gt;it must be access-controlled, audited, and protected from exfiltration;&lt;/li&gt;
&lt;li&gt;only the release pipeline may ask it to sign, and only by proving a short-lived, federated identity, never by holding a copy of the key.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&amp;rsquo;s not a feature you bolt onto a CLI. That&amp;rsquo;s infrastructure.&lt;/p&gt;
&lt;p&gt;The shape of it: a cloud account, with the key held in a managed key service so the private key material never exists as a file on a disk that anyone, me included, can copy. The release pipeline authenticates to that account as itself, briefly, and asks the key service to produce a signature. The key never moves.&lt;/p&gt;
&lt;p&gt;But an account you&amp;rsquo;re going to trust with a signing key is itself something you have to get right first. An account with a weak baseline, no audit trail, and long-lived credentials lying around is not a safe home for the most security-sensitive key in the whole system. Before the key can move in, the house has to be built and the locks have to actually work.&lt;/p&gt;
&lt;h2 id="what-this-series-builds"&gt;What this series builds
&lt;/h2&gt;&lt;p&gt;So this turned into a rather longer project than &amp;ldquo;add a signature&amp;rdquo;, and the series follows it in order.&lt;/p&gt;
&lt;p&gt;It starts with bootstrapping a fresh AWS account: the deliberately minimal first &lt;code&gt;tofu apply&lt;/code&gt;, and the remote state backend that has a genuine chicken-and-egg problem. Then the credential question, which is the heart of it: how a CI pipeline deploys to AWS with no stored access key at all. Then hardening the account, so it&amp;rsquo;s genuinely safe to hold something valuable. Then the discipline of deploying changes to it: plans reviewed before they&amp;rsquo;re applied. Then the shared tooling that makes all of it repeatable.&lt;/p&gt;
&lt;p&gt;Every one of those pieces exists for the same reason. The signing key needs somewhere to live, and somewhere safe is not a default you&amp;rsquo;re handed. It&amp;rsquo;s a thing you build, deliberately, before you have anything worth protecting in it.&lt;/p&gt;
&lt;p&gt;The series ends where the verifying-downloads post pointed: a signing service whose key the release platform can&amp;rsquo;t touch, so a self-updating tool can finally verify that the binary it&amp;rsquo;s about to become is genuinely the one I published.&lt;/p&gt;
&lt;h2 id="the-upshot"&gt;The upshot
&lt;/h2&gt;&lt;p&gt;go-tool-base&amp;rsquo;s self-update verifies downloads against a checksum, and a same-origin checksum stops accidents but not a compromise of the release platform. The fix is a signature, and a signature is only worth the distance between its signing key and the release pipeline.&lt;/p&gt;
&lt;p&gt;Holding that key safely means a private key that never leaves a managed key service, in a separate cloud account, reached only by a short-lived federated identity. That&amp;rsquo;s infrastructure, and a safe account is something you build before you trust it with anything. The rest of this series builds it, piece by piece, right up to the signing service itself.&lt;/p&gt;</description></item><item><title>Process isolation won't save you from the filesystem</title><link>https://phpboyscout.uk/process-isolation-wont-save-you-from-the-filesystem/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/process-isolation-wont-save-you-from-the-filesystem/</guid><description>&lt;img src="https://phpboyscout.uk/process-isolation-wont-save-you-from-the-filesystem/cover-process-isolation-wont-save-you-from-the-filesystem.png" alt="Featured image of post Process isolation won't save you from the filesystem" /&gt;&lt;p&gt;A test that passed every single time I ran it on its own, and failed maybe one run in five when I ran the whole suite. The failure was always the same: the self-update test downloaded a release archive, went to extract it, and found the archive corrupt. Half-written. As if something had been scribbling in the file while it read it. Something had.&lt;/p&gt;
&lt;h2 id="the-comfort-i-was-leaning-on"&gt;The comfort I was leaning on
&lt;/h2&gt;&lt;p&gt;The self-update tests are heavier than a unit test wants to be. They stand up a fake release, download the artefact, verify its checksum, extract it, swap a binary. Real files, real I/O. So they&amp;rsquo;d been built to run as separate &lt;em&gt;processes&lt;/em&gt;, not just separate threads, each one its own little world.&lt;/p&gt;
&lt;p&gt;And I&amp;rsquo;d quietly filed that under &amp;ldquo;solved&amp;rdquo;. Separate processes don&amp;rsquo;t share an address space. One can&amp;rsquo;t reach into another&amp;rsquo;s memory and corrupt a value mid-read. That whole category of data race, the kind you reach for a mutex to fix, simply can&amp;rsquo;t happen across a process boundary. So I&amp;rsquo;d stopped thinking about concurrency in these tests at all, because I&amp;rsquo;d convinced myself the isolation was total.&lt;/p&gt;
&lt;p&gt;It wasn&amp;rsquo;t total. It was isolation of &lt;em&gt;memory&lt;/em&gt;, and I&amp;rsquo;d let myself hear it as isolation of &lt;em&gt;everything&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="two-processes-one-path"&gt;Two processes, one path
&lt;/h2&gt;&lt;p&gt;The thing two processes very much do still share is the filesystem. And the self-update flow, sensibly, caches its download rather than re-fetching it. The default cache directory is computed from the tool&amp;rsquo;s name and the release version, in &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-update/src/flow.rs#L228" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-update/src/flow.rs&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-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;cache_dir_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_name&lt;/span&gt;: &lt;span class="kp"&gt;&amp;amp;&lt;/span&gt;&lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;: &lt;span class="kp"&gt;&amp;amp;&lt;/span&gt;&lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="nc"&gt;PathBuf&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="kd"&gt;let&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;base&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="n"&gt;directories&lt;/span&gt;::&lt;span class="n"&gt;ProjectDirs&lt;/span&gt;::&lt;span class="n"&gt;from&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="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="n"&gt;tool_name&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="n"&gt;map_or_else&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;::&lt;span class="n"&gt;env&lt;/span&gt;::&lt;span class="n"&gt;temp_dir&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="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_dir&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;to_path_buf&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="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;update&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;version&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;Read that with two parallel test processes in mind. They&amp;rsquo;re testing the &lt;em&gt;same&lt;/em&gt; tool, against the &lt;em&gt;same&lt;/em&gt; fake release tag. So &lt;code&gt;tool_name&lt;/code&gt; matches and &lt;code&gt;version&lt;/code&gt; matches, which means &lt;code&gt;cache_dir_for&lt;/code&gt; hands both of them the &lt;em&gt;identical path&lt;/em&gt;. Two processes, isolated in every way that involves memory, both downloading and extracting into one shared directory on disk, at the same time. One writes the archive while the other is partway through reading it, and you get exactly the corrupt half-written file the test kept tripping over.&lt;/p&gt;
&lt;p&gt;Process isolation did nothing here, because the contention was never in memory. It was on a path string that came out the same for both of them.&lt;/p&gt;
&lt;h2 id="the-fix-is-to-stop-sharing-the-path"&gt;The fix is to stop sharing the path
&lt;/h2&gt;&lt;p&gt;Once it&amp;rsquo;s framed as &amp;ldquo;they share a path&amp;rdquo;, the fix writes itself: don&amp;rsquo;t share the path. Give each invocation its own cache directory. The updater builder already had the seam for it, and the doc comment now says exactly why it&amp;rsquo;s there, in &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-update/src/updater.rs#L396" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-update/src/updater.rs&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-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// Tools call this when they want isolation per-invocation
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// (e.g. CI runners, tests with parallel processes) or to honour
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// a user-supplied `--cache-dir` flag.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;cache_dir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cache_dir&lt;/span&gt;: &lt;span class="nc"&gt;impl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Into&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PathBuf&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="nc"&gt;Self&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="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_dir&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="nb"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cache_dir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;into&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="bp"&gt;self&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;Each test now builds its updater with &lt;code&gt;cache_dir(its_own_tempdir)&lt;/code&gt;, so two parallel processes land on two different directories and never meet. No lock, no serialisation, no clever cross-process file mutex. Just the realisation that the shared thing was a directory, and the cure for shared mutable state is usually to stop sharing it, not to guard it.&lt;/p&gt;
&lt;h2 id="the-fix-that-turned-out-to-be-a-feature"&gt;The fix that turned out to be a feature
&lt;/h2&gt;&lt;p&gt;The part I&amp;rsquo;m quietly pleased about is that this didn&amp;rsquo;t stay a test-only hack. The override I needed to isolate the tests is exactly the override a real tool wants for its own reasons. A CI runner doing self-update wants a writable cache path it controls, not wherever &lt;code&gt;directories-rs&lt;/code&gt; decides the system cache lives. A user might reasonably want to point the whole thing somewhere specific. That&amp;rsquo;s a &lt;code&gt;--cache-dir&lt;/code&gt; flag, and &lt;code&gt;cache_dir()&lt;/code&gt; is precisely the hook you&amp;rsquo;d wire it to.&lt;/p&gt;
&lt;p&gt;So the thing I added to stop a flaky test is the same thing a downstream tool reaches for to expose &lt;code&gt;--cache-dir&lt;/code&gt;. The test forced the seam to exist, and the seam was worth having anyway. I&amp;rsquo;ll take that trade every time over a fix that only the test suite benefits from.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;d treated &amp;ldquo;separate processes&amp;rdquo; as a synonym for &amp;ldquo;can&amp;rsquo;t race&amp;rdquo;, and it isn&amp;rsquo;t. Processes don&amp;rsquo;t share memory, so the memory races are gone. They absolutely still share the filesystem, the network, every named resource the OS will hand to anyone who asks for it by the same name. My two test processes computed the same cache path from the same tool and tag, and raced on the files in it, and no amount of address-space isolation was ever going to touch that.&lt;/p&gt;
&lt;p&gt;Shared mutable state on disk is still shared mutable state. The fix wasn&amp;rsquo;t a bigger hammer, it was giving each process its own directory and letting the isolation I thought I already had actually be true.&lt;/p&gt;</description></item><item><title>Verifying your own downloads: how I solved it for self-updating CLI tools</title><link>https://phpboyscout.uk/verifying-your-own-downloads/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/verifying-your-own-downloads/</guid><description>&lt;img src="https://phpboyscout.uk/verifying-your-own-downloads/cover-verifying-your-own-downloads.png" alt="Featured image of post Verifying your own downloads: how I solved it for self-updating CLI tools" /&gt;&lt;p&gt;Way back in the &lt;a class="link" href="https://phpboyscout.uk/introducing-go-tool-base/" &gt;introduction&lt;/a&gt; I promised I&amp;rsquo;d come back to the self-update integrity checks. Here we are. And the starting point is a slightly uncomfortable admission: for a good long while, go-tool-base&amp;rsquo;s &lt;code&gt;update&lt;/code&gt; command was the most trusting line of code in the entire tool.&lt;/p&gt;
&lt;h2 id="the-most-trusting-line-of-code-in-the-tool"&gt;The most trusting line of code in the tool
&lt;/h2&gt;&lt;p&gt;Self-update is a lovely feature. The user runs &lt;code&gt;yourtool update&lt;/code&gt;, the tool fetches the latest release, swaps itself out, and they&amp;rsquo;re current. go-tool-base has had this since early on, wired to GitHub, GitLab, Bitbucket, Gitea and a few others.&lt;/p&gt;
&lt;p&gt;But look closely at what that feature actually does. It reaches out to the internet, pulls down a file, and then &lt;em&gt;replaces the executable that&amp;rsquo;s currently running with that file&lt;/em&gt;. The next time the user invokes the tool, they&amp;rsquo;re running whatever those bytes turned out to be.&lt;/p&gt;
&lt;p&gt;The original implementation downloaded the release asset over HTTPS and extracted it. HTTPS gets you transport security: the bytes weren&amp;rsquo;t tampered with &lt;em&gt;in flight&lt;/em&gt;. It tells you nothing about whether the bytes were right when they left, or whether they&amp;rsquo;re even the bytes you meant to fetch. A truncated download, a CDN cache serving a mangled object, a release asset that got swapped after the fact&amp;hellip; HTTPS waves all of those straight through. For the one operation in the whole tool that replaces the binary, &amp;ldquo;we didn&amp;rsquo;t check&amp;rdquo; is an uncomfortable place to be sitting.&lt;/p&gt;
&lt;h2 id="goreleaser-already-does-half-the-job"&gt;GoReleaser already does half the job
&lt;/h2&gt;&lt;p&gt;The good news is that the build side was already producing exactly what I needed. GoReleaser, which builds go-tool-base&amp;rsquo;s releases, generates a &lt;code&gt;checksums.txt&lt;/code&gt; for every release: one SHA-256 per published artefact, the same format &lt;code&gt;sha256sum&lt;/code&gt; emits. It was sitting right there as a release asset and nothing was reading it.&lt;/p&gt;
&lt;p&gt;So Phase 1 of the integrity work is exactly that: read it.&lt;/p&gt;
&lt;p&gt;When &lt;code&gt;update&lt;/code&gt; downloads the platform binary, it now also fetches &lt;code&gt;checksums.txt&lt;/code&gt; from the same release, looks up the entry for the asset it just pulled, and compares the SHA-256 of the downloaded bytes against the expected hash before anything gets extracted or installed. Mismatch, and the update aborts before it has so much as touched the installed binary. The hash comparison &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/setup/checksum.go#L267" target="_blank" rel="noopener"
 &gt;runs in constant time&lt;/a&gt;, which is more defence-in-depth than strictly necessary here, but it costs nothing and means every hash comparison in the codebase is the same and reassuringly audit-boring.&lt;/p&gt;
&lt;h2 id="fail-open-or-fail-closed"&gt;Fail open, or fail closed?
&lt;/h2&gt;&lt;p&gt;The interesting design question wasn&amp;rsquo;t the hashing. It was: what do you do when there &lt;em&gt;is no&lt;/em&gt; &lt;code&gt;checksums.txt&lt;/code&gt;?&lt;/p&gt;
&lt;p&gt;Plenty of older releases predate this feature. A release might have been cut by hand without GoReleaser. If go-tool-base flatly refused to update whenever a manifest was missing, the very act of shipping this feature would brick the update path for every existing tool the moment they upgraded into it. That&amp;rsquo;s a cure worse than the disease.&lt;/p&gt;
&lt;p&gt;So the default is fail-open: no manifest, log a clear warning, proceed. It matches how the existing offline-update path already behaved with its optional &lt;code&gt;.sha256&lt;/code&gt; sidecar, and it keeps upgrades working.&lt;/p&gt;
&lt;p&gt;Fail-open as a &lt;em&gt;default&lt;/em&gt; is not the same as fail-open being &lt;em&gt;right for everyone&lt;/em&gt;, though. A security-sensitive tool should be able to say &amp;ldquo;no manifest, no update, full stop&amp;rdquo;. Two ways to get there:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tool authors&lt;/strong&gt; flip a compile-time switch (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/setup/checksum.go#L42" target="_blank" rel="noopener"
 &gt;&lt;code&gt;setup.DefaultRequireChecksum = true&lt;/code&gt;&lt;/a&gt; in &lt;code&gt;main()&lt;/code&gt;) and their binary ships fail-closed from day one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;End users&lt;/strong&gt; override either way through config (&lt;code&gt;update.require_checksum&lt;/code&gt;) or an environment variable.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;go-tool-base itself ships with the strict setting turned on, because a tool whose entire job is being a careful framework should hold itself to the stricter bar.&lt;/p&gt;
&lt;h2 id="the-caveat"&gt;The caveat
&lt;/h2&gt;&lt;p&gt;Security features oversell themselves constantly, so here is the limit, stated plainly.&lt;/p&gt;
&lt;p&gt;A checksum hosted &lt;em&gt;next to&lt;/em&gt; the binary it describes protects you from accidents. Corruption, truncation, a CDN serving stale junk, a release asset that got partially clobbered. It does not protect you from a determined attacker who&amp;rsquo;s compromised the release platform itself. If someone can replace the binary, they can replace &lt;code&gt;checksums.txt&lt;/code&gt; in the same breath, and your tool will cheerfully verify a malicious download against a malicious manifest and pronounce it good.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s not a flaw in the implementation. It&amp;rsquo;s the inherent ceiling of &lt;em&gt;same-origin&lt;/em&gt; integrity: the manifest and the artefact share a trust root, so they fall together. Closing that gap needs a signature whose trust root is somewhere the release platform can&amp;rsquo;t reach, a key the attacker doesn&amp;rsquo;t have. That&amp;rsquo;s the next phase of this work, and it&amp;rsquo;s a bigger piece: &lt;a class="link" href="https://phpboyscout.uk/a-signing-key-needs-somewhere-to-live/" &gt;GPG-signing the manifest&lt;/a&gt;, with the public half both embedded in the binary and published independently so a single platform compromise isn&amp;rsquo;t enough.&lt;/p&gt;
&lt;p&gt;Phase 1 is the floor, not the ceiling. But it&amp;rsquo;s a floor worth having, because the overwhelming majority of real-world &amp;ldquo;the download was wrong&amp;rdquo; incidents are accidents, not attacks, and accidents are exactly what a same-origin checksum catches.&lt;/p&gt;
&lt;h2 id="pulling-it-together"&gt;Pulling it together
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;update&lt;/code&gt; command is the most trusting code in a self-updating tool: it fetches bytes from the internet and then becomes them. go-tool-base now verifies the SHA-256 of every self-update download against the release&amp;rsquo;s own &lt;code&gt;checksums.txt&lt;/code&gt; before installing. It fails open by default so shipping the feature doesn&amp;rsquo;t strand anyone on an un-updatable version, fails closed for tool authors who ask (go-tool-base itself does), and stays honest that a same-origin checksum stops accidents, not a platform compromise.&lt;/p&gt;
&lt;p&gt;Verifying your own downloads is a low bar. The point is that the previous height of that bar was zero.&lt;/p&gt;</description></item></channel></rss>