sec(install): make cosign verification mandatory on Windows - #523
Merged
Conversation
…cosign
install.ps1 gated cosign verification on `if (Get-Command cosign)`. On a
fresh Windows box cosign is never present, so the default path printed
(cosign not installed; SHA256 verified, signature skipped)
and installed. The SHA256 it kept is same-channel — SHA256SUMS ships from
the same release as the binary, so whoever could swap one could swap the
other. It proves the download finished, not who built it.
That made README.md:75 ("Signature verification is mandatory ... fails
closed", no platform qualifier) false on Windows, and only on Windows:
install.sh has bootstrapped a pinned cosign and failed closed since
backend#889. The README needs no edit — this makes the existing claim
true.
Windows now does what Linux and macOS do: bootstrap a pinned,
checksum-verified cosign, and refuse to install when it can't.
TRACEBLOC_ALLOW_UNVERIFIED=1 is the one escape, and it covers "cannot
verify" only — never a verification that ran and FAILED, which is
tampering evidence no env var should override.
Two things this does that install.sh does not have to:
- The bootstrap fetches cosign-windows-amd64.exe on BOTH architectures.
Sigstore has never published a Windows arm64 build, so a per-arch name
404s and locks Windows-on-ARM out permanently (the same bug as
tracebloc/client#734). Verification is over bytes, so the verifier's
instruction set cannot change the verdict.
- Test-CosignRuns separates "cosign won't start here" from "the signature
is bad". They arrive through the same channel and warrant opposite
messages — only one means the artifact may be tampered with. Absent x64
emulation on Windows-on-ARM is the case that makes this real, and
"install cosign" would be useless advice there.
Also sets a TLS 1.2 floor: PS 5.1 defaults to SSL3/TLS1.0 on older
Windows, and every fetch here carries either the binary or the verifier
that authenticates it.
Tests — scripts/tests/install-ps1-verify.sh, install-verify.sh's sibling,
wired into the same CI job. Against develop's installer: 0 passed, 5
failed. After: 5 passed, 0 failed.
install.ps1 cannot be driven end-to-end on the Linux runner (it ends in
Windows-registry PATH writes), so the behavioural tier extracts the
helpers from the real file BY AST and executes them — a copy would pass
while production broke. Six guards, each mutation-proven, each mutation
asserted to have applied:
[bool]$env:... instead of -eq '1' -> 4 fail
checksum mismatch stops refusing -> 1 fail
$LASTEXITCODE not armed before the probe -> 1 fail
an opt-out on a FAILED verification -> 1 fail
the no-cosign degrade restored -> 1 fail
a \$ escape in a message -> 1 fail
Three of those are bugs this change made and this tier caught before
review:
- $AllowUnverified was [bool]$env:TRACEBLOC_ALLOW_UNVERIFIED. Every
non-empty string casts to $true in PowerShell, so setting it to 0 would
have switched the bypass ON.
- The bootstrap restated Get-Arch's logic minus its PROCESSOR_ARCHITEW6432
handling, so a 32-bit PowerShell host would have refused. Removed: the
asset is arch-independent, there was nothing to branch on.
- "Pin a signed \$env:RELEASE_VERSION" rendered as "Pin a signed ," —
PowerShell escapes with a backtick, so \$ prints a backslash and expands
the variable. Now a check of its own.
One test in this tier was vacuous on its first writing: a "stale exit
code" assertion built on an absent path, which throws and returns before
ever reading $LASTEXITCODE. The mutation caught it — M3 applied and
nothing reddened. Replaced with the input that is actually reachable: a
PowerShell shim (scoop and chocolatey install cosign.ps1), which `&`
dispatches in-process and which sets no $LASTEXITCODE at all.
backend#2078
saadqbal
approved these changes
Aug 18, 2026
saadqbal
left a comment
Collaborator
There was a problem hiding this comment.
Really careful PR — the fail-closed properties all hold. A verification that ran and FAILED has no TRACEBLOC_ALLOW_UNVERIFIED escape (verify-blob runs outside the try/catch, $LASTEXITCODE gates the exit); the opt-out covers "cannot verify" only; cosign-won't-run is split from bad-signature via Test-CosignRuns and gives ARM-aware advice; and -eq '1' avoids the [bool]'0' foot-gun. release.yml already signs both Windows arches and publishes .sig/.cert where install.ps1 looks (identity/issuer match install.sh), so mandatory-verify doesn't break existing installs. Ran the harness — one non-blocking test-hermeticity nit inline; CI is green.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes tracebloc/backend#2078.
The bug
scripts/install.ps1gated cosign verification on whether cosign happened to be installed:On a fresh Windows box cosign is never present, so the default path was the unverified one. The SHA256 it kept is same-channel:
SHA256SUMSships from the same GitHub release as the binary, so whoever could swap the binary could swap the sums with it. It proves the download completed, not who built it.install.shhas bootstrapped a pinned cosign and failed closed since backend#889. Windows was the one platform left, which made this claim inREADME.md:75false — and false with no platform qualifier:No README edit in this PR. The claim was correct about intent and wrong about Windows; this makes it true.
The fix
Windows now does what the other two platforms do — bootstrap a pinned, checksum-verified cosign, refuse to install when it can't.
TRACEBLOC_ALLOW_UNVERIFIED=1is the one escape, and it covers "cannot verify" only: no cosign, no.sig/.cert. It deliberately does not cover a verification that ran and failed, which is tampering evidence no env var should override. (AST-asserted, test 5.)Two things this needs that
install.shdoes not:The bootstrap fetches
cosign-windows-amd64.exeon both architectures. Sigstore has never published a Windows arm64 cosign — at any release. Asking for a per-arch name 404s and locks Windows-on-ARM out permanently; that is the same bug as tracebloc/client#734, fixed there the same way. Verification is over bytes, so the verifier's instruction set cannot change the verdict, and the artifact handed to it is still the native arm64 binary.Test-CosignRunsseparates "cosign won't start here" from "the signature is bad." They arrive through the same channel and warrant opposite reactions — only one means the artifact may be tampered with. Absent x64 emulation on Windows-on-ARM is the case that makes it real, and "install cosign" would be useless advice there since one is installed.Also sets a TLS 1.2 floor. PS 5.1 defaults to SSL3/TLS1.0 on older Windows, and every fetch here carries either the binary we are about to run or the verifier that authenticates it.
Tests
scripts/tests/install-ps1-verify.sh—install-verify.sh's sibling, same CI job.develop's installerinstall.ps1can't be driven end-to-end on the Linux runner — it ends in Windows-registry PATH writes that throw there, so a full run would fail for reasons unrelated to what's under test. The behavioural tier therefore extracts the helpers from the real file by AST and executes them. A hand-copied helper would pass while production broke, which is the failure mode CLAUDE.md rule 9 names.If
pwshis missing the harness fails rather than skips: "cannot tell" is not evidence that verification is mandatory (rule 3).ALLOW_NO_PWSH=1downgrades it on a dev box; CI never sets it.Six guards, each mutation-proven, each mutation asserted to have applied:
[bool]$env:…instead of-eq '1'$LASTEXITCODEnot armed before the probe\$escape in a messageThree of those are bugs this change made, caught before review
$AllowUnverifiedwas[bool]$env:TRACEBLOC_ALLOW_UNVERIFIED. Every non-empty string casts to$truein PowerShell, soTRACEBLOC_ALLOW_UNVERIFIED=0would have switched the bypass on. Now-eq '1', matchinginstall.sh, with a truth table overunset / '' / 0 / false / no / true / 1.Get-Arch's logic minus itsPROCESSOR_ARCHITEW6432handling, so a 32-bit PowerShell host on ARM would have refused. Removed rather than fixed: the asset is arch-independent, so there was nothing to branch on."Pin a signed \$env:RELEASE_VERSION"rendered asPin a signed ,— PowerShell escapes with a backtick, so\$emits a backslash and expands the variable, dropping the one thing the user needed. Now a check of its own over every double-quoted message.One test was vacuous on its first writing
A "stale exit code must not leak through" assertion built on an absent path — which throws and returns before ever reading
$LASTEXITCODE. The mutation caught it: M3 applied and nothing reddened. Replaced with the input that is actually reachable — a PowerShell shim (scoop and chocolatey install tools ascosign.ps1, andGet-Commandfinds.ps1on PATH whateverPATHEXTsays), which&dispatches in-process and which sets no$LASTEXITCODEat all. Without the 255 preset the helper then reads the0left by the last successful command and reports a shim that did nothing as a working cosign.Local
shellcheck --severity=errorclean on the new harness.actionlintonbuild.ymlreports the same two pre-existingSC2001style findings asHEAD(lines 132/145, unrelated).Not in scope
TRACEBLOC_ALLOW_UNVERIFIEDstill has no Windows-side test that drives the installer end-to-end — only its helpers and its control-flow shape. Doing that properly needs awindows-latestjob, which is a separate call about CI minutes.🤖 Generated with Claude Code