Skip to content

sec(install): make cosign verification mandatory on Windows - #523

Merged
LukasWodka merged 2 commits into
developfrom
sec/2078-windows-mandatory-cosign
Aug 18, 2026
Merged

sec(install): make cosign verification mandatory on Windows#523
LukasWodka merged 2 commits into
developfrom
sec/2078-windows-mandatory-cosign

Conversation

@LukasWodka

Copy link
Copy Markdown
Contributor

Closes tracebloc/backend#2078.

The bug

scripts/install.ps1 gated cosign verification on whether cosign happened to be installed:

if (Get-Command cosign -ErrorAction SilentlyContinue) { ...verify... }
else { Write-Host "  (cosign not installed; SHA256 verified, signature skipped)" }

On a fresh Windows box cosign is never present, so the default path was the unverified one. The SHA256 it kept is same-channel: SHA256SUMS ships 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.sh has bootstrapped a pinned cosign and failed closed since backend#889. Windows was the one platform left, which made this claim in README.md:75 false — and false with no platform qualifier:

Signature verification is mandatory … fails closed

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=1 is 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.sh does not:

The bootstrap fetches cosign-windows-amd64.exe on 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-CosignRuns separates "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.shinstall-verify.sh's sibling, same CI job.

result
against develop's installer 0 passed, 5 failed (exit 1)
after this change 5 passed, 0 failed (exit 0)

install.ps1 can'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 pwsh is missing the harness fails rather than skips: "cannot tell" is not evidence that verification is mandatory (rule 3). ALLOW_NO_PWSH=1 downgrades it on a dev box; CI never sets it.

Six guards, each mutation-proven, each mutation asserted to have applied:

mutation reddens
[bool]$env:… instead of -eq '1' 4 tests
checksum mismatch stops refusing 1
$LASTEXITCODE not armed before the probe 1
an opt-out on a FAILED verification 1
the no-cosign degrade restored 1
a \$ escape in a message 1

Three of those are bugs this change made, caught before review

  • $AllowUnverified was [bool]$env:TRACEBLOC_ALLOW_UNVERIFIED. Every non-empty string casts to $true in PowerShell, so TRACEBLOC_ALLOW_UNVERIFIED=0 would have switched the bypass on. Now -eq '1', matching install.sh, with a truth table over unset / '' / 0 / false / no / true / 1.
  • The bootstrap restated Get-Arch's logic minus its PROCESSOR_ARCHITEW6432 handling, 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 as Pin 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 as cosign.ps1, and Get-Command finds .ps1 on PATH whatever PATHEXT says), which & dispatches in-process and which sets no $LASTEXITCODE at all. Without the 255 preset the helper then reads the 0 left by the last successful command and reports a shim that did nothing as a working cosign.

Local

install-ps1-verify: 5 passed, 0 failed
install-ps1-functions: 22 passed, 0 failed
install-verify: 33 passed, 0 failed          # unchanged, no regression

shellcheck --severity=error clean on the new harness. actionlint on build.yml reports the same two pre-existing SC2001 style findings as HEAD (lines 132/145, unrelated).

Not in scope

TRACEBLOC_ALLOW_UNVERIFIED still has no Windows-side test that drives the installer end-to-end — only its helpers and its control-flow shape. Doing that properly needs a windows-latest job, which is a separate call about CI minutes.

🤖 Generated with Claude Code

…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
@LukasWodka LukasWodka self-assigned this Aug 17, 2026
@LukasWodka
LukasWodka requested a review from saadqbal August 17, 2026 14:58

@saadqbal saadqbal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread scripts/tests/install-ps1-functions.tests.ps1
@LukasWodka
LukasWodka merged commit 3b88a85 into develop Aug 18, 2026
25 checks passed
@LukasWodka
LukasWodka deleted the sec/2078-windows-mandatory-cosign branch August 18, 2026 08:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants