From ef356eccea955328b2e00c1fdccce39093f47623 Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Thu, 1 Oct 2026 15:00:37 -0400 Subject: [PATCH] fix: sign release checksums with a sigstore bundle (#5362) * fix: sign release checksums with a sigstore bundle The cosign update rejects the legacy `--output-certificate` / `--output-signature` flags alongside the new bundle format, which broke signing during the v1.53.0 release. - checksums are now signed into a single `checksums.txt.sigstore.json` bundle (signature, certificate, and tlog proof) - `install.sh -v` verifies with `--bundle` for releases >= v1.53.0 and keeps the `.sig` / `.pem` flow for older releases Signed-off-by: Alex Goodman * chore: smooth the cutover to sigstore bundle verification - `install.sh -v` now hints that bundle verification needs cosign v2.5.0 or newer when verification of a bundle-signed release fails - install tests keep exercising the legacy `.sig` / `.pem` path against v1.52.0 once the latest release is bundle-signed - touch the install test environment Dockerfiles so the cached images rebuild with a current cosign Signed-off-by: Alex Goodman * fix: only hint at the cosign minimum when cosign is actually too old - `install.sh -v` used to print the cosign v2.5.0 note on any failed bundle verification, including a missing bundle or a bad signature. It now only prints it when cosign reports it couldn't read the bundle (`bundle does not contain cert`), which is how v2.2.4 through v2.4.1 fail on a sigstore bundle. - `verify_sign` is now covered with a stub cosign, so the bundle path is exercised before any bundle-signed release is latest Signed-off-by: Alex Goodman * fix: stop the install when a release asset is missing or fails its checksum - `install.sh` now stops when the checksums file, the signature bundle, or the asset can't be downloaded, instead of handing an empty or error-page file to the next step - a sha256 mismatch between the asset and the checksums file now stops the install. Before this the mismatch was logged and the asset was installed anyway, so `-v` only ever vouched for `checksums.txt`, not the binary Signed-off-by: Alex Goodman * chore: pin cosign in the install test images The install test images pulled whatever cosign was latest, unverified. They now install cosign v3.1.3 checked against its published sha256, so the install tests always run with a cosign that can verify sigstore bundles (releases >= v1.53.0 need v2.5.0 or newer). Signed-off-by: Alex Goodman --------- Signed-off-by: Alex Goodman Co-authored-by: Keith Zantow --- .goreleaser.yaml | 9 +- install.sh | 58 +++++++++---- test/install/2_download_release_asset_test.sh | 6 ++ .../4_prep_signature_verification_test.sh | 58 +++++++++++++ .../install/5_download_asset_failures_test.sh | 84 +++++++++++++++++++ .../environments/Dockerfile-alpine-3.6 | 8 +- .../environments/Dockerfile-busybox-1.36 | 15 ++-- .../environments/Dockerfile-ubuntu-20.04 | 10 ++- test/install/test_harness.sh | 25 ++++++ 9 files changed, 241 insertions(+), 32 deletions(-) create mode 100755 test/install/5_download_asset_failures_test.sh diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d5bb7de18..a06e04762 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -167,16 +167,13 @@ sboms: - "--output" - "json=$document" +# the signature, certificate, and transparency log proof all land in a single sigstore bundle. signs: - cmd: .tool/cosign - signature: "${artifact}.sig" - certificate: "${artifact}.pem" + signature: "${artifact}.sigstore.json" args: - "sign-blob" - - "--use-signing-config=false" - - "--oidc-issuer=https://token.actions.githubusercontent.com" - - "--output-certificate=${certificate}" - - "--output-signature=${signature}" + - "--bundle=${signature}" - "${artifact}" - "--yes" artifacts: checksum diff --git a/install.sh b/install.sh index bf9222d06..7fb08d0d4 100755 --- a/install.sh +++ b/install.sh @@ -19,6 +19,8 @@ VERIFY_SIGN=false VERIFY_SIGN_SUPPORTED_VERSION=v0.104.0 # this is the earliest tag in the repo where the -v flag was introduced to this install.sh script VERIFY_SIGN_FLAG_VERSION=v1.6.0 +# releases at or after this version are signed with a sigstore bundle (.sigstore.json); earlier releases ship a separate .sig and .pem +VERIFY_SIGN_BUNDLE_VERSION=v1.53.0 # do not change the name of this parameter (this must always be backwards compatible) DOWNLOAD_TAG_INSTALL_SCRIPT=${DOWNLOAD_TAG_INSTALL_SCRIPT:-true} @@ -386,8 +388,8 @@ download_github_release_checksums_files() ( complete_url=$(github_release_asset_url "${download_url}" "${name}" "${version}" "${filename}") output_path="${output_dir}/${complete_filename}" - http_download "${output_path}" "${complete_url}" "" - asset_file_exists "${output_path}" + http_download "${output_path}" "${complete_url}" "" || return 1 + asset_file_exists "${output_path}" || return 1 log_trace "download_github_release_checksums_files() returned '${output_path}' for file '${complete_filename}'" @@ -585,28 +587,31 @@ download_and_install_asset() ( install_asset "${asset_filepath}" "${install_path}" "${binary}" ) -# verify_sign [checksums-file-path] [certificate-reference] [signature-reference] [version] +# verify_sign [checksums-file-path] [verification-material-flags...] # # attempts verify the signature of the checksums file from the release workflow in Github Actions run against the main branch. +# the verification material is either "--bundle " or (for older releases) "--certificate --signature ". # verify_sign() { checksums_file=$1 - cert_reference=$2 - sig_reference=$3 + shift - log_trace "verifying artifact $1" + log_trace "verifying artifact ${checksums_file}" log_file=$(mktemp) ${COSIGN_BINARY} \ verify-blob "$checksums_file" \ - --certificate "$cert_reference" \ - --signature "$sig_reference" \ + "$@" \ --certificate-identity "https://github.com/${OWNER}/${REPO}/.github/workflows/release.yaml@refs/heads/main" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" > "${log_file}" 2>&1 if [ $? -ne 0 ]; then log_err "$(cat "${log_file}")" + # cosign older than v2.4.2 can't read the bundle's verification material and reports a missing cert + if [ "$1" = "--bundle" ] && grep -q "bundle does not contain cert" "${log_file}"; then + log_err "note: releases >= ${VERIFY_SIGN_BUNDLE_VERSION} are verified with a sigstore bundle, which requires cosign v2.5.0 or newer (check '${COSIGN_BINARY} version')" + fi rm -f "${log_file}" return 1 fi @@ -630,7 +635,10 @@ download_asset() ( log_trace "download_asset(url=${download_url}, destination=${destination}, name=${name}, os=${os}, arch=${arch}, version=${version}, format=${format})" - checksums_filepath=$(download_github_release_checksums "${download_url}" "${name}" "${version}" "${destination}") + if ! checksums_filepath=$(download_github_release_checksums "${download_url}" "${name}" "${version}" "${destination}"); then + log_err "unable to download checksums file" + return 1 + fi log_trace "checksums content:\n$(cat ${checksums_filepath})" @@ -642,13 +650,25 @@ download_asset() ( fi if [ "$VERIFY_SIGN" = true ]; then - checksum_sig_file_url=$(github_release_checksums_sig_url "${download_url}" "${name}" "${version}") - log_trace "checksums signature url: ${checksum_sig_file_url}" + if compare_semver "${version}" "${VERIFY_SIGN_BUNDLE_VERSION}"; then + if ! checksums_bundle_filepath=$(download_github_release_checksums_files "${download_url}" "${name}" "${version}" "${destination}" "checksums.txt.sigstore.json"); then + log_err "unable to download checksums signature bundle" + return 1 + fi + log_trace "checksums bundle: ${checksums_bundle_filepath}" - checksums_cert_file_url=$(github_release_checksums_cert_url "${download_url}" "${name}" "${version}") - log_trace "checksums certificate url: ${checksums_cert_file_url}" + set -- --bundle "${checksums_bundle_filepath}" + else + checksum_sig_file_url=$(github_release_checksums_sig_url "${download_url}" "${name}" "${version}") + log_trace "checksums signature url: ${checksum_sig_file_url}" - if ! verify_sign "${checksums_filepath}" "${checksums_cert_file_url}" "${checksum_sig_file_url}"; then + checksums_cert_file_url=$(github_release_checksums_cert_url "${download_url}" "${name}" "${version}") + log_trace "checksums certificate url: ${checksums_cert_file_url}" + + set -- --certificate "${checksums_cert_file_url}" --signature "${checksum_sig_file_url}" + fi + + if ! verify_sign "${checksums_filepath}" "$@"; then log_err "signature verification failed" return 1 fi @@ -657,9 +677,15 @@ download_asset() ( asset_url="${download_url}/${asset_filename}" asset_filepath="${destination}/${asset_filename}" - http_download "${asset_filepath}" "${asset_url}" "" + if ! http_download "${asset_filepath}" "${asset_url}" ""; then + log_err "unable to download asset '${asset_url}'" + return 1 + fi - hash_sha256_verify "${asset_filepath}" "${checksums_filepath}" + # this is what ties the asset to the (optionally signature verified) checksums file, so a mismatch must stop the install + if ! hash_sha256_verify "${asset_filepath}" "${checksums_filepath}"; then + return 1 + fi log_trace "download_asset_by_checksums_file() returned '${asset_filepath}'" diff --git a/test/install/2_download_release_asset_test.sh b/test/install/2_download_release_asset_test.sh index 003477149..4ea173e15 100755 --- a/test/install/2_download_release_asset_test.sh +++ b/test/install/2_download_release_asset_test.sh @@ -43,3 +43,9 @@ run_test_case test_download_release_asset "${release}" "linux" "amd64" "deb" "ap run_test_case test_download_release_asset "${release}" "linux" "arm64" "tar.gz" "application/gzip" run_test_case test_download_release_asset "${release}" "linux" "arm64" "rpm" "application/x-rpm" run_test_case test_download_release_asset "${release}" "linux" "arm64" "deb" "application/vnd.debian.binary-package" + +# releases before VERIFY_SIGN_BUNDLE_VERSION are signed with a separate .sig and .pem instead of a sigstore bundle, +# so keep exercising that path against a fixed release +legacy_release="v1.52.0" + +run_test_case test_download_release_asset "${legacy_release}" "linux" "amd64" "tar.gz" "application/gzip" diff --git a/test/install/4_prep_signature_verification_test.sh b/test/install/4_prep_signature_verification_test.sh index 401364ac0..545aaf4b9 100755 --- a/test/install/4_prep_signature_verification_test.sh +++ b/test/install/4_prep_signature_verification_test.sh @@ -87,3 +87,61 @@ test_prep_signature_verification() { } run_test_case test_prep_signature_verification + +# ensure the verification material is passed through to cosign and the old-cosign hint only shows when warranted +test_verify_sign() { + # verify_sign [checksums-file-path] [verification-material-flags...] + + OG_COSIGN_BINARY=${COSIGN_BINARY} + + tmpdir=$(mktemp -d) + args_file="${tmpdir}/args" + hint="requires cosign v2.5.0 or newer" + + # stub cosign that records its arguments, then prints STUB_OUTPUT and exits with STUB_EXIT + COSIGN_BINARY="${tmpdir}/cosign" + cat > "${COSIGN_BINARY}" < "${args_file}" +echo "\${STUB_OUTPUT}" +exit "\${STUB_EXIT}" +STUB + chmod +x "${COSIGN_BINARY}" + + identity="--certificate-identity https://github.com/${OWNER}/${REPO}/.github/workflows/release.yaml@refs/heads/main --certificate-oidc-issuer https://token.actions.githubusercontent.com" + + # bundle verification succeeds + STUB_OUTPUT="Verified OK" STUB_EXIT=0 verify_sign "checksums.txt" --bundle "checksums.txt.sigstore.json" >/dev/null 2>&1 + assertEquals "0" "$?" "bundle verification should succeed" + assertEquals "verify-blob checksums.txt --bundle checksums.txt.sigstore.json ${identity}" "$(cat "${args_file}")" "unexpected cosign args for bundle" + + # legacy verification succeeds + STUB_OUTPUT="Verified OK" STUB_EXIT=0 verify_sign "checksums.txt" --certificate "c.pem" --signature "c.sig" >/dev/null 2>&1 + assertEquals "0" "$?" "legacy verification should succeed" + assertEquals "verify-blob checksums.txt --certificate c.pem --signature c.sig ${identity}" "$(cat "${args_file}")" "unexpected cosign args for legacy" + + # cosign too old to read the bundle: fail with the hint (this is what cosign v2.2.4 through v2.4.1 print) + output=$(STUB_OUTPUT="Error: bundle does not contain cert for verification, please provide public key" STUB_EXIT=1 verify_sign "checksums.txt" --bundle "b.json" 2>&1) + assertEquals "1" "$?" "old cosign should fail verification" + assertContains "${output}" "${hint}" "old cosign should get the version hint" + + # bad signature (e.g. tampered checksums): fail without the hint + output=$(STUB_OUTPUT="Error: invalid signature when validating ASN.1 encoded signature" STUB_EXIT=1 verify_sign "checksums.txt" --bundle "b.json" 2>&1) + assertEquals "1" "$?" "bad signature should fail verification" + assertNotContains "${output}" "${hint}" "bad signature should not get the version hint" + + # unparseable bundle (e.g. a 404 body): fail without the hint + output=$(STUB_OUTPUT="Error: invalid character 'N' looking for beginning of value" STUB_EXIT=1 verify_sign "checksums.txt" --bundle "b.json" 2>&1) + assertEquals "1" "$?" "unparseable bundle should fail verification" + assertNotContains "${output}" "${hint}" "unparseable bundle should not get the version hint" + + # legacy failure never gets the bundle hint, even with the same output + output=$(STUB_OUTPUT="Error: bundle does not contain cert for verification, please provide public key" STUB_EXIT=1 verify_sign "checksums.txt" --certificate "c.pem" --signature "c.sig" 2>&1) + assertEquals "1" "$?" "legacy failure should fail verification" + assertNotContains "${output}" "${hint}" "legacy failure should not get the version hint" + + COSIGN_BINARY=${OG_COSIGN_BINARY} + rm -rf -- "${tmpdir}" +} + +run_test_case test_verify_sign diff --git a/test/install/5_download_asset_failures_test.sh b/test/install/5_download_asset_failures_test.sh new file mode 100755 index 000000000..edc51234f --- /dev/null +++ b/test/install/5_download_asset_failures_test.sh @@ -0,0 +1,84 @@ +. test_harness.sh + +# serve release assets from a local fixture dir instead of the network (a missing file is a failed download) +http_download() { + src="${FIXTURE_DIR}/${2##*/}" + [ -f "${src}" ] || return 1 + cp "${src}" "$1" +} + +# ensure every missing or mismatched piece of a release stops download_asset before anything is installed +test_download_asset_failures() { + # download_asset [release-url-prefix] [download-path] [name] [os] [arch] [version] [format] + + # we are expecting error messages, which is confusing to look at in passing tests... disable logging for now + log_set_priority -1 + + OG_COSIGN_BINARY=${COSIGN_BINARY} + + tmpdir=$(mktemp -d) + FIXTURE_DIR="${tmpdir}/release" + dest="${tmpdir}/dest" + args_file="${tmpdir}/cosign-args" + mkdir -p "${FIXTURE_DIR}" "${dest}" + + # stub cosign that records it was called and always verifies + COSIGN_BINARY="${tmpdir}/cosign" + printf '#!/bin/sh\necho "$@" > "%s"\n' "${args_file}" > "${COSIGN_BINARY}" + chmod +x "${COSIGN_BINARY}" + + asset="syft_1.54.0_linux_amd64.tar.gz" + echo "the real asset" > "${FIXTURE_DIR}/${asset}" + echo "$(hash_sha256 "${FIXTURE_DIR}/${asset}") ${asset}" > "${FIXTURE_DIR}/syft_1.54.0_checksums.txt" + echo "{}" > "${FIXTURE_DIR}/syft_1.54.0_checksums.txt.sigstore.json" + + run() { + rm -rf "${dest:?}"/* "${args_file}" + download_asset "https://example.invalid/v1.54.0" "${dest}" "syft" "linux" "amd64" "1.54.0" "tar.gz" + } + + # happy path, verified and unverified + VERIFY_SIGN=false + asset_path=$(run) + assertEquals "0" "$?" "complete release should download" + assertEquals "${dest}/${asset}" "${asset_path}" "unexpected asset path" + + VERIFY_SIGN=true + run >/dev/null + assertEquals "0" "$?" "complete release should verify" + assertContains "$(cat "${args_file}")" "--bundle ${dest}/syft_1.54.0_checksums.txt.sigstore.json" "bundle should be passed to cosign" + + # missing bundle: fail before cosign is ever called + mv "${FIXTURE_DIR}/syft_1.54.0_checksums.txt.sigstore.json" "${tmpdir}/bundle" + run >/dev/null + assertEquals "1" "$?" "missing bundle should fail" + assertFilesDoesNotExist "${args_file}" "cosign should not be called without a bundle" + mv "${tmpdir}/bundle" "${FIXTURE_DIR}/syft_1.54.0_checksums.txt.sigstore.json" + + for VERIFY_SIGN in true false; do + # asset does not match the checksums file + echo "a tampered asset" > "${FIXTURE_DIR}/${asset}" + run >/dev/null + assertEquals "1" "$?" "checksum mismatch should fail (verify=${VERIFY_SIGN})" + echo "the real asset" > "${FIXTURE_DIR}/${asset}" + + # missing asset + mv "${FIXTURE_DIR}/${asset}" "${tmpdir}/asset" + run >/dev/null + assertEquals "1" "$?" "missing asset should fail (verify=${VERIFY_SIGN})" + mv "${tmpdir}/asset" "${FIXTURE_DIR}/${asset}" + + # missing checksums file + mv "${FIXTURE_DIR}/syft_1.54.0_checksums.txt" "${tmpdir}/checksums" + run >/dev/null + assertEquals "1" "$?" "missing checksums should fail (verify=${VERIFY_SIGN})" + mv "${tmpdir}/checksums" "${FIXTURE_DIR}/syft_1.54.0_checksums.txt" + done + + COSIGN_BINARY=${OG_COSIGN_BINARY} + rm -rf -- "${tmpdir}" + + log_set_priority 0 +} + +run_test_case test_download_asset_failures diff --git a/test/install/environments/Dockerfile-alpine-3.6 b/test/install/environments/Dockerfile-alpine-3.6 index 69df678a6..31cfbae30 100644 --- a/test/install/environments/Dockerfile-alpine-3.6 +++ b/test/install/environments/Dockerfile-alpine-3.6 @@ -1,5 +1,9 @@ FROM alpine:3.6 RUN apk update && apk add python3 wget curl unzip make ca-certificates jq -RUN curl -O -L "https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64" && \ - mv cosign-linux-amd64 /usr/local/bin/cosign && \ +# cosign is pinned (and checksum verified) so the install tests always run with a cosign that can verify +# sigstore bundles (releases >= v1.53.0 need v2.5.0 or newer) +ARG COSIGN_VERSION=v3.1.3 +ARG COSIGN_SHA256=4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 +RUN curl -sSfL -o /usr/local/bin/cosign "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" && \ + echo "${COSIGN_SHA256} /usr/local/bin/cosign" | sha256sum -c - && \ chmod +x /usr/local/bin/cosign diff --git a/test/install/environments/Dockerfile-busybox-1.36 b/test/install/environments/Dockerfile-busybox-1.36 index e2cf2d722..2f9747f90 100644 --- a/test/install/environments/Dockerfile-busybox-1.36 +++ b/test/install/environments/Dockerfile-busybox-1.36 @@ -5,17 +5,22 @@ RUN apk update && apk add ca-certificates # so let docker pull the image that matches the hosts architecture first and then pull the correct asset FROM busybox:1.36.1-musl +# cosign is pinned (and checksum verified) so the install tests always run with a cosign that can verify +# sigstore bundles (releases >= v1.53.0 need v2.5.0 or newer) +ARG COSIGN_VERSION=v3.1.3 +ARG COSIGN_SHA256_AMD64=4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 +ARG COSIGN_SHA256_ARM64=c5d324e091826b0d7a78eb16fef316450b4eb9aaec045611c08ba06f5e73220a RUN ARCH=$(uname -m) && \ if [ "$ARCH" = "x86_64" ]; then \ - COSIGN_ARCH="amd64"; \ + COSIGN_ARCH="amd64"; COSIGN_SHA256="${COSIGN_SHA256_AMD64}"; \ elif [ "$ARCH" = "aarch64" ]; then \ - COSIGN_ARCH="arm64"; \ + COSIGN_ARCH="arm64"; COSIGN_SHA256="${COSIGN_SHA256_ARM64}"; \ else \ echo "Unsupported architecture: $ARCH" && exit 1; \ fi && \ - echo "Downloading cosign for $COSIGN_ARCH" && \ - wget https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-${COSIGN_ARCH} && \ - mv cosign-linux-${COSIGN_ARCH} /bin/cosign && \ + echo "Downloading cosign ${COSIGN_VERSION} for $COSIGN_ARCH" && \ + wget -O /bin/cosign https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-${COSIGN_ARCH} && \ + echo "${COSIGN_SHA256} /bin/cosign" | sha256sum -c - && \ chmod +x /bin/cosign COPY --from=certs /etc/ssl/certs /etc/ssl/certs diff --git a/test/install/environments/Dockerfile-ubuntu-20.04 b/test/install/environments/Dockerfile-ubuntu-20.04 index 87c09e66f..967610232 100644 --- a/test/install/environments/Dockerfile-ubuntu-20.04 +++ b/test/install/environments/Dockerfile-ubuntu-20.04 @@ -1,5 +1,9 @@ FROM --platform=linux/amd64 ubuntu:20.04@sha256:33a5cc25d22c45900796a1aca487ad7a7cb09f09ea00b779e3b2026b4fc2faba RUN apt update -y && apt install make python3 curl unzip jq -y -RUN LATEST_VERSION=$(curl https://api.github.com/repos/sigstore/cosign/releases/latest | grep tag_name | cut -d : -f2 | tr -d "v\", ") && \ - curl -O -L "https://github.com/sigstore/cosign/releases/latest/download/cosign_${LATEST_VERSION}_amd64.deb" && \ - dpkg -i cosign_${LATEST_VERSION}_amd64.deb +# cosign is pinned (and checksum verified) so the install tests always run with a cosign that can verify +# sigstore bundles (releases >= v1.53.0 need v2.5.0 or newer) +ARG COSIGN_VERSION=v3.1.3 +ARG COSIGN_SHA256=4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 +RUN curl -sSfL -o /usr/local/bin/cosign "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" && \ + echo "${COSIGN_SHA256} /usr/local/bin/cosign" | sha256sum -c - && \ + chmod +x /usr/local/bin/cosign diff --git a/test/install/test_harness.sh b/test/install/test_harness.sh index 8db34de36..e38b8b092 100644 --- a/test/install/test_harness.sh +++ b/test/install/test_harness.sh @@ -56,6 +56,31 @@ assertFileExists() { fi } +assertContains() { + haystack=$1 + needle=$2 + msg=$3 + case "$haystack" in + *"$needle"*) ;; + *) + echo "assertContains failed: '$needle' not found in '$haystack' $msg" + exit 2 + ;; + esac +} + +assertNotContains() { + haystack=$1 + needle=$2 + msg=$3 + case "$haystack" in + *"$needle"*) + echo "assertNotContains failed: '$needle' found in '$haystack' $msg" + exit 2 + ;; + esac +} + assertFilesEqual() { want=$1 got=$2