diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index c851718f0..30c901194 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -95,6 +95,64 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) { } } +func TestReleaseValidationRequiresSignedSidecars(t *testing.T) { + localValidatorBytes, err := os.ReadFile(repoFile("scripts", "validate-release.sh")) + if err != nil { + t.Fatalf("read validate-release.sh: %v", err) + } + localValidator := string(localValidatorBytes) + localRequired := []string{ + `info "Validating SSH signature sidecars..."`, + `if [ ! -s "checksums.txt.sshsig" ]; then`, + `error "Missing or empty checksums.txt.sshsig"`, + `if [ ! -s "${filename}.sshsig" ]; then`, + `error "Missing or empty ${filename}.sshsig"`, + `success "SSH signature sidecars validated"`, + } + for _, needle := range localRequired { + if !strings.Contains(localValidator, needle) { + t.Fatalf("validate-release.sh missing signed sidecar validation: %s", needle) + } + } + + publishedValidatorBytes, err := os.ReadFile(repoFile("scripts", "validate-published-release.sh")) + if err != nil { + t.Fatalf("read validate-published-release.sh: %v", err) + } + publishedValidator := string(publishedValidatorBytes) + publishedRequired := []string{ + `CHECKSUMS_SIG_PATH="${TMP_DIR}/checksums.txt.sshsig"`, + `"${BASE_URL}/checksums.txt.sshsig"`, + `echo "Failed to download checksums.txt.sshsig for ${TAG}" >&2`, + `sshsig_path="${TMP_DIR}/${filename}.sshsig"`, + `"${artifact_url}.sshsig"`, + `echo "Failed to download ${filename}.sshsig" >&2`, + `Published release assets for ${TAG} match checksums.txt, *.sha256 files, and required *.sshsig sidecars.`, + } + for _, needle := range publishedRequired { + if !strings.Contains(publishedValidator, needle) { + t.Fatalf("validate-published-release.sh missing signed sidecar validation: %s", needle) + } + } + + contractBytes, err := os.ReadFile(repoFile("docs", "release-control", "v6", "internal", "subsystems", "deployment-installability.md")) + if err != nil { + t.Fatalf("read deployment-installability contract: %v", err) + } + contract := string(contractBytes) + contractRequired := []string{ + "`scripts/validate-release.sh`, and", + "`scripts/validate-published-release.sh` must derive the embedded update trust", + "fail validation if any", + "published artifact or `checksums.txt` is missing its `.sshsig` sidecar", + } + for _, needle := range contractRequired { + if !strings.Contains(contract, needle) { + t.Fatalf("deployment-installability contract missing signed sidecar validation requirement: %s", needle) + } + } +} + func TestDockerAndDemoBuildsUseCanonicalReleaseLdflags(t *testing.T) { dockerfileBytes, err := os.ReadFile(repoFile("Dockerfile")) if err != nil { diff --git a/scripts/validate-published-release.sh b/scripts/validate-published-release.sh index 06cbdcb37..b97ac041d 100755 --- a/scripts/validate-published-release.sh +++ b/scripts/validate-published-release.sh @@ -2,9 +2,10 @@ # Remote release validator. # Downloads the published (or draft) assets straight from GitHub Releases, -# recalculates their SHA256 sums, and ensures checksums.txt and the *.sha256 -# helper files match what is actually live. This prevents broken updates when -# artifacts are re-uploaded without regenerating checksums (see issue #698). +# recalculates their SHA256 sums, and ensures checksums.txt, the *.sha256 +# helper files, and the required *.sshsig sidecars match the live release +# packet. This prevents broken updates when artifacts are re-uploaded without +# regenerating checksums or their pinned signature sidecars (see issue #698). set -euo pipefail @@ -29,6 +30,17 @@ if ! "${curl_args[@]}" "${BASE_URL}/checksums.txt" >"$CHECKSUMS_PATH"; then exit 1 fi +CHECKSUMS_SIG_PATH="${TMP_DIR}/checksums.txt.sshsig" +echo "Downloading ${BASE_URL}/checksums.txt.sshsig" +if ! "${curl_args[@]}" "${BASE_URL}/checksums.txt.sshsig" >"$CHECKSUMS_SIG_PATH"; then + echo "Failed to download checksums.txt.sshsig for ${TAG}" >&2 + exit 1 +fi +if [[ ! -s "$CHECKSUMS_SIG_PATH" ]]; then + echo "checksums.txt.sshsig is empty for ${TAG}" >&2 + exit 1 +fi + status=0 while read -r checksum filename _; do @@ -66,6 +78,17 @@ while read -r checksum filename _; do echo "${filename}.sha256 content mismatch (expected '${expected_line}', got '${sha_content}')" >&2 status=$((status + 1)) fi + + sshsig_path="${TMP_DIR}/${filename}.sshsig" + if ! "${curl_args[@]}" "${artifact_url}.sshsig" >"$sshsig_path"; then + echo "Failed to download ${filename}.sshsig" >&2 + status=$((status + 1)) + continue + fi + if [[ ! -s "$sshsig_path" ]]; then + echo "${filename}.sshsig is empty" >&2 + status=$((status + 1)) + fi done < "$CHECKSUMS_PATH" if [[ "$status" -ne 0 ]]; then @@ -73,4 +96,4 @@ if [[ "$status" -ne 0 ]]; then exit 1 fi -echo "Published release assets for ${TAG} match checksums.txt and *.sha256 files." +echo "Published release assets for ${TAG} match checksums.txt, *.sha256 files, and required *.sshsig sidecars." diff --git a/scripts/validate-release.sh b/scripts/validate-release.sh index 8647206d4..7615ae587 100755 --- a/scripts/validate-release.sh +++ b/scripts/validate-release.sh @@ -364,12 +364,36 @@ info "Validating checksums..." sha256sum -c checksums.txt >/dev/null 2>&1 || { error "checksums.txt validation failed"; exit 1; } success "checksums.txt validated" +# Validate release signature sidecars +info "Validating SSH signature sidecars..." +if [ ! -s "checksums.txt.sshsig" ]; then + error "Missing or empty checksums.txt.sshsig" + exit 1 +fi + +while IFS= read -r line; do + checksum=$(echo "$line" | awk '{print $1}') + filename=$(echo "$line" | awk '{print $2}') + + [ -n "$checksum" ] || continue + [ -n "$filename" ] || continue + + if [ ! -s "${filename}.sshsig" ]; then + error "Missing or empty ${filename}.sshsig" + exit 1 + fi +done < checksums.txt +success "SSH signature sidecars validated" + # Validate individual .sha256 files exist and match checksums.txt info "Validating individual .sha256 files..." while IFS= read -r line; do checksum=$(echo "$line" | awk '{print $1}') filename=$(echo "$line" | awk '{print $2}') + [ -n "$checksum" ] || continue + [ -n "$filename" ] || continue + # Check .sha256 file exists if [ ! -f "${filename}.sha256" ]; then error "Missing ${filename}.sha256"