From 5c2eef7b874a8e7f04b9b4dc0562d1d2134b6489 Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:18:47 -0400 Subject: [PATCH] fix(provenance): fail closed + explicit attestation state; wire verify-image as a gate (SP-GATE-002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signer was fail-open ('never fails — always exit 0'): an unsigned artifact shipped provenance a consumer could mistake for attested, and the correct verify-image.sh was referenced by ZERO workflows. sign-and-provenance.sh: - ALWAYS records explicit attestation state in the OSImage provenance (provenance.attestation.{signed,reason}) so 'unsigned' is loud, not inferred from a missing signatureRef (feeds the downstream epistemicLevel, SP-GATE-003). - Fail closed: a signing key present but no artifact signed -> exit 4; and SOURCEOS_REQUIRE_SIGNATURE=1 + unsigned -> exit 4. No key + not required stays exit 0 (graceful dev), but with attestation.signed=false explicit. Teeth-verified all 3 modes. build-custom.yml: - The orphaned verify-image.sh is now a GATE: when a signing key is configured (vars.SOURCEOS_SIGN_PUBKEY set), every produced image MUST verify against the public key + in-toto subject digest or the build fails. Unsigned dev builds skip it. --- .github/workflows/build-custom.yml | 18 +++++++++++++++ scripts/sign-and-provenance.sh | 37 ++++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-custom.yml b/.github/workflows/build-custom.yml index 751ede9..2fb6bfe 100644 --- a/.github/workflows/build-custom.yml +++ b/.github/workflows/build-custom.yml @@ -75,6 +75,24 @@ jobs: bash scripts/build-custom-image.sh echo "artifact=$(cat out/artifact-url.txt)" >> "$GITHUB_OUTPUT" + # SP-GATE-002: the previously-orphaned verifier is now a GATE. When a signing + # key is configured, every produced image MUST verify against its public key + + # in-toto subject digest, or the build fails. (Unsigned dev builds — no pubkey + # configured — skip this; the signer still records attestation.signed=false.) + - name: Verify image signature + attestation + if: ${{ vars.SOURCEOS_SIGN_PUBKEY != '' }} + env: + SOURCEOS_SIGN_PUBKEY: ${{ vars.SOURCEOS_SIGN_PUBKEY }} + run: | + shopt -s nullglob + imgs=(out/*.iso out/kernel out/initrd) + [ ${#imgs[@]} -gt 0 ] || { echo "SP-GATE-002: a signing key is configured but no image artifacts to verify in out/" >&2; exit 1; } + for f in "${imgs[@]}"; do + [ -f "$f" ] || continue + echo "SP-GATE-002: verifying $(basename "$f")" + bash scripts/verify-image.sh "$f" "$SOURCEOS_SIGN_PUBKEY" + done + - name: Mark done if: success() run: | diff --git a/scripts/sign-and-provenance.sh b/scripts/sign-and-provenance.sh index 0da6642..b24cb35 100755 --- a/scripts/sign-and-provenance.sh +++ b/scripts/sign-and-provenance.sh @@ -13,9 +13,17 @@ # yourself with `minisign -W` (see docs/SIGNING_SETUP.md). Verification is # anyone-can-check with the public key. # -# Graceful by design: with no SOURCEOS_SIGN_SECRET_KEY the artifact is left -# unsigned but provenance is still emitted (signatureRef omitted). This script -# never fails the build — it always exits 0. +# Attestation state is ALWAYS explicit: the OSImage provenance carries +# `attestation.signed` (true/false) + a reason, so a consumer never has to infer +# "unsigned" from a missing signatureRef (SP-GATE-002 / SP-GATE-003). +# +# Fail-closed, not fail-open: +# * no SOURCEOS_SIGN_SECRET_KEY → unsigned, attestation.signed=false, exit 0 +# (graceful for local/dev builds) +# * key present but signing produced none → EXIT NON-ZERO (a key was there and we +# failed to sign — refusing to emit +# unsigned-but-attested provenance) +# * SOURCEOS_REQUIRE_SIGNATURE=1 + unsigned → EXIT NON-ZERO (release contexts mandate it) # # Env: # OUT output dir holding the artifact(s) (required) @@ -174,8 +182,14 @@ if [[ "$TARGET" == "iso" ]]; then SIG_REF="" [[ "$SIGNED" -eq 1 ]] && SIG_REF="$(ref_for "${PRIMARY_BASE}.minisig")" OSIMAGE_FILE="$OUT/${PRIMARY_BASE}.osimage.json" + # Explicit attestation state — a consumer reads attestation.signed, never infers + # it from a missing signatureRef. (Feeds the downstream epistemicLevel, SP-GATE-003.) + if [[ "$SIGNED" -eq 1 ]]; then SIGNED_BOOL=true; SIGN_REASON="minisign"; + elif [[ -n "${SOURCEOS_SIGN_SECRET_KEY:-}" ]]; then SIGNED_BOOL=false; SIGN_REASON="signing-failed"; + else SIGNED_BOOL=false; SIGN_REASON="no-signing-key"; fi prov="$(jq -n --arg s "$STATEMENT_URN" --arg p "$SLSA_URN" --arg sb "$SBOM_REF" --arg sg "$SIG_REF" \ - '{statementRef:$s, slsaPredicateRef:$p} + --argjson signed "$SIGNED_BOOL" --arg reason "$SIGN_REASON" \ + '{statementRef:$s, slsaPredicateRef:$p, attestation:{signed:$signed, reason:$reason}} + (if $sb != "" then {sbomRef:$sb} else {} end) + (if $sg != "" then {signatureRef:$sg} else {} end)')" jq -n \ @@ -219,4 +233,19 @@ if [[ "$TARGET" == "iso" ]]; then fi log "provenance complete (signed=$SIGNED) for $PRIMARY_BASE" + +# ── Fail-closed gate (SP-GATE-002) ───────────────────────────────────────────── +# The old behaviour ("never fails — always exit 0") let an unsigned artifact ship +# provenance that a downstream consumer could mistake for attested. Refuse that: +if [[ "$SIGNED" -ne 1 ]]; then + if [[ -n "${SOURCEOS_SIGN_SECRET_KEY:-}" ]]; then + log "FAIL-CLOSED: a signing key was provided but no artifact was signed (minisign failed)" + exit 4 + fi + if [[ "${SOURCEOS_REQUIRE_SIGNATURE:-0}" = "1" ]]; then + log "FAIL-CLOSED: SOURCEOS_REQUIRE_SIGNATURE=1 but the artifact is unsigned" + exit 4 + fi + log "unsigned by design (no key, not required) — attestation.signed=false is explicit in the provenance" +fi exit 0