diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 5b92f2e..ce72077 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -38,3 +38,10 @@ jobs: - name: Run module preflight validator run: python3 validate.py + + # build.sh's --sign path only ever executes in the release/republish + # workflows, so a mistake in the manifest-digest parser would sign the + # wrong object in production with nothing to catch it. These tests need + # no registry and no cosign. + - name: Test build.sh signing preconditions + run: ./test-build-sh.sh diff --git a/build.sh b/build.sh index c392c5b..0ebf29c 100755 --- a/build.sh +++ b/build.sh @@ -13,6 +13,10 @@ # # Required: oras >= 1.2.0 (https://oras.land/docs/installation) # For --sign: cosign >= 2.0 (https://docs.sigstore.dev/cosign/installation) +# Signing stays on cosign's classic signature format +# (--new-bundle-format=false --use-signing-config=false) because that is what +# the operator's cosign/v2 verifier reads; the newer Rekor v2 flow also needs +# a timestamp authority that keyed signing cannot satisfy. set -euo pipefail @@ -32,22 +36,39 @@ Flags: --plain-http Use plain HTTP (for local kind registries) --insecure Skip TLS verification --tag-latest Also tag :latest in addition to module.yaml's version - --sign cosign-sign each pushed bundle (keyed, offline; no Rekor) + --sign cosign-sign each pushed bundle (keyed; offline by default) + --tlog-upload With --sign, also record each signature in the public + Rekor transparency log (for official published bundles) Reads each modules//module.yaml for the version. The bundle is pushed to /:. With --sign, set COSIGN_PRIVATE_KEY to the PEM private key (and -COSIGN_PASSWORD if it is encrypted); each bundle is signed by digest with -\`cosign sign --tlog-upload=false\`, which the operator verifies offline via -ModuleSource.spec.verify.key. See docs/module-authoring.md. +COSIGN_PASSWORD if it is encrypted); each bundle is signed by its manifest +digest. By default signing is offline (\`--tlog-upload=false\`), which the +operator verifies offline via ModuleSource.spec.verify.key — that keeps +local/air-gapped authoring and the plain-HTTP e2e working without Sigstore +connectivity. Add --tlog-upload for official bundles so the signature is +publicly logged. See docs/module-authoring.md. USAGE } +# parse_manifest_digest reads `oras push` output on stdin and prints the pushed +# MANIFEST digest, anchoring on oras's authoritative "Digest: sha256:…" summary +# line. Scanning the whole output for the first sha256 token would risk picking +# up a LAYER digest and signing the wrong object. Kept as a function (and +# reachable via the internal `_parse-digest` command) so it can be regression +# tested without a registry — see test-build-sh.sh. +parse_manifest_digest() { + sed -n 's/^[[:space:]]*Digest:[[:space:]]*\(sha256:[0-9a-f]\{64\}\).*/\1/p' | head -n1 +} + cmd="${1:-}" shift || true case "$cmd" in push) ;; + # Internal, for tests: read oras output on stdin, print the manifest digest. + _parse-digest) parse_manifest_digest; exit 0 ;; ""|-h|--help) usage; exit 0 ;; *) echo "unknown command: $cmd" >&2; usage; exit 2 ;; esac @@ -58,6 +79,7 @@ PLAIN_HTTP="" INSECURE="" TAG_LATEST=0 SIGN=0 +TLOG_UPLOAD=0 while [[ $# -gt 0 ]]; do case "$1" in @@ -67,6 +89,7 @@ while [[ $# -gt 0 ]]; do --insecure) INSECURE=1; shift ;; --tag-latest) TAG_LATEST=1; shift ;; --sign) SIGN=1; shift ;; + --tlog-upload) TLOG_UPLOAD=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "unknown flag: $1" >&2; usage; exit 2 ;; esac @@ -77,6 +100,8 @@ command -v oras >/dev/null || { echo "oras not in PATH" >&2; exit 2; } if (( SIGN )); then command -v cosign >/dev/null || { echo "cosign not in PATH (required for --sign)" >&2; exit 2; } [[ -n "${COSIGN_PRIVATE_KEY:-}" ]] || { echo "--sign requires COSIGN_PRIVATE_KEY (PEM private key) in the environment" >&2; exit 2; } +elif (( TLOG_UPLOAD )); then + echo "--tlog-upload has no effect without --sign" >&2; exit 2 fi # Resolve modules dir from the script's location, so callers can run @@ -132,13 +157,27 @@ push_one() { if (( SIGN )); then local digest - digest="$(printf '%s\n' "$out" | grep -oE 'sha256:[0-9a-f]{64}' | head -n1)" - [[ -n "$digest" ]] || { echo "$name: could not parse pushed digest from oras output" >&2; return 1; } + digest="$(printf '%s\n' "$out" | parse_manifest_digest)" + [[ -n "$digest" ]] || { echo "$name: could not parse pushed manifest digest from oras output" >&2; return 1; } echo ">> signing $REGISTRY/$name@$digest" - # Keyed + offline: no transparency-log upload, matching the operator's - # offline keyed verify path (IgnoreTlog/Offline). cosign reads the key - # from $COSIGN_PRIVATE_KEY and the passphrase from $COSIGN_PASSWORD. - local sargs=( sign --key env://COSIGN_PRIVATE_KEY --tlog-upload=false --yes ) + # cosign reads the key from $COSIGN_PRIVATE_KEY and the passphrase from + # $COSIGN_PASSWORD. Two modes: + # default keyed + offline (--tlog-upload=false), matching the + # operator's offline keyed verify path (IgnoreTlog/Offline) + # — this is what local authors and the plain-HTTP e2e use. + # --tlog-upload also record the signature in the public Rekor + # transparency log, for official published bundles. + # --new-bundle-format=false --use-signing-config=false keep cosign on the + # classic signature/hashedrekord path in both modes: it is the format the + # operator's cosign/v2 verifier reads, and cosign's newer Rekor v2 flow + # additionally requires a TSA that keyed signing cannot satisfy. + local sargs=( sign --key env://COSIGN_PRIVATE_KEY --yes + --new-bundle-format=false --use-signing-config=false ) + if (( TLOG_UPLOAD )); then + echo ">> (recording signature in the public Rekor transparency log)" + else + sargs+=( --tlog-upload=false ) + fi # --plain-http needs --allow-http-registry (force HTTP); --allow-insecure- # registry only skips TLS verification on an HTTPS connection, so against a # plain-HTTP registry it errors "server gave HTTP response to HTTPS client" diff --git a/test-build-sh.sh b/test-build-sh.sh new file mode 100755 index 0000000..ea7ceb5 --- /dev/null +++ b/test-build-sh.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# test-build-sh.sh — regression tests for build.sh's signing preconditions. +# +# Focus: the manifest-digest parser. `--sign` signs whatever digest this parser +# returns, and that code path only ever runs in the release/republish +# workflows — so a mistake there would sign the wrong object in production with +# nothing to catch it. These tests need no registry and no cosign. +# +# Run: modules/test-build-sh.sh + +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +BUILD="$HERE/build.sh" +pass=0 fail=0 + +check() { # check + if [[ "$2" == "$3" ]]; then + echo "ok - $1"; pass=$((pass + 1)) + else + echo "FAIL - $1"; echo " expected: '$2'"; echo " actual: '$3'"; fail=$((fail + 1)) + fi +} + +MANIFEST="sha256:391f362c54183cd0528018d0576089e1fa76258b9f337ee6d525a17d6e8f6f3e" +LAYER="sha256:4b2b418bbeeb1f1d0d3e1a0f4a5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e" + +# 1. Real oras output shape (layer digests abbreviated, authoritative Digest: line). +out="$(cat </dev/null 2>&1 +check "--tlog-upload without --sign exits 2" "2" "$?" + +echo +echo "passed: $pass failed: $fail" +[[ $fail -eq 0 ]]