diff --git a/.github/workflows/release-helm-chart.yaml b/.github/workflows/release-helm-chart.yaml index 8e43a057..10cb4f88 100644 --- a/.github/workflows/release-helm-chart.yaml +++ b/.github/workflows/release-helm-chart.yaml @@ -267,9 +267,17 @@ jobs: COSIGN_YES: 'true' run: | cd scripts + # Emit an offline Sigstore BUNDLE (#584) alongside the .sig/.cert. The + # bundle carries the Rekor inclusion proof (SET), so the installer can + # `verify-blob --bundle --offline` with NO live Rekor call — the fix for + # sigstore-blocked / TLS-inspecting networks, where the short-lived keyless + # cert is expired by install time and only the bundle's embedded timestamp + # proves it was valid at signing. The .sig/.cert stay for older installers' + # online path (backward compatible). cosign sign-blob \ --output-certificate manifest.sha256.cert \ --output-signature manifest.sha256.sig \ + --bundle manifest.sha256.bundle \ manifest.sha256 echo "Signed manifest.sha256" ls -l manifest.sha256* @@ -436,6 +444,7 @@ jobs: scripts/manifest.sha256 scripts/manifest.sha256.sig scripts/manifest.sha256.cert + scripts/manifest.sha256.bundle env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Post-publish invariant check: turns the 2026-07-29 manual leak catch into diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 57d14cbe..d3ac37ce 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -308,6 +308,30 @@ function Resolve-Cosign { return $bin } +# Run cosign verify-blob with the fail-closed sentinel + stderr suppression, returning +# $true iff it verified. Shared by Confirm-ManifestSignature's offline-bundle and online +# sig/cert paths so the hardening lives in ONE place: +# - $LASTEXITCODE is seeded to a NONZERO sentinel first, so a cosign that returns +# WITHOUT setting it (corrupt / AV-quarantined / wrong exec format) fails closed, +# never a stale 0 read as "verified". +# - stderr is merged to stdout and discarded: a native tool writing to stderr would +# otherwise surface as a NativeCommandError dumping this script's source line + +# internal identifiers into the console/transcript (#576). +function Invoke-CosignVerifyBlob { + param([Parameter(Mandatory)][string]$Cosign, [Parameter(Mandatory)][string[]]$VerifyArgs) + $global:LASTEXITCODE = 255 + $prevEAP = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + & $Cosign @VerifyArgs 2>&1 | Out-Null + } catch { + return $false + } finally { + $ErrorActionPreference = $prevEAP + } + return ($LASTEXITCODE -eq 0) +} + # Authenticate manifest.sha256 with cosign keyless before trusting a single digest # in it. The signing identity is the client release workflow's OIDC certificate # (same chain as install.sh + the CLI binary). Fail-closed unless the operator @@ -346,6 +370,33 @@ function Confirm-ManifestSignature { throw "cosign is required to verify the installer's signed manifest and couldn't be found or bootstrapped. Refusing to fall back to an unauthenticated, same-channel checksum (RFC-0001 R8). Fix: install cosign (https://docs.sigstore.dev/cosign/installation/) and re-run, or for local development only set `$env:TRACEBLOC_ALLOW_UNVERIFIED = '1'`." } + # The keyless signing identity: the release workflow's OIDC cert. SAME pins as + # install.sh; shared by both verification paths below. + $idRe = 'https://github.com/tracebloc/client/\.github/workflows/.*@.*' + $issuer = 'https://token.actions.githubusercontent.com' + + # OFFLINE Sigstore bundle first (#584): the bundle carries the Rekor inclusion + # proof, so this verifies signature + cert identity + tlog inclusion with NO live + # Rekor call — immune to networks that block/TLS-inspect sigstore, and the only + # path that verifies our short-lived keyless cert once it has expired (its embedded + # timestamp proves the cert was valid at signing). Releases cut before the bundle + # existed 404 here and fall through to the online .sig/.cert path; so does a bundle + # that doesn't verify — the online path does the SAME full keyless check, just + # needing live Rekor, so this is a fallback, never a downgrade. + $bundle = Join-Path $TmpDir "manifest.sha256.bundle" + if (Get-Optional "$RepoRel/manifest.sha256.bundle" $bundle) { + if (Invoke-CosignVerifyBlob $cosign @( + 'verify-blob', + '--bundle', $bundle, + '--certificate-identity-regexp', $idRe, + '--certificate-oidc-issuer', $issuer, + '--offline', + $Manifest)) { + Ok "Download verified as published by tracebloc." + return + } + } + $sig = Join-Path $TmpDir "manifest.sha256.sig" $cert = Join-Path $TmpDir "manifest.sha256.cert" if (-not (Get-Optional "$RepoRel/manifest.sha256.sig" $sig) -or @@ -357,42 +408,17 @@ function Confirm-ManifestSignature { throw "manifest.sha256.sig / .cert not published for this release -- can't authenticate the manifest. Pin a release tag that ships them (RFC-0001 R8)." } - # The identity is the client release workflow (release-helm-chart.yaml) — the - # keyless signer that produced the manifest. SAME pins as install.sh. - $cosignArgs = @( - 'verify-blob', - '--certificate-identity-regexp', 'https://github.com/tracebloc/client/\.github/workflows/.*@.*', - '--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com', - '--certificate', $cert, - '--signature', $sig, - $Manifest - ) - # Reset to a NONZERO sentinel first: a cosign that exists but can't launch - # (corrupt, AV-quarantined, wrong exec format) can return WITHOUT setting - # $LASTEXITCODE, leaving a stale prior value — a stale 0 would read as "verified" - # (fail-open). The sentinel + the catch below make BOTH the won't-launch and the - # returns-nonzero cases fail closed (parity with install.sh's `if cosign; else`). - $global:LASTEXITCODE = 255 - $prevEAP = $ErrorActionPreference - try { - # Merge cosign's stderr into stdout and discard both. A native tool writing to - # stderr otherwise surfaces as a NativeCommandError that dumps THIS script's - # source line + internal identifiers into the console / any transcript (#576 — - # a client's log exposed `& $cosign @cosignArgs` and our internal codes). Only - # a curated message is ever shown. ($ErrorActionPreference=Continue so a native - # non-zero doesn't terminate before we check $LASTEXITCODE; #578 will capture - # this output to guide users whose network blocks the verification service.) - $ErrorActionPreference = 'Continue' - & $cosign @cosignArgs 2>&1 | Out-Null - } catch { - throw "Couldn't run the download-verification step, so the install stopped before changing anything on your machine." - } finally { - $ErrorActionPreference = $prevEAP - } - if ($LASTEXITCODE -ne 0) { + if (Invoke-CosignVerifyBlob $cosign @( + 'verify-blob', + '--certificate-identity-regexp', $idRe, + '--certificate-oidc-issuer', $issuer, + '--certificate', $cert, + '--signature', $sig, + $Manifest)) { + Ok "Download verified as published by tracebloc." + } else { throw "Couldn't confirm the installer download is authentic, so the install stopped before changing anything on your machine." } - Ok "Download verified as published by tracebloc." } # Verify each fetched sub-script against the signed manifest. A missing manifest diff --git a/scripts/install.sh b/scripts/install.sh index 0a4a1df2..df9287ee 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -435,6 +435,11 @@ verify_manifest_signature() { local manifest="$1" local sig="$TMPDIR/manifest.sha256.sig" local cert="$TMPDIR/manifest.sha256.cert" + local bundle="$TMPDIR/manifest.sha256.bundle" + # The keyless signing identity: the release workflow's OIDC cert. Shared by both + # the offline-bundle and the online sig/cert verification paths below. + local id_re='https://github.com/tracebloc/client/\.github/workflows/.*@.*' + local issuer='https://token.actions.githubusercontent.com' if ! ensure_cosign; then if [[ "$ALLOW_UNVERIFIED" == "1" ]]; then @@ -450,6 +455,26 @@ verify_manifest_signature() { exit 1 fi + # OFFLINE Sigstore bundle first (#584): the bundle carries the Rekor inclusion + # proof, so this verifies signature + cert identity + tlog inclusion with NO live + # Rekor call — immune to networks that block/TLS-inspect sigstore, and the ONLY + # path that verifies our short-lived keyless cert once it has expired (its embedded + # timestamp proves the cert was valid at signing). Releases cut before the bundle + # existed simply 404 here and fall through to the online .sig/.cert path below; + # so does any bundle that doesn't verify — the online path does the SAME full + # keyless check, just needing live Rekor, so this is a fallback, never a downgrade. + if curl -fsSL --tlsv1.2 --connect-timeout 30 --max-time 300 "$REPO_REL/manifest.sha256.bundle" -o "$bundle" 2>/dev/null; then + if "$COSIGN_BIN" verify-blob \ + --bundle "$bundle" \ + --certificate-identity-regexp "$id_re" \ + --certificate-oidc-issuer "$issuer" \ + --offline \ + "$manifest" >/dev/null 2>&1; then + printf ' %s✔%s Download verified as published by tracebloc\n' "$_G" "$_R" + return 0 + fi + fi + if ! curl -fsSL --tlsv1.2 --connect-timeout 30 --max-time 300 "$REPO_REL/manifest.sha256.sig" -o "$sig" 2>/dev/null \ || ! curl -fsSL --tlsv1.2 --connect-timeout 30 --max-time 300 "$REPO_REL/manifest.sha256.cert" -o "$cert" 2>/dev/null; then if [[ "$ALLOW_UNVERIFIED" == "1" ]]; then @@ -462,9 +487,8 @@ verify_manifest_signature() { fi if "$COSIGN_BIN" verify-blob \ - --certificate-identity-regexp \ - 'https://github.com/tracebloc/client/\.github/workflows/.*@.*' \ - --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ + --certificate-identity-regexp "$id_re" \ + --certificate-oidc-issuer "$issuer" \ --certificate "$cert" \ --signature "$sig" \ "$manifest" >/dev/null 2>&1; then diff --git a/scripts/tests/install-bootstrap.bats b/scripts/tests/install-bootstrap.bats index b4adfd31..7b94e8bd 100644 --- a/scripts/tests/install-bootstrap.bats +++ b/scripts/tests/install-bootstrap.bats @@ -66,8 +66,9 @@ done serve="$SERVE"; serve_rel="$SERVE_REL" case "\$url" in *"/releases/download/"*/manifest.sha256) src="\$serve_rel/manifest.sha256" ;; - *"/releases/download/"*/manifest.sha256.sig) src="\$serve_rel/manifest.sha256.sig" ;; - *"/releases/download/"*/manifest.sha256.cert) src="\$serve_rel/manifest.sha256.cert" ;; + *"/releases/download/"*/manifest.sha256.sig) src="\$serve_rel/manifest.sha256.sig" ;; + *"/releases/download/"*/manifest.sha256.cert) src="\$serve_rel/manifest.sha256.cert" ;; + *"/releases/download/"*/manifest.sha256.bundle) src="\$serve_rel/manifest.sha256.bundle" ;; *raw.githubusercontent.com/*/scripts/*) src="\$serve/scripts/\${url#*/scripts/}" ;; *) echo "mock curl: unmapped \$url" >&2; exit 22 ;; esac @@ -214,6 +215,71 @@ EOF [ -z "$(cat "$SBX/cosign-ssl")" ] || return 1 } +@test "bootstrap prefers the offline Sigstore bundle: verify-blob --bundle --offline (#584)" { + # When a manifest.sha256.bundle is published, the bootstrap must verify it OFFLINE + # (no live Rekor) and NOT fall back to the .sig/.cert online path — that's what makes + # a fresh install work on a sigstore-blocked / TLS-inspecting network. + printf 'BUNDLE\n' > "$SERVE_REL/manifest.sha256.bundle" + cat > "$BIN/cosign" <> "$SBX/cosign-args" +exit 0 +EOF + chmod +x "$BIN/cosign" + REF="v9.9.9" run_boot + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [ -f "$SBX/k8s-ran" ] || return 1 + grep -q -- '--bundle' "$SBX/cosign-args" || return 1 + grep -q -- '--offline' "$SBX/cosign-args" || return 1 + # bundle verified => the online sig/cert path is NOT taken + ! grep -q -- '--signature' "$SBX/cosign-args" || return 1 +} + +@test "bootstrap falls back to sig+cert when the bundle is present but fails offline verify (#584, reviewer)" { + # Exercise the bundle-present-but-verify-fails -> sig/cert fallback (not covered by + # the exit-0 bundle tests). cosign REJECTS the --bundle call but ACCEPTS sig/cert. + printf 'BUNDLE\n' > "$SERVE_REL/manifest.sha256.bundle" + cat > "$BIN/cosign" <> "$SBX/cosign-args" +for a in "\$@"; do [ "\$a" = "--bundle" ] && exit 1; done # offline bundle verify fails +exit 0 # online sig/cert verify passes +EOF + chmod +x "$BIN/cosign" + REF="v9.9.9" run_boot + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [ -f "$SBX/k8s-ran" ] || return 1 + grep -q -- '--bundle' "$SBX/cosign-args" || return 1 # bundle path WAS attempted + grep -q -- '--signature' "$SBX/cosign-args" || return 1 # ...then fell back to sig/cert +} + +@test "bootstrap fails closed when BOTH the bundle and sig/cert verify fail (#584, reviewer)" { + # Bundle present, but every cosign verify fails -> must abort, never reach the + # privileged step (no silent fall-through to running unverified scripts). + printf 'BUNDLE\n' > "$SERVE_REL/manifest.sha256.bundle" + COSIGN_RESULT=1 REF="v9.9.9" run_boot + [ "$status" -ne 0 ] || { echo "$output"; return 1; } + [ ! -f "$SBX/k8s-ran" ] || return 1 + [[ "$output" == *"Couldn't confirm the installer download is authentic"* ]] || return 1 +} + +@test "bootstrap falls back to sig+cert when no bundle is published (older release) (#584)" { + # No bundle asset (a release cut before #584): the bundle fetch 404s and the + # bootstrap must fall through to the online .sig/.cert keyless verification. + [ ! -f "$SERVE_REL/manifest.sha256.bundle" ] || return 1 # precondition: no bundle + cat > "$BIN/cosign" <> "$SBX/cosign-args" +exit 0 +EOF + chmod +x "$BIN/cosign" + REF="v9.9.9" run_boot + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [ -f "$SBX/k8s-ran" ] || return 1 + grep -q -- '--signature' "$SBX/cosign-args" || return 1 # online path used + ! grep -q -- '--bundle' "$SBX/cosign-args" || return 1 # bundle path not taken +} + @test "bootstrap fails fast on a set-but-unreadable CA bundle (#583 Bugbot)" { # A bad CA path must fail here with a clear message, not silently no-op and surface # later as a generic cosign authenticity error. diff --git a/scripts/tests/install.Tests.ps1 b/scripts/tests/install.Tests.ps1 index 697450b1..d84b93e8 100644 --- a/scripts/tests/install.Tests.ps1 +++ b/scripts/tests/install.Tests.ps1 @@ -154,8 +154,10 @@ Describe "Bootstrap log hygiene: cosign output captured, no internals leaked (#5 BeforeAll { $script:BOOTSRC = Get-Content "$PSScriptRoot/../install.ps1" -Raw } It "captures cosign output instead of letting PowerShell dump the raw native error + source line" { - $script:BOOTSRC | Should -Not -Match '& \$cosign @cosignArgs 2>\$null 1>\$null' - $script:BOOTSRC | Should -Match '& \$cosign @cosignArgs 2>&1 \| Out-Null' + # The capture hardening now lives in the shared Invoke-CosignVerifyBlob helper (#584); + # the leaky discard form must be gone and the stderr-merged capture present. + $script:BOOTSRC | Should -Not -Match '2>\$null 1>\$null' + $script:BOOTSRC | Should -Match '& \$Cosign @VerifyArgs 2>&1 \| Out-Null' } It "the verification-failure message carries no internal identifiers (no source, no RFC/manifest codes)" { $script:BOOTSRC | Should -Not -Match 'cosign signature verification FAILED for manifest\.sha256' @@ -175,3 +177,43 @@ Describe "Bootstrap CA handling for cosign on Windows (#583)" { $fn | Should -Not -Match '\$env:SSL_CERT_FILE = \$ca' # inert on Windows; not wired } } + +Describe "Bootstrap prefers the offline Sigstore bundle (#584)" { + It "Confirm-ManifestSignature verifies --bundle --offline first, with a sig/cert fallback" { + $src = Get-Content "$PSScriptRoot/../install.ps1" -Raw + $fn = (($src -split "function Confirm-ManifestSignature")[1] -split "`nfunction ")[0] + $fn | Should -Match 'manifest\.sha256\.bundle' + $fn | Should -Match "'--bundle'" + $fn | Should -Match "'--offline'" + $fn | Should -Match "'--signature'" # online fallback path retained + } + It "Invoke-CosignVerifyBlob is fail-closed (nonzero LASTEXITCODE sentinel + stderr suppressed)" { + $src = Get-Content "$PSScriptRoot/../install.ps1" -Raw + $fn = (($src -split "function Invoke-CosignVerifyBlob")[1] -split "`nfunction ")[0] + $fn | Should -Match '\$global:LASTEXITCODE = 255' + $fn | Should -Match '2>&1 \| Out-Null' + } +} + +Describe "Confirm-ManifestSignature: offline-bundle -> sig/cert fallback behaviour (#584, reviewer)" { + # Behavioural (not source-text): drive the fallback + fail-closed branches directly. + BeforeEach { + $env:TRACEBLOC_CA_BUNDLE = $null; $env:CURL_CA_BUNDLE = $null # skip the CA fast-fail + Mock Resolve-Cosign { "cosign" } + Mock Get-Optional { $true } # bundle + sig + cert all "published/fetched" + Mock Ok {}; Mock Warn {} + } + + It "falls back to the sig/cert path when the bundle verify fails, and verifies" { + Mock Invoke-CosignVerifyBlob { if ($VerifyArgs -contains '--bundle') { $false } else { $true } } + { Confirm-ManifestSignature -Manifest 'm' -RepoRel 'r' -TmpDir $TestDrive -AllowUnverified $false } | + Should -Not -Throw + Should -Invoke Invoke-CosignVerifyBlob -Times 2 -Exactly # bundle attempt + sig/cert fallback + } + + It "fails closed when BOTH the bundle and the sig/cert verify fail" { + Mock Invoke-CosignVerifyBlob { $false } + { Confirm-ManifestSignature -Manifest 'm' -RepoRel 'r' -TmpDir $TestDrive -AllowUnverified $false } | + Should -Throw -ExpectedMessage "*Couldn't confirm the installer download is authentic*" + } +}