<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Security on PHP Boy Scout</title><link>https://phpboyscout.uk/categories/security/</link><description>Recent content in Security on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Sat, 04 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/categories/security/index.xml" rel="self" type="application/rss+xml"/><item><title>The secret that wasn't on my branch</title><link>https://phpboyscout.uk/the-secret-that-wasnt-on-my-branch/</link><pubDate>Sat, 04 Jul 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-secret-that-wasnt-on-my-branch/</guid><description>&lt;img src="https://phpboyscout.uk/the-secret-that-wasnt-on-my-branch/cover-the-secret-that-wasnt-on-my-branch.png" alt="Featured image of post The secret that wasn't on my branch" /&gt;&lt;p&gt;My CI went red on a secret I&amp;rsquo;d never committed.&lt;/p&gt;
&lt;p&gt;Not a close call, not a near-miss I&amp;rsquo;d half-forgotten about. gitleaks, the secret scanner, failed a merge request of mine on a private key that was not on my branch, was not in my change, and as far as I could tell had nothing to do with me at all. The job was adamant. I was baffled. Somewhere in between was a lesson about what a secret scanner actually scans.&lt;/p&gt;
&lt;h2 id="prove-it-isnt-yours"&gt;Prove it isn&amp;rsquo;t yours
&lt;/h2&gt;&lt;p&gt;First rule of being accused: don&amp;rsquo;t get defensive, get evidence. The scanner pointed at a couple of commits carrying a test private key and a PEM block in a spec document. I genuinely didn&amp;rsquo;t recognise them, but &amp;ldquo;I don&amp;rsquo;t recognise it&amp;rdquo; is a feeling, not a fact, and feelings don&amp;rsquo;t reopen a pipeline.&lt;/p&gt;
&lt;p&gt;Git will tell you the truth if you ask it precisely. The question is: are these flagged commits actually part of my branch?&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git merge-base --is-ancestor &amp;lt;flagged-sha&amp;gt; HEAD
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That asks &amp;ldquo;is this commit an ancestor of where I am?&amp;rdquo;. The answer came back no. The commits the scanner was choking on were not in my history. They weren&amp;rsquo;t mine.&lt;/p&gt;
&lt;p&gt;So whose were they? A bit of digging turned them up on a completely separate, still-unmerged branch, where someone (me, a few days earlier, on a different feature) had committed a throwaway test key and a PEM example in a spec, on purpose, as fixtures. Deliberate, harmless, and nowhere near the branch under review. And yet here they were, failing a merge request that had never touched them.&lt;/p&gt;
&lt;h2 id="what-gitleaks-actually-scans"&gt;What gitleaks actually scans
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the bit I&amp;rsquo;d taken for granted. I assumed &lt;code&gt;gitleaks detect&lt;/code&gt; scanned &lt;em&gt;my change&lt;/em&gt;. It doesn&amp;rsquo;t. With no further instruction it scans the whole history reachable in the checkout it&amp;rsquo;s handed.&lt;/p&gt;
&lt;p&gt;And the checkout it&amp;rsquo;s handed is where the second half of the surprise lives. GitLab runners default to &lt;code&gt;GIT_STRATEGY: fetch&lt;/code&gt;, which reuses the runner&amp;rsquo;s working directory between jobs rather than cloning fresh every time. It&amp;rsquo;s faster, and most of the time you never notice. But it means a shared runner accumulates refs from every branch it has ever built. My MR&amp;rsquo;s job happened to run on a runner that had, at some point, built that other branch, so the fixtures were sitting right there in the local object store, fair game for a scanner walking the whole graph.&lt;/p&gt;
&lt;p&gt;So gitleaks did exactly what I&amp;rsquo;d asked it to do, which was &amp;ldquo;scan everything&amp;rdquo;, and &amp;ldquo;everything&amp;rdquo; turned out to be a great deal more than my change. It walked the lot and dutifully reported fixtures from a branch I wasn&amp;rsquo;t even proposing to merge. The scanner wasn&amp;rsquo;t wrong. My idea of what it was looking at was.&lt;/p&gt;
&lt;h2 id="unblock-now-fix-properly-after"&gt;Unblock now, fix properly after
&lt;/h2&gt;&lt;p&gt;Two problems on two timescales. I needed the MR to merge today, and I needed this to never happen again. Those want different fixes.&lt;/p&gt;
&lt;p&gt;The immediate one: tell gitleaks those specific fixtures are known and intended. They&amp;rsquo;re test material, they&amp;rsquo;re meant to be there, so they go in the &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/75f87c5/.gitleaks.toml#L27-L28" target="_blank" rel="noopener"
 &gt;allowlist&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;paths&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c"&gt;# ...&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&amp;#39;internal/cmd/keys/keys_test\.go&amp;#39;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&amp;#39;docs/development/specs/2026-06-08-keys-mint-command\.md&amp;#39;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That unblocks the merge. It does nothing about the root cause, which is that the scan was looking at the wrong commits in the first place. Allowlisting individual false positives as they crop up is closing the stable door after the horse has bolted, one horse at a time, forever.&lt;/p&gt;
