Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/release-helm-chart.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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*
Expand DownExpand Up@@ -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
Expand Down
94 changes: 60 additions & 34 deletions scripts/install.ps1
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
Expand All@@ -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
Expand Down
30 changes: 27 additions & 3 deletions scripts/install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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 \
Comment thread
shujaatTracebloc marked this conversation as resolved.
--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
Expand All@@ -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
Expand Down
70 changes: 68 additions & 2 deletions scripts/tests/install-bootstrap.bats
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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.
Expand Down
46 changes: 44 additions & 2 deletions scripts/tests/install.Tests.ps1
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'
Expand All@@ -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*"
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(installer): offline Sigstore bundle — verify --offline, no live Rekor (#584) by shujaatTracebloc · Pull Request #599 · tracebloc/client · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/release-helm-chart.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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*
Expand DownExpand Up@@ -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
Expand Down
94 changes: 60 additions & 34 deletions scripts/install.ps1
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
Expand All@@ -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
Expand Down
30 changes: 27 additions & 3 deletions scripts/install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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 \
Comment thread
shujaatTracebloc marked this conversation as resolved.
--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
Expand All@@ -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
Expand Down
70 changes: 68 additions & 2 deletions scripts/tests/install-bootstrap.bats
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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.
Expand Down
46 changes: 44 additions & 2 deletions scripts/tests/install.Tests.ps1
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'
Expand All@@ -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*"
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(installer): offline Sigstore bundle — verify --offline, no live Rekor (#584) by shujaatTracebloc · Pull Request #599 · tracebloc/client · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/release-helm-chart.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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*
Expand DownExpand Up@@ -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
Expand Down
94 changes: 60 additions & 34 deletions scripts/install.ps1
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
Expand All@@ -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
Expand Down
30 changes: 27 additions & 3 deletions scripts/install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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 \
Comment thread
shujaatTracebloc marked this conversation as resolved.
--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
Expand All@@ -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
Expand Down
70 changes: 68 additions & 2 deletions scripts/tests/install-bootstrap.bats
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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.
Expand Down
46 changes: 44 additions & 2 deletions scripts/tests/install.Tests.ps1
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'
Expand All@@ -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*"
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(installer): offline Sigstore bundle — verify --offline, no live Rekor (#584) by shujaatTracebloc · Pull Request #599 · tracebloc/client · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/release-helm-chart.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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*
Expand DownExpand Up@@ -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
Expand Down
94 changes: 60 additions & 34 deletions scripts/install.ps1
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
Expand All@@ -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
Expand Down
30 changes: 27 additions & 3 deletions scripts/install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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 \
Comment thread
shujaatTracebloc marked this conversation as resolved.
--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
Expand All@@ -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
Expand Down
70 changes: 68 additions & 2 deletions scripts/tests/install-bootstrap.bats
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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.
Expand Down
46 changes: 44 additions & 2 deletions scripts/tests/install.Tests.ps1
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'
Expand All@@ -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*"
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(installer): offline Sigstore bundle — verify --offline, no live Rekor (#584) by shujaatTracebloc · Pull Request #599 · tracebloc/client · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/release-helm-chart.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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*
Expand DownExpand Up@@ -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
Expand Down
94 changes: 60 additions & 34 deletions scripts/install.ps1
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
Expand All@@ -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
Expand Down
30 changes: 27 additions & 3 deletions scripts/install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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 \
Comment thread
shujaatTracebloc marked this conversation as resolved.
--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
Expand All@@ -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
Expand Down
70 changes: 68 additions & 2 deletions scripts/tests/install-bootstrap.bats
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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.
Expand Down
46 changes: 44 additions & 2 deletions scripts/tests/install.Tests.ps1
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'
Expand All@@ -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*"
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(installer): offline Sigstore bundle — verify --offline, no live Rekor (#584) by shujaatTracebloc · Pull Request #599 · tracebloc/client · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/release-helm-chart.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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*
Expand DownExpand Up@@ -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
Expand Down
94 changes: 60 additions & 34 deletions scripts/install.ps1
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
Expand All@@ -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
Expand Down
30 changes: 27 additions & 3 deletions scripts/install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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 \
Comment thread
shujaatTracebloc marked this conversation as resolved.
--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
Expand All@@ -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
Expand Down
70 changes: 68 additions & 2 deletions scripts/tests/install-bootstrap.bats
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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.
Expand Down
46 changes: 44 additions & 2 deletions scripts/tests/install.Tests.ps1
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'
Expand All@@ -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*"
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(installer): offline Sigstore bundle — verify --offline, no live Rekor (#584) by shujaatTracebloc · Pull Request #599 · tracebloc/client · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/release-helm-chart.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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*
Expand DownExpand Up@@ -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
Expand Down
94 changes: 60 additions & 34 deletions scripts/install.ps1
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
Expand All@@ -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
Expand Down
30 changes: 27 additions & 3 deletions scripts/install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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 \
Comment thread
shujaatTracebloc marked this conversation as resolved.
--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
Expand All@@ -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
Expand Down
70 changes: 68 additions & 2 deletions scripts/tests/install-bootstrap.bats
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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.
Expand Down
46 changes: 44 additions & 2 deletions scripts/tests/install.Tests.ps1
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'
Expand All@@ -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*"
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(installer): offline Sigstore bundle — verify --offline, no live Rekor (#584) by shujaatTracebloc · Pull Request #599 · tracebloc/client · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/release-helm-chart.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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*
Expand DownExpand Up@@ -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
Expand Down
94 changes: 60 additions & 34 deletions scripts/install.ps1
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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
Expand All@@ -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
Expand Down
30 changes: 27 additions & 3 deletions scripts/install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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 \
Comment thread
shujaatTracebloc marked this conversation as resolved.
--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
Expand All@@ -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
Expand Down
70 changes: 68 additions & 2 deletions scripts/tests/install-bootstrap.bats
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >> "$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.
Expand Down
46 changes: 44 additions & 2 deletions scripts/tests/install.Tests.ps1
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'
Expand All@@ -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*"
}
}
Loading