<?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://blog-570662.gitlab.io/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, 26 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://blog-570662.gitlab.io/tags/self-update/index.xml" rel="self" type="application/rss+xml"/><item><title>A signing key needs somewhere to live</title><link>https://blog-570662.gitlab.io/a-signing-key-needs-somewhere-to-live/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/a-signing-key-needs-somewhere-to-live/</guid><description>&lt;img src="https://blog-570662.gitlab.io/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://blog-570662.gitlab.io/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>Verifying your own downloads: how I solved it for self-updating CLI tools</title><link>https://blog-570662.gitlab.io/verifying-your-own-downloads/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/verifying-your-own-downloads/</guid><description>&lt;img src="https://blog-570662.gitlab.io/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://blog-570662.gitlab.io/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 honest 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 runs in constant time, 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;code&gt;setup.DefaultRequireChecksum = true&lt;/code&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-honest-caveat"&gt;The honest caveat
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the part I want to be straight about, because security features oversell themselves constantly.&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://blog-570662.gitlab.io/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>