&lt;p&gt;The real fix lives in the shared CI component, not in any one repo. Scope the scan to the commits the merge request actually introduces (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/blob/52b5482/templates/go-security.yml#L105-L125" target="_blank" rel="noopener"
 &gt;cicd v0.10.3&lt;/a&gt;):&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="l"&gt;if [ -n &amp;#34;$CI_MERGE_REQUEST_DIFF_BASE_SHA&amp;#34; ]; then&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;gitleaks detect --source . --verbose --redact \&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;--&lt;span class="l"&gt;log-opts=&amp;#34;$CI_MERGE_REQUEST_DIFF_BASE_SHA..$CI_COMMIT_SHA&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="l"&gt;else&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;gitleaks detect --source . --verbose --redact&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="l"&gt;fi&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;--log-opts&lt;/code&gt; is handed straight through to &lt;code&gt;git log&lt;/code&gt;, so that range, base-of-the-MR to tip, is the exact set of commits the merge request adds and nothing else. On a merge request the scan now sees only what you&amp;rsquo;re proposing to merge. Off a merge request (a plain branch or a tag pipeline) it falls back to the full scan, because there you genuinely do want the lot. The before-and-after in the job log tells the whole story: the entire accumulated history on one side, the handful of commits you actually wrote on the other.&lt;/p&gt;
&lt;h2 id="fixing-the-fix"&gt;Fixing the fix
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a tax on touching CI shell, and I paid it. The change went into the go, rust and tofu security templates. Go and tofu went green. Rust failed.&lt;/p&gt;
&lt;p&gt;The &lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/blob/52b5482/templates/rust-security.yml#L118-L145" target="_blank" rel="noopener"
 &gt;rust template&lt;/a&gt; had built its optional &lt;code&gt;--config&lt;/code&gt; flag the clever way, inline:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;... &lt;span class="k"&gt;$(&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt; -n &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$CONFIG&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;--config &lt;/span&gt;&lt;span class="nv"&gt;$CONFIG&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;As a command &lt;em&gt;argument&lt;/em&gt;, that substitution is harmless: its exit status is thrown away, so nobody cares that the test inside returns false when there&amp;rsquo;s no config. But when I rewrote the block and reached for the same pattern as an &lt;em&gt;assignment&lt;/em&gt;, &lt;code&gt;VAR=$(... &amp;amp;&amp;amp; ...)&lt;/code&gt;, it became a different animal. An assignment takes the exit status of the command substitution, and under &lt;code&gt;set -e&lt;/code&gt; a non-zero status anywhere aborts the job. So on every run where the config was empty, which was most of them, the test returned false, the assignment inherited that false, and &lt;code&gt;set -e&lt;/code&gt; killed the job stone dead. Same &lt;code&gt;$(...)&lt;/code&gt;, two completely different fates, decided entirely by whether it sat to the right of an &lt;code&gt;=&lt;/code&gt; or got handed to a command as an argument. Go and tofu never used the assignment form, so only rust fell down the hole.&lt;/p&gt;
&lt;p&gt;The fix was to stop being clever and write the boring &lt;code&gt;if&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;GITLEAKS_CONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; -n &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$CONFIG&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;GITLEAKS_CONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;--config &lt;/span&gt;&lt;span class="nv"&gt;$CONFIG&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Boring shell is good shell.&lt;/p&gt;
&lt;h2 id="what-a-scanner-is-actually-looking-at"&gt;What a scanner is actually looking at
&lt;/h2&gt;&lt;p&gt;The whole mess came from one unspoken assumption: that a tool called to scan &amp;ldquo;my change&amp;rdquo; was scanning my change. It was scanning a checkout, and a checkout on a shared runner is a much bigger, messier thing than the diff in front of you. None of the pieces were broken. gitleaks did its job, &lt;code&gt;GIT_STRATEGY: fetch&lt;/code&gt; did its job, my fixtures were exactly where I&amp;rsquo;d left them. They just added up to a red pipeline that had nothing to do with the code I was trying to ship. I&amp;rsquo;d spent a good chunk of the day proving my innocence to a scanner that was only ever doing as it was told&amp;hellip; and the one thing I&amp;rsquo;d actually got wrong was being sure I already knew what it was looking at.&lt;/p&gt;</description></item><item><title>Bought, not stolen</title><link>https://phpboyscout.uk/bought-not-stolen/</link><pubDate>Tue, 23 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/bought-not-stolen/</guid><description>&lt;img src="https://phpboyscout.uk/bought-not-stolen/cover-bought-not-stolen.png" alt="Featured image of post Bought, not stolen" /&gt;&lt;p&gt;The malware that spent months wearing Microsoft&amp;rsquo;s trust didn&amp;rsquo;t steal a thing. No cracked certificate authority, no private key lifted off some breached vendor. There was, in effect, a shop: you uploaded your malware to a website, paid somewhere between five and nine thousand dollars, and got it back signed with a real, valid certificate. The same kind that vouches for the software you actually want on your machine.&lt;/p&gt;
&lt;p&gt;The crew running that shop is one Microsoft&amp;rsquo;s Digital Crimes Unit tracks as Fox Tempest, and the certificates weren&amp;rsquo;t forged. They were minted through Microsoft&amp;rsquo;s own code-signing service, which Fox Tempest abused under a pile of fake identities and impersonated companies, then resold to anyone with the money. The malware they dressed up was the usual rogues&amp;rsquo; gallery, the Oyster backdoor, the Lumma and Vidar infostealers, often got up as spoofed Teams or AnyDesk installers. In May, Microsoft pulled the operation apart: more than a thousand fraudulent certificates revoked, the infrastructure seized, a lawsuit filed against Fox Tempest and the ransomware crew behind Rhysida that had been paying for the service. For the better part of a year, a valid Microsoft signature was a product you could check out of a basket.&lt;/p&gt;
&lt;p&gt;The reflex is to file this under &amp;ldquo;another supply-chain breach&amp;rdquo;. It isn&amp;rsquo;t one. Nothing was breached. The system did exactly what it was built to do, for a paying customer who&amp;rsquo;d lied about who they were. That&amp;rsquo;s the part worth sitting with.&lt;/p&gt;
&lt;h2 id="we-have-been-here-before"&gt;We have been here before
&lt;/h2&gt;&lt;p&gt;None of this is new. I went back and read a Trend Micro write-up on code-signing abuse from 2018, and it could have gone out last week. Stuxnet carried valid signatures from stolen Realtek and JMicron certificates. After Sony Pictures was ransacked in 2014, the attackers signed their Destover malware with Sony&amp;rsquo;s own keys. The names rotate and the methods rotate, the story doesn&amp;rsquo;t: there is a trust mark, there is money in wearing it, so somebody works out how to wear it.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve started thinking of them as honest villains. Not honest with their victims, obviously. Honest in their incentives: a rational operator who finds a loophole and works it for everything it&amp;rsquo;s worth, because there&amp;rsquo;s profit on the other side of it. You can be as furious at Fox Tempest as you like, and I am, prosecution is exactly right, but the honest villain is never the surprise. The honest villain is a constant. Build a gate worth getting through and one will turn up to test it&amp;hellip; every single time! That isn&amp;rsquo;t cynicism. It might be the most dependable law we&amp;rsquo;ve got.&lt;/p&gt;
&lt;h2 id="a-seal-was-never-a-promise"&gt;A seal was never a promise
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the thing we keep forgetting about a signature, and it goes back a very long way.&lt;/p&gt;
&lt;p&gt;For most of human history, trust was blood. You trusted the people you were bound to, by birth and by the pacts of kinship and marriage that held a tribe together. It worked, for tens of thousands of years, because the circle was small enough that everyone you needed to trust was someone you knew, or someone known to someone you knew. The bond and the accountability were the same thing. Betray the tribe and the tribe knew precisely whose door to come to.&lt;/p&gt;
&lt;p&gt;Then we outgrew the tribe. We started trading with strangers, across distances and across lifetimes, with people we would never meet and could never vouch for by blood. And blood stopped being enough. So we built stand-ins for it. The wax seal pressed into a letter. The signature at the foot of a contract. The notary, the stamp, the certificate. Each one a small portable proxy for the bond of kinship we&amp;rsquo;d walked away from.&lt;/p&gt;
&lt;p&gt;But look at what a seal actually did. A seal on a letter never told you the letter was true. It told you whose seal it was, which is to say it told you who to hold responsible if the letter turned out to be a lie. It was never a guarantee of honesty. It was a marker of accountability. It pointed at a person.&lt;/p&gt;
&lt;p&gt;A code signature is the newest seal in that very old line, and it does the same single job. It does not tell you the software is safe. It was never built to. It tells you who signed it, which is to say who to come to when it isn&amp;rsquo;t. That&amp;rsquo;s the whole of it. The padlock in the browser, the green tick on the installer, the verified signature on a binary, none of them ever meant &amp;ldquo;this is good&amp;rdquo;. They meant &amp;ldquo;here is a name attached to this&amp;rdquo;.&lt;/p&gt;
&lt;h2 id="the-part-you-cannot-sell"&gt;The part you cannot sell
&lt;/h2&gt;&lt;p&gt;Which brings me to the uncomfortable bit, the one that points back at me as much as at Redmond.&lt;/p&gt;
&lt;p&gt;If a signature is a name accepting responsibility, then the power to sign and the blame for what you sign are the same object. You don&amp;rsquo;t get one without the other. You cannot hold out the seal and quietly keep back the accountability behind it, because the accountability is the only thing the seal was ever made of.&lt;/p&gt;
&lt;p&gt;That is exactly what came apart here. Microsoft holds enormous power as a signing authority, the power to make code look trustworthy to millions of machines. With that power comes the plain duty to check who you&amp;rsquo;re handing it to. They took the first part and skimped on the second. They sold the seal and skipped the diligence the seal is supposed to stand for, and the honest villain simply walked through the gap between the two.&lt;/p&gt;
&lt;p&gt;And I have to hold myself to that very same rule, or I&amp;rsquo;ve no business naming it. I sign my own releases. Every go-tool-base release carries an OpenPGP signature over its checksums, made by &lt;a class="link" href="https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/" &gt;a key that never leaves AWS KMS&lt;/a&gt;, with the public key &lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;published off-platform&lt;/a&gt; so the release host can&amp;rsquo;t quietly swap it. I do it so the people who use my tools, and they&amp;rsquo;re a varied bunch, can trust that what they&amp;rsquo;re running genuinely came from me.&lt;/p&gt;
&lt;p&gt;But trusting me was never really the question. Here&amp;rsquo;s the one that keeps me up: what happens the day I&amp;rsquo;m the weak point? If a contributor slips something rotten into my code, or one of my own AI agents writes something it shouldn&amp;rsquo;t, and my automation dutifully signs it, then the signature does its job perfectly and the whole house of cards comes down. My users can trust me all they like. The seal will still say &amp;ldquo;Matt&amp;rdquo;, and it will be telling the truth, and that is precisely the problem.&lt;/p&gt;
&lt;p&gt;So the accountability can&amp;rsquo;t be delegated, and I don&amp;rsquo;t try to. Nothing reaches my releasable branches without my own eyes on it first. No merge request, no commit, not from a contributor and not from one of my agents. The vigilance is mine, singularly, and that&amp;rsquo;s deliberate, because the blame is mine too, singularly, and they&amp;rsquo;re the same coin. It&amp;rsquo;s an easier thing to say as a one-man outfit than it&amp;rsquo;ll be if that ever changes. But the principle doesn&amp;rsquo;t get cheaper at scale. It just gets harder to honour, which is a very different thing from optional.&lt;/p&gt;
&lt;h2 id="what-standing-behind-it-looks-like"&gt;What standing behind it looks like
&lt;/h2&gt;&lt;p&gt;If you want to see the duty done properly, look at how OpenAI handled the Axios incident earlier this year. A poisoned dependency had got at the material used to sign their macOS app. They had every reason to believe their certificates were fine. They revoked them anyway, and rebuilt, because the cost of being wrong was their users&amp;rsquo; machines and their own name on the door. That&amp;rsquo;s what holding the power looks like. You act on the possibility of compromise, not the proof of it, because by the time you&amp;rsquo;ve got proof it&amp;rsquo;s already on somebody&amp;rsquo;s laptop.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s also why I&amp;rsquo;ve come to treat trust as layers rather than a single tick in a box. The release signature is one layer. Notarisation is another: every macOS binary go-tool-base ships is notarised by Apple as well, and has been for a good while. There will be more, and the Rust side of my tooling has its own signing coming very soon. None of them is the answer on its own. Each one is just another check, another name standing behind the thing. And the part I care about most, as someone who builds tools other people build on, is handing that same machinery to them, so they can stand behind their own releases for their own customers instead of &lt;a class="link" href="https://phpboyscout.uk/nobody-is-coming-to-clean-your-supply-chain/" &gt;trusting that someone, somewhere up the chain, did the diligence&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="vigilance-still"&gt;Vigilance, still
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;d love to tell you there&amp;rsquo;s a clean technical fix on the way, a trust system the honest villain can&amp;rsquo;t game&amp;hellip; There isn&amp;rsquo;t. We&amp;rsquo;ve tried plenty, and some have aged better than others. (I&amp;rsquo;m looking at you, blockchain, the confident answer to a question almost nobody was asking!) Whatever we build next, the same loop runs: the mechanism gets more elaborate, so the attacks on it get more insidious, so the mechanism gets more elaborate again. We have spent the whole of human history moving trust further and further from the blood bond that used to ground it, and every step we take away from a person who will answer for it, the honest villain takes right alongside us.&lt;/p&gt;
&lt;p&gt;Maybe there&amp;rsquo;s a version of the future where this stops&amp;hellip; some distant place where nobody needs to cheat because nobody wants for anything, and greed has quietly retired. I&amp;rsquo;m not holding my breath. Until then the job is the plodding one it has always been: stay vigilant, act before you&amp;rsquo;re certain, and keep trust as close as you can to a person who&amp;rsquo;ll stand behind it. Because that is the only thing a seal ever was. Not a promise that the thing is good. A name, and someone willing to answer to it.&lt;/p&gt;</description></item><item><title>A signing key that never leaves KMS</title><link>https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/</link><pubDate>Thu, 11 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/</guid><description>&lt;img src="https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/cover-a-signing-key-that-never-leaves-kms.png" alt="Featured image of post A signing key that never leaves KMS" /&gt;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;The last post in this series&lt;/a&gt;
walked through how a tool &lt;em&gt;verifies&lt;/em&gt; a release signature the platform can&amp;rsquo;t forge.
That post had a loose end dangling off the back of it, and I knew it the whole time I
was writing. Because a signature has to be produced by a private key&amp;hellip; and a private
signing key is the single worst thing in this entire story to lose. Steal it, and you
sign malware that sails through every check I spent two posts building, signature and
all. So where does that key live? The answer I landed on is the one this whole post is
about: inside AWS KMS, and it never comes out.&lt;/p&gt;
&lt;h2 id="the-only-key-you-cant-steal"&gt;The only key you can&amp;rsquo;t steal
&lt;/h2&gt;&lt;p&gt;Think about where a signing key normally ends up. A file on a build server. A secret
in CI. A key on the release engineer&amp;rsquo;s laptop, &amp;ldquo;just for the release, I&amp;rsquo;ll delete it
after&amp;rdquo;. Every one of those is a copy, and every copy is one more thing somebody can
read, exfiltrate, or quietly clone while your back is turned. You can wrap them in
passphrases and vaults and rotation policies all you like, and you&amp;rsquo;re still standing
guard over a thing that &lt;em&gt;exists in a place you don&amp;rsquo;t fully control&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The way out is almost annoyingly simple to state: the only key nobody can steal is
the one that was never anywhere to be stolen from. So don&amp;rsquo;t hold the key at all. Let
something else hold it, somewhere it has no export path, and ask that thing to sign
&lt;em&gt;for&lt;/em&gt; you.&lt;/p&gt;
&lt;p&gt;That thing is AWS KMS. This is the infrastructure side of &lt;a class="link" href="https://phpboyscout.uk/a-signing-key-needs-somewhere-to-live/" &gt;the question I opened the
signing series with&lt;/a&gt;,
finally answered with real Terraform.&lt;/p&gt;
&lt;h2 id="a-key-thats-born-in-the-box-and-stays-there"&gt;A key that&amp;rsquo;s born in the box and stays there
&lt;/h2&gt;&lt;p&gt;The signing key is an asymmetric KMS key, and the
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-signing-kms/-/blob/v0.1.0/main.tf#L86-L100" target="_blank" rel="noopener"
 &gt;module that provisions it&lt;/a&gt;
is small enough to read in one sitting:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;aws_kms_key&amp;#34; &amp;#34;this&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;description&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; key_usage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;SIGN_VERIFY&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; customer_master_key_spec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;key_spec&lt;/span&gt;&lt;span class="c1"&gt; # default RSA_4096
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # Asymmetric SIGN_VERIFY keys do not support KMS-managed rotation;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # rotation is handled by minting a new key (alias = `&amp;lt;name&amp;gt;-v2`) and
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # publishing the v2 public key alongside the v1 key (dual-sign window).
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; enable_key_rotation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;false&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;aws_iam_policy_document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;key_policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;json&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The private half of that key is generated inside KMS and there is no API that hands
it back to you. You don&amp;rsquo;t sign &lt;em&gt;with&lt;/em&gt; it the way you&amp;rsquo;d sign with a file. You call
&lt;code&gt;kms:Sign&lt;/code&gt;: the bytes you want signed go up, a signature comes back down, and the key
itself never moves. An attacker who completely owns my CI, my account, my laptop, can
ask KMS to sign things for as long as their access lasts&amp;hellip; but they can&amp;rsquo;t walk off
with the key and keep signing forever. The blast radius is &amp;ldquo;while I&amp;rsquo;m compromised&amp;rdquo;,
not &amp;ldquo;until I rotate a key I didn&amp;rsquo;t know had leaked three years ago&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;Why RSA-4096 and not the Ed25519 I&amp;rsquo;d normally reach for? Because KMS asymmetric
signing doesn&amp;rsquo;t offer Ed25519, and OpenPGP&amp;rsquo;s packet format is tied to the algorithm
that signed it, so the choice of key spec ripples all the way out to the signature on
the wire. RSA-4096 is the strong option KMS does offer, so RSA-4096 is what the
workflow is built around. A constraint of the box shaped the cryptography, not the
other way round, and I&amp;rsquo;d rather say so than pretend I picked RSA on purpose.&lt;/p&gt;
&lt;h2 id="minting-an-openpgp-key-from-a-key-you-cant-hold"&gt;Minting an OpenPGP key from a key you can&amp;rsquo;t hold
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the part I find genuinely neat. OpenPGP wants a private key to self-sign its
own public key when you generate it. And I don&amp;rsquo;t &lt;em&gt;have&lt;/em&gt; a private key in any form I
can hand to a library&amp;hellip; it&amp;rsquo;s sitting in KMS, behind a door with no handle on my side.
So how do you produce a valid OpenPGP public key at all?&lt;/p&gt;
&lt;p&gt;go-tool-base leans on a small Go interface, &lt;code&gt;crypto.Signer&lt;/code&gt;: anything that can return
its public key and sign a digest. A KMS-backed signer satisfies it by turning each
&lt;code&gt;Sign&lt;/code&gt; call into a &lt;code&gt;kms:Sign&lt;/code&gt; request. Then
&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/v0.12.2/pkg/openpgpkey/openpgpkey.go#L114-L126" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/openpgpkey&lt;/code&gt;&lt;/a&gt;
builds the OpenPGP entity around that signer:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Signer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;creationTime&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;openpgp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;rsaPub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Public&lt;/span&gt;&lt;span class="p"&gt;().(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;rsa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PublicKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="c1"&gt;// ...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;pubPkt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;packet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewRSAPublicKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;creationTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;rsaPub&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="c1"&gt;// Construct the private-key packet directly (rather than&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="c1"&gt;// packet.NewSignerPrivateKey, which panics on opaque signers):&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="c1"&gt;// the crypto.Signer drives the actual signing, so a KMS-backed&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="c1"&gt;// signer works here.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;privPkt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;packet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PrivateKey&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;PublicKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;pubPkt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;PrivateKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="c1"&gt;// ...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Look at that &lt;code&gt;PrivateKey&lt;/code&gt; packet. The field where OpenPGP expects the secret key
material holds the &lt;code&gt;crypto.Signer&lt;/code&gt; instead, which is to say, a remote handle to KMS.
When the entity self-signs its public key, that self-signature is computed by KMS.
&lt;code&gt;gtb keys mint&lt;/code&gt; runs exactly this and writes out an ASCII-armored OpenPGP public key,
and at no point did a single byte of private key material exist on the machine that
minted it. The OpenPGP &amp;ldquo;private key&amp;rdquo; is a phone line to a vault, not a key.&lt;/p&gt;
&lt;p&gt;That public key is what gets published off-platform over WKD and baked into the
binary, the two trust anchors that post cross-checks.&lt;/p&gt;
&lt;h2 id="access-without-a-human-and-without-a-standing-key"&gt;Access without a human and without a standing key
&lt;/h2&gt;&lt;p&gt;A key that never leaves KMS is only as good as the rules about who may call
&lt;code&gt;kms:Sign&lt;/code&gt;. The
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-signing-kms/-/blob/v0.1.0/main.tf#L49-L82" target="_blank" rel="noopener"
 &gt;signer role&lt;/a&gt;
is deliberately narrow: it can call &lt;code&gt;kms:Sign&lt;/code&gt; and &lt;code&gt;kms:GetPublicKey&lt;/code&gt; on this one
key and nothing else, and it is assumable only over OIDC from specific CI subjects,
the same &lt;a class="link" href="https://phpboyscout.uk/no-access-keys-in-ci/" &gt;keyless federation&lt;/a&gt;
the rest of the estate runs on. No human holds it. No long-lived access key sits in
a CI variable waiting to leak. A release job federates in for its few minutes,
signs, and the credentials evaporate with the runner.&lt;/p&gt;
&lt;p&gt;So the chain of &amp;ldquo;who can sign a release&amp;rdquo; has no standing secret in it anywhere. Not a
key file, not an access key, not a console user. Just a short-lived token, scoped to
two API calls, on a key that can&amp;rsquo;t be exported.&lt;/p&gt;
&lt;h2 id="the-real-cost-rotation-is-manual"&gt;The real cost: rotation is manual
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t free, and the bit it taxes you on is rotation. KMS won&amp;rsquo;t auto-rotate an
asymmetric &lt;code&gt;SIGN_VERIFY&lt;/code&gt; key, which is why the module sets &lt;code&gt;enable_key_rotation = false&lt;/code&gt; rather than leaving a default on. Rotating means minting a &lt;em&gt;new&lt;/em&gt; key (a &lt;code&gt;-v2&lt;/code&gt;
alias), publishing its public key alongside the old one, and running a dual-sign
window long enough that clients have picked up the new anchor before you retire the
old. It&amp;rsquo;s manual, it&amp;rsquo;s a runbook, and pretending otherwise would be the kind of thing
this series exists to argue against. The trade I made was: a key with no exfiltration
path, in exchange for rotation I have to do by hand. For a release-signing key, that&amp;rsquo;s
the right side of the trade.&lt;/p&gt;
&lt;h2 id="why-this-is-a-command-and-not-a-script-i-hid"&gt;Why this is a command and not a script I hid
&lt;/h2&gt;&lt;p&gt;The origin of all this is a good deal less tidy than the result. I was working through
the key-generation runbook, creating the offline rotation key with a &lt;code&gt;gpg&lt;/code&gt; command I&amp;rsquo;d
copied straight off my own page&amp;hellip; and it just hung. No error, no prompt, just a cursor
blinking while gpg waited on something it never bothered to mention.&lt;/p&gt;
&lt;p&gt;My first instinct was the lazy one: drop the minting script into a &lt;code&gt;scripts&lt;/code&gt; folder in
my infra repo and never speak of it again. Then it nagged. That repo&amp;rsquo;s private, so the
recipe would live somewhere nobody else could ever reach, and I&amp;rsquo;d already half-promised
myself a tutorial walking people through this exact setup. So it shouldn&amp;rsquo;t sit in infra
at all. It should be a &lt;code&gt;gtb&lt;/code&gt; command, with a pluggable backend so anyone can swap my KMS
for whatever provider they happen to run.&lt;/p&gt;
&lt;p&gt;The deeper objection is the one that actually shaped it, though. I didn&amp;rsquo;t want to be
shelling out to &lt;code&gt;gpg&lt;/code&gt; by hand in the first place. gtb is a tool I hand to other people,
and every time it drops to the shell for some gpg incantation, that&amp;rsquo;s an environment
I&amp;rsquo;m asking the next person to reproduce, a dependency to install, a fiddly step to get
subtly wrong, all before they can sign a single thing. The aim was to keep as much of
this &lt;em&gt;inside the box&lt;/em&gt; as I could: mint the key, build the WKD tree, produce the
signature, all in pure Go, with no &lt;code&gt;gpg&lt;/code&gt; on the path and no &lt;code&gt;gpg-wks-client&lt;/code&gt; either.&lt;/p&gt;
&lt;p&gt;So &lt;code&gt;gtb keys mint&lt;/code&gt; pulls the public half out of your KMS key and frames it as OpenPGP,
the trick from earlier; &lt;code&gt;gtb keys wkd&lt;/code&gt; builds the tree ready to upload; and &lt;code&gt;gtb sign&lt;/code&gt;
produces the detached signature through that same remote round-trip. What comes out is
an entirely ordinary OpenPGP signature &lt;code&gt;gpg --verify&lt;/code&gt; is happy with, so you&amp;rsquo;re not
locked into anything of mine. And none of it is just for me: build your tool on
go-tool-base and the same handful of commands stands you up with this exact model,
pointed at your own KMS. No cloud KMS to hand? There&amp;rsquo;s a &lt;code&gt;local&lt;/code&gt; backend, a plain key
on disk, to wire the whole thing together on your laptop first. These are commands for
you, the person shipping the tool. Your users never run &lt;code&gt;mytool keys mint&lt;/code&gt;&amp;hellip; they just
get updates that quietly check themselves, which was the whole idea two posts ago.&lt;/p&gt;
&lt;p&gt;That setup deserves a walkthrough of its own, and it&amp;rsquo;ll get one. For now, the
ergonomics were the point, not a nicety bolted on afterwards. The safest setup in the
world is no use to anyone if it takes a PhD to stand up.&lt;/p&gt;
&lt;h2 id="where-this-leaves-the-whole-story"&gt;Where this leaves the whole story
&lt;/h2&gt;&lt;p&gt;Step back and the full loop is finally closed. The private key is born in KMS and
never leaves it. Its public key is minted &lt;em&gt;from&lt;/em&gt; it, with KMS computing its own
self-signature. That public key is published off-platform and embedded in the binary.
Releases are signed by KMS, reached only through short-lived OIDC federation. And the
client verifies against the embedded and WKD keys &lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;cross-checked against each
other&lt;/a&gt;. At no
single point in that chain is there a thing an attacker can grab that lets them forge
a release, and the most dangerous thing of all, the private key, has no theft path
because it has no export path.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the thread running through the whole signing series, from &lt;a class="link" href="https://phpboyscout.uk/verifying-your-own-downloads/" &gt;the very first
checksum&lt;/a&gt; to here: the
strongest control isn&amp;rsquo;t a better lock on the key. It&amp;rsquo;s arranging things so the key
was never somewhere you could lose it. &lt;a class="link" href="https://phpboyscout.uk/nobody-is-coming-to-clean-your-supply-chain/" &gt;Nobody is coming to clean your supply
chain&lt;/a&gt;,
so the least you can do is leave it nothing worth stealing.&lt;/p&gt;</description></item><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>Anything under an 8</title><link>https://phpboyscout.uk/anything-under-an-8/</link><pubDate>Mon, 08 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/anything-under-an-8/</guid><description>&lt;img src="https://phpboyscout.uk/anything-under-an-8/cover-anything-under-an-8.png" alt="Featured image of post Anything under an 8" /&gt;&lt;p&gt;I read the news about the National Vulnerability Database over a coffee that
went cold while I sat there muttering at my phone. The short version: the NVD,
the free public catalogue that quietly props up half the security tooling you
and I run every day, is going under in slow motion. And the more I dug into
&lt;em&gt;why&lt;/em&gt;, the worse the taste in my mouth got.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;m an open-source person. I think of myself as part of that community, and the
NVD is one of those public goods the whole community leans on without ever
really thinking about it. So my first reaction wasn&amp;rsquo;t clever or measured. It was
a kick in the teeth.&lt;/p&gt;
&lt;h2 id="the-carcass-and-the-vultures"&gt;The carcass and the vultures
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s where things actually are. In February 2024 the NVD had around 13,000
unprocessed vulnerabilities sitting in a queue waiting to be analysed. By the end
of 2025 that backlog had passed
&lt;a class="link" href="https://www.helpnetsecurity.com/2026/06/01/nist-nvd-management-problems/" target="_blank" rel="noopener"
 &gt;27,000&lt;/a&gt;.
This April, NIST effectively
&lt;a class="link" href="https://www.nist.gov/news-events/news/2026/04/nist-updates-nvd-operations-address-record-cve-growth" target="_blank" rel="noopener"
 &gt;admitted it can&amp;rsquo;t dig out&lt;/a&gt;:
everything published before 1 March 2026 that hadn&amp;rsquo;t been enriched got swept into
a bucket marked &amp;ldquo;Not Scheduled&amp;rdquo;, and going forward only the highest-risk entries
get the full treatment. The rest you&amp;rsquo;re on your own with.&lt;/p&gt;
&lt;p&gt;The reasons are grimly ordinary. The
&lt;a class="link" href="https://www.helpnetsecurity.com/2026/06/01/nist-nvd-management-problems/" target="_blank" rel="noopener"
 &gt;Cybersecurity and Infrastructure Security Agency stopped funding the
programme&lt;/a&gt;
in 2024. The enrichment contract lapsed that same February, and despite NIST
having two years&amp;rsquo; notice it needed a replacement, the database limped along
understaffed until late November. And the volume kept climbing regardless:
&lt;a class="link" href="https://jerrygamblin.com/2026/01/01/2025-cve-data-review/" target="_blank" rel="noopener"
 &gt;48,185 CVEs in 2025&lt;/a&gt;,
roughly 131 a day, with forecasts of the annual figure topping 60,000, getting on
for ten times what it was a decade ago. No money, a fumbled handover, and a
firehose. That&amp;rsquo;s the whole story.&lt;/p&gt;
&lt;p&gt;The bit that turns my stomach is what comes next. When a free public good fails,
the gap doesn&amp;rsquo;t stay empty. It gets filled, and it gets filled by people selling
something. There are already commercial vulnerability databases that are better
resourced and more current than the NVD, and the moment the free one is visibly
on the floor, every one of them sees a market. Plenty of those subscriptions cost
more in a year than a small open-source project will see in donations in its
lifetime. So the catalogue the little projects relied on most is exactly the one
about to be priced out of their reach. Vultures circling a carcass, and the
carcass is something we all built on.&lt;/p&gt;
&lt;h2 id="the-number-we-never-checked"&gt;The number we never checked
&lt;/h2&gt;&lt;p&gt;And then I read the part that stopped me blaming everyone else.&lt;/p&gt;
&lt;p&gt;A Department of Commerce Inspector General audit went through the NVD&amp;rsquo;s work and
found that NIST&amp;rsquo;s own severity scores
&lt;a class="link" href="https://therecord.media/nist-mistakes-vulnerability-database-inspector-general" target="_blank" rel="noopener"
 &gt;matched independent assessors only 12% of the
time&lt;/a&gt;.
Read that again. Not that NIST was wrong 88% of the time, that&amp;rsquo;s not quite what
it says, but that two competent parties looking at the same vulnerability landed
on the same severity barely one time in eight. The score was never an objective
fact handed down from on high. It was always an estimate, a judgement call, the
kind of thing reasonable people disagree about most of the time.&lt;/p&gt;
&lt;p&gt;Which is awkward, because I have spent years treating that number as gospel. And
I know I&amp;rsquo;m not alone, because I&amp;rsquo;ve watched whole engineering organisations do the
same thing in writing. More than one large employer I&amp;rsquo;ve had bakes the CVSS score
straight into policy: anything scored 8 or above blocks the build and gets a
meeting, and anything under an 8 goes through at an engineer&amp;rsquo;s discretion. When
time is money, and it always is in those places, &amp;ldquo;it&amp;rsquo;s only a 6.4, ship it&amp;rdquo; is the
easiest decision you&amp;rsquo;ll make all week. I&amp;rsquo;ve made it. I&amp;rsquo;ve made it without opening
the advisory, without checking whether the vulnerable code path was even reachable
in what we&amp;rsquo;d built, on the strength of a single number that, it turns out, two
experts wouldn&amp;rsquo;t have agreed on anyway.&lt;/p&gt;
&lt;p&gt;So before I get cross about the funding, I have to sit with my own part in this.
We took a contestable estimate and bolted it to the door as a gatekeeper. We
turned &amp;ldquo;a rough signal worth a closer look&amp;rdquo; into &amp;ldquo;the closer look&amp;rdquo;, and then we
stopped looking. The database didn&amp;rsquo;t promise us a safety net. We just decided it
was one and stopped checking underneath.&lt;/p&gt;
&lt;h2 id="dont-blame-the-robots-for-this-one"&gt;Don&amp;rsquo;t blame the robots for this one
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s an easy villain on offer here, and I want to wave you off it. It would be
tidy to say AI did this, that the flood drowning the NVD is a tide of
machine-generated slop, the same dynamic I wrote about when
&lt;a class="link" href="https://phpboyscout.uk/ai-didnt-kill-curls-bug-bounty/" &gt;curl&amp;rsquo;s bug bounty buckled under unverifiable
reports&lt;/a&gt;. It&amp;rsquo;s
tempting, it&amp;rsquo;s topical, and it&amp;rsquo;s mostly wrong.&lt;/p&gt;
&lt;p&gt;The people who actually crunch the numbers are clear that the surge is largely
&lt;a class="link" href="https://bishopfox.com/blog/understanding-the-cve-ecosystem-and-nists-changing-role" target="_blank" rel="noopener"
 &gt;legitimate growth&lt;/a&gt;.
There are now more than 484 CVE Numbering Authorities, far more organisations
reporting far more bugs far more thoroughly than they did a decade ago. That isn&amp;rsquo;t
a quality collapse, it&amp;rsquo;s the system working as designed and simply getting bigger
than its funding. Pinning it on AI would be scapegoating, and scapegoating the
robots for an underfunding-and-mismanagement problem is just a way of letting the
people who defunded it off the hook.&lt;/p&gt;
&lt;p&gt;None of which means AI gets a free pass. It just isn&amp;rsquo;t the arsonist. The same
machine-assisted discovery tools that found genuine bugs are also forecast to push
CVE volumes
higher still, and yes, one of the tools named in that forecast is the very one I
&lt;a class="link" href="https://phpboyscout.uk/ai-didnt-kill-curls-bug-bounty/" &gt;poked fun at over curl&lt;/a&gt;.
AI is an accelerant on a fire that was already burning for thoroughly human
reasons. It&amp;rsquo;s a beat in this story, not the spine.&lt;/p&gt;
&lt;h2 id="the-version-im-betting-on"&gt;The version I&amp;rsquo;m betting on
&lt;/h2&gt;&lt;p&gt;Where does this leave the working engineer? In a harder spot than before, because
the easy answer stopped being easy. My usual line, the one I keep ending these pieces on, is that
&lt;a class="link" href="https://phpboyscout.uk/nobody-is-coming-to-clean-your-supply-chain/" &gt;the diligence is the
job&lt;/a&gt;:
pin, lock, audit, and read the actual advisory instead of trusting a number. All
of that still holds. But it just got more expensive, because the data underneath
the diligence is thinner and, as it turns out, was shakier than we let ourselves
believe.&lt;/p&gt;
&lt;p&gt;So I&amp;rsquo;m not going to pretend there&amp;rsquo;s a clean fix. This problem won&amp;rsquo;t solve itself,
and it won&amp;rsquo;t be solved by any one of us. It needs all of us to actually support
the services we depend on, with money, with contributions, with attention, so the
public goods that underpin our craft are still standing in ten years. That&amp;rsquo;s the
dull, grown-up part.&lt;/p&gt;
&lt;p&gt;But I&amp;rsquo;ll end this one looking up rather than down, because for once I can. I think
the next few years bend towards safer software almost in spite of us. Modern
languages are quietly closing off whole categories of vulnerability at the source:
every memory-safety bug that a borrow checker refuses to compile is one that never
reaches a database to be mis-scored in the first place, which is rather the point
of building
&lt;a class="link" href="https://phpboyscout.uk/a-framework-that-contains-no-unsafe/" &gt;a framework that contains no &lt;code&gt;unsafe&lt;/code&gt;&lt;/a&gt;.
Used with proper guidance instead of left to spew slop, AI can be a genuine help
finding and triaging the things that do slip through. And the
&lt;a class="link" href="https://phpboyscout.uk/the-greybeards-edge-was-never-typing/" &gt;junior engineers we keep sawing off the bottom
rung&lt;/a&gt; are
exactly the people who, mentored by the greybeards before they retire, could build
the next generation of vulnerability identification that the current model clearly
can&amp;rsquo;t sustain.&lt;/p&gt;
&lt;p&gt;As for the vultures&amp;hellip; it&amp;rsquo;s a coin toss. A lot of firms will look at the NVD on
its back and see a land grab. I&amp;rsquo;d love to be proved an optimist and watch at least
one of them stand tall, take all that better-resourced data and open it to
open-source projects for nothing, because it&amp;rsquo;s the right thing to do and because
the whole industry drinks from that well. One of them doing the decent thing would
be worth more than all the press releases about responsible AI put together.&lt;/p&gt;
&lt;p&gt;The catalogue is wobbling. The number was never as solid as we treated it. Neither
of those is the end of the world, as long as we stop outsourcing our judgement to a
free service we never funded and never checked, and start paying, in every sense,
for the foundations we build on. Boring, unfashionable, and the only thing that
ever works. I think we&amp;rsquo;re up to it.&lt;/p&gt;</description></item><item><title>Nobody's coming to clean your supply chain</title><link>https://phpboyscout.uk/nobody-is-coming-to-clean-your-supply-chain/</link><pubDate>Fri, 29 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/nobody-is-coming-to-clean-your-supply-chain/</guid><description>&lt;img src="https://phpboyscout.uk/nobody-is-coming-to-clean-your-supply-chain/cover-nobody-is-coming-to-clean-your-supply-chain.png" alt="Featured image of post Nobody's coming to clean your supply chain" /&gt;&lt;p&gt;Pick a week in May 2026 and there&amp;rsquo;s a supply-chain attack in it. On the 11th
someone owned TanStack&amp;rsquo;s CI and pushed
&lt;a class="link" href="https://www.wiz.io/blog/mini-shai-hulud-strikes-again-tanstack-more-npm-packages-compromised" target="_blank" rel="noopener"
 &gt;84 poisoned package versions in six minutes&lt;/a&gt;.
On the 14th, three malicious versions of
&lt;a class="link" href="https://www.stepsecurity.io/blog/node-ipc-npm-supply-chain-attack" target="_blank" rel="noopener"
 &gt;node-ipc&lt;/a&gt;,
a library with ten million weekly downloads, shipped an identical
credential-stealer. Days later it was
&lt;a class="link" href="https://www.microsoft.com/en-us/security/blog/2026/05/20/mini-shai-hulud-compromised-antv-npm-packages-enable-ci-cd-credential-theft/" target="_blank" rel="noopener"
 &gt;@antv&lt;/a&gt;,
cascading down into a charting library a million projects depend on. Each one
runs its payload the moment you install it, then quietly tries to publish
itself from your machine.&lt;/p&gt;
&lt;p&gt;You&amp;rsquo;ve heard this story so many times the outrage has worn smooth. So let me
point at the one detail that should still make you sit up: the TanStack
packages carried &lt;em&gt;valid signing provenance&lt;/em&gt;. Real attestation, pointing at the
real pipeline. The seal was genuine. The contents were poison.&lt;/p&gt;
&lt;h2 id="a-signature-proves-the-sender-not-the-contents"&gt;A signature proves the sender, not the contents
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;ve spent a fair while &lt;a class="link" href="https://phpboyscout.uk/verifying-your-own-downloads/" &gt;building integrity and signing into my own
tools&lt;/a&gt;, so this one
stings a little. Signing is a trust mechanism, and a good one. It&amp;rsquo;s how I prove
a binary you downloaded was built and published by me and nobody else, and in a
world with this many ways to be impersonated, that matters more than ever.&lt;/p&gt;
&lt;p&gt;But TanStack shows the limit in neon. If the pipeline doing the signing is
itself compromised, the signature is still perfectly valid. It just now
certifies a lie. Provenance answers &amp;ldquo;did this come from where it claims?&amp;rdquo; It
does not answer &amp;ldquo;is what&amp;rsquo;s inside safe?&amp;rdquo;, and we have spent a few years quietly
letting people treat those as the same question.&lt;/p&gt;
&lt;p&gt;They aren&amp;rsquo;t. A signature is a promise about the &lt;em&gt;sender&lt;/em&gt;. The thing we actually
need is a promise about the &lt;em&gt;contents&lt;/em&gt;: that whoever signed has done the
diligence, the testing, the vetting, to vouch for what&amp;rsquo;s in the tin. A
signature without that behind it isn&amp;rsquo;t a safety certificate. It&amp;rsquo;s a
tamper-proof seal on a poisoned jar.&lt;/p&gt;
&lt;h2 id="it-was-never-just-npm"&gt;It was never just npm
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s tempting to file all this under &amp;ldquo;npm being npm&amp;rdquo;. Resist it, because it&amp;rsquo;s a
category error. The thing that makes these attacks work, a stranger&amp;rsquo;s code
running on your machine as a side effect of installing or building, is not an
npm bug. It&amp;rsquo;s a near-universal design choice.&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Ecosystem&lt;/th&gt;
 &lt;th&gt;Untrusted code on install/build?&lt;/th&gt;
 &lt;th&gt;Mechanism&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;npm&lt;/strong&gt; (JS)&lt;/td&gt;
 &lt;td&gt;Yes, at install (dependencies too)&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;pre&lt;/code&gt;/&lt;code&gt;postinstall&lt;/code&gt; scripts&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;PyPI&lt;/strong&gt; (Python)&lt;/td&gt;
 &lt;td&gt;sdist yes, wheel no&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;setup.py&lt;/code&gt;; wheels forbid hooks&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;RubyGems&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;Yes, at install&lt;/td&gt;
 &lt;td&gt;native-extension build (&lt;code&gt;extconf.rb&lt;/code&gt;)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;cargo&lt;/strong&gt; (Rust)&lt;/td&gt;
 &lt;td&gt;Yes, at build&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;build.rs&lt;/code&gt; and proc-macros&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;Composer&lt;/strong&gt; (PHP)&lt;/td&gt;
 &lt;td&gt;Dependencies: no&lt;/td&gt;
 &lt;td&gt;only the &lt;em&gt;root&lt;/em&gt; project&amp;rsquo;s scripts run, by design&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;Maven/Gradle&lt;/strong&gt; (JVM)&lt;/td&gt;
 &lt;td&gt;Yes, at build&lt;/td&gt;
 &lt;td&gt;build scripts and plugins&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;NuGet&lt;/strong&gt; (.NET)&lt;/td&gt;
 &lt;td&gt;Modern: no&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;install.ps1&lt;/code&gt;, legacy format only&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;Go&lt;/strong&gt; (modules)&lt;/td&gt;
 &lt;td&gt;No&lt;/td&gt;
 &lt;td&gt;no install or build hooks&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;(Lifecycle hooks across ecosystems are catalogued at
&lt;a class="link" href="https://github.com/ecosyste-ms/package-manager-hooks" target="_blank" rel="noopener"
 &gt;ecosyste.ms&lt;/a&gt; if you want
the receipts.)&lt;/p&gt;
&lt;p&gt;Read that and the lesson isn&amp;rsquo;t &amp;ldquo;npm is uniquely bad&amp;rdquo;, it&amp;rsquo;s &amp;ldquo;this was a choice,
and several ecosystems chose differently&amp;rdquo;. Go runs no install or build hooks at
all. PHP&amp;rsquo;s Composer flatly refuses to run a dependency&amp;rsquo;s scripts, only your own
project&amp;rsquo;s. Python&amp;rsquo;s wheel format forbids install hooks. The hook was never
inevitable.&lt;/p&gt;
&lt;p&gt;And yes, that includes my own back yard. cargo&amp;rsquo;s &lt;code&gt;build.rs&lt;/code&gt; is the same gun
fired at build time instead of install time, and the
&lt;a class="link" href="https://socket.dev/blog/trapdoor-crypto-stealer-npm-pypi-crates" target="_blank" rel="noopener"
 &gt;TrapDoor campaign&lt;/a&gt;
used exactly that to rifle through keystores on crates.io this year. Rust isn&amp;rsquo;t
safe here. It&amp;rsquo;s a smaller, better-policed target, which is a different thing,
and I&amp;rsquo;d rather say so than pretend one of my favourite languages is above it.&lt;/p&gt;
&lt;h2 id="no-registry-can-hand-you-a-clean-package"&gt;No registry can hand you a clean package
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the uncomfortable core. Not one of these registries can guarantee the
package you pull is clean. They can sign it, scan it, attest its origin and
mandate 2FA on maintainers, and they should do all of that. But none of it is a
guarantee, because the failure modes are endless and attackers keep finding new
ones. A maintainer account gets phished. A CI token leaks. A trusted
contributor turns. A dependency four levels down quietly changes hands.&lt;/p&gt;
&lt;p&gt;So the onus lands, and will keep landing for a good while yet, on the consuming
engineer. That isn&amp;rsquo;t a comfortable answer or a clever one. It&amp;rsquo;s the true one.&lt;/p&gt;
&lt;p&gt;And it&amp;rsquo;s a genuinely rotten spot to stand in, because the advice contradicts
itself. Patch slowly and you&amp;rsquo;re scolded for running known-vulnerable
dependencies. Patch the instant a release drops and you&amp;rsquo;ve skipped the
bedding-in that might have caught a poisoned one. There&amp;rsquo;s no setting on that
dial that&amp;rsquo;s safe, only trade-offs you have to actually think about. Add CI that
leaks credentials it never needed, and a dependency tree thousands of strangers
deep, and you can see why there&amp;rsquo;s no single villain to blame and no single
switch to flip.&lt;/p&gt;
&lt;h2 id="the-boring-discipline-that-actually-helps"&gt;The boring discipline that actually helps
&lt;/h2&gt;&lt;p&gt;What&amp;rsquo;s left isn&amp;rsquo;t heroic, it&amp;rsquo;s hygiene, and it&amp;rsquo;s the boring, necessary stuff I keep
banging on about.
&lt;a class="link" href="https://phpboyscout.uk/openssf-scorecard-graded-my-supply-chain/" &gt;Pin your CI actions to commit SHAs&lt;/a&gt;
so a moved tag can&amp;rsquo;t swap code under you. Commit your lockfiles. Run the
auditors, &lt;code&gt;cargo-audit&lt;/code&gt;, &lt;code&gt;pip-audit&lt;/code&gt;, &lt;code&gt;govulncheck&lt;/code&gt;, &lt;code&gt;npm audit&lt;/code&gt;, or Google&amp;rsquo;s
cross-ecosystem &lt;a class="link" href="https://github.com/google/osv-scanner" target="_blank" rel="noopener"
 &gt;OSV-Scanner&lt;/a&gt;, on every
build. Gate the dependency tree and
&lt;a class="link" href="https://phpboyscout.uk/waivers-with-an-expiry-date/" &gt;give every exception an expiry date&lt;/a&gt;
so &amp;ldquo;we&amp;rsquo;ll deal with it later&amp;rdquo; can&amp;rsquo;t quietly become &amp;ldquo;never&amp;rdquo;. Keep the tree
small: every crate you don&amp;rsquo;t add is a stranger you don&amp;rsquo;t have to trust.&lt;/p&gt;
&lt;p&gt;None of that is a solution. All of it is diligence, and diligence is the only
thing that was ever going to stand behind the signature. When I sign a release,
the cryptography is the easy part. The promise underneath it, that I pinned,
locked, audited, vetted and tested before I put my name on it, is the part
worth anything. That&amp;rsquo;s the contract. The signature is just how I countersign
it.&lt;/p&gt;
&lt;p&gt;The encouraging note is that the structural defences exist and they work. Go&amp;rsquo;s
checksum database and its refusal to run hooks. Composer declining to trust a
dependency&amp;rsquo;s scripts. Python&amp;rsquo;s wheels. &lt;code&gt;cargo-vet&lt;/code&gt; and &lt;code&gt;cargo-deny&lt;/code&gt; giving you
somewhere to record human judgement at scale. More ecosystems should steal
these shamelessly, because a registry that makes the safe path the &lt;em&gt;default&lt;/em&gt;
does the working engineer a far bigger favour than one that leaves it all to
discipline.&lt;/p&gt;
&lt;h2 id="the-same-shape-a-third-time"&gt;The same shape, a third time
&lt;/h2&gt;&lt;p&gt;If this feels familiar, it should. I wrote recently about
&lt;a class="link" href="https://phpboyscout.uk/ai-didnt-kill-curls-bug-bounty/" &gt;a bug bounty that collapsed because the cost of slop was deferred&lt;/a&gt;,
and about &lt;a class="link" href="https://phpboyscout.uk/the-greybeards-edge-was-never-typing/" &gt;a junior pipeline being cut because the bill lands years
later&lt;/a&gt;.
Supply-chain security is the same shape a third time. The convenience is now,
the catastrophe is later, and the only thing standing in the gap is an engineer
paying attention, doing the dull work, refusing to be rushed into trusting
something they haven&amp;rsquo;t checked.&lt;/p&gt;
&lt;p&gt;There is no clean package waiting to be found, no registry about to solve this
for us, no signature that means &amp;ldquo;safe&amp;rdquo; all on its own. There&amp;rsquo;s the diligence
you do before you put your name to something, and the judgement to know when an
install is asking you to trust more than you should. For a good while yet, that
is the whole job. Boring, unfashionable, and the only thing that works.&lt;/p&gt;</description></item></channel></rss>