From 85fc193a0d65607b9d0f7ede5805037fec8a8ca3 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 5 Oct 2026 01:36:43 +0200 Subject: [PATCH 1/2] feat: recover incomplete signed application versions --- .github/scripts/recover-app-version.sh | 87 +++++++++ .github/tests/test-app-recovery.sh | 173 ++++++++++++++++++ .github/tests/test-publish-preflight.sh | 9 +- .../tests/test-unpublished-version-native.sh | 117 +++++++++++- .github/workflows/ci.yaml | 4 + .github/workflows/publish-app.yaml | 72 +++++++- AGENTS.md | 2 + README.md | 8 + 8 files changed, 463 insertions(+), 9 deletions(-) create mode 100644 .github/scripts/recover-app-version.sh create mode 100644 .github/tests/test-app-recovery.sh diff --git a/.github/scripts/recover-app-version.sh b/.github/scripts/recover-app-version.sh new file mode 100644 index 00000000..81bdc1f7 --- /dev/null +++ b/.github/scripts/recover-app-version.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Restore missing version aliases only after authenticating the whole original pair. +set -euo pipefail +umask 077 +fail() { echo "::error::application recovery refused: $*" >&2; exit 1; } +[[ "${ENABLE_SIGNED_PROMOTION:-}" == true && "${ENABLE_CALLER_PIN:-}" == true ]] || fail 'signed promotion and caller pinning are required' +[[ "${REGISTRY:-}" =~ ^[a-z0-9.-]+(:[0-9]+)?$ ]] || fail 'invalid registry host' +[[ "${VERSION:-}" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || fail 'invalid normalized version' +[[ "${RECOVERY_IMAGE_DIGEST:-}" =~ ^sha256:[0-9a-f]{64}$ && "${RECOVERY_MANIFESTS_DIGEST:-}" =~ ^sha256:[0-9a-f]{64}$ ]] || fail 'invalid original digests' +[[ "${RECOVERY_WORKFLOW_SHA:-}" =~ ^[0-9a-f]{40}$ ]] || fail 'original producer must be immutable' +[[ "${RECOVERY_RUN_ID:-}" =~ ^[0-9]+$ && "${RECOVERY_RUN_ATTEMPT:-}" =~ ^[0-9]+$ ]] || fail 'invalid original run identity' +[[ "${SHA:-}" =~ ^[0-9a-f]{40}$ && -n "${REF_NAME:-}" && -n "${REPOSITORY:-}" ]] || fail 'missing source identity' +[[ "${WORKFLOW_REPOSITORY:-}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || fail 'invalid workflow repository' +[[ "${JOB_WORKFLOW_REF:-}" =~ ^[^@]+@[0-9a-f]{40}$ ]] || fail 'current caller must be immutable' +[[ "${JOB_WORKFLOW_REF%@*}" == "$WORKFLOW_REPOSITORY/.github/workflows/publish-app.yaml" ]] || fail 'unexpected current producer' +[[ "${SERVER_URL:-}" == https://github.com ]] || fail 'unsupported signature authority' +[[ -n "${ACTOR:-}" && -n "${GH_TOKEN:-}" ]] || fail 'missing registry credentials' +name="$(printf '%s' "${IMAGE_NAME:-$REPOSITORY}" | tr '[:upper:]' '[:lower:]')" +[[ "$name/manifests" =~ ^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*$ && "${#name}" -lt 246 ]] || fail 'invalid OCI repositories' +image="$REGISTRY/$name" +artifact="$image/manifests" +original_workflow="$WORKFLOW_REPOSITORY/.github/workflows/publish-app.yaml@$RECOVERY_WORKFLOW_SHA" +annotations=( + -a "devantler.repository=$REPOSITORY" + -a "devantler.source-sha=$SHA" + -a "devantler.source-ref=$REF_NAME" + -a "devantler.version=$VERSION" + -a "devantler.run-id=$RECOVERY_RUN_ID" + -a "devantler.run-attempt=$RECOVERY_RUN_ATTEMPT" + -a "devantler.workflow-ref=$original_workflow" + -a "devantler.image-digest=$RECOVERY_IMAGE_DIGEST" + -a "devantler.manifests-digest=$RECOVERY_MANIFESTS_DIGEST" +) +for target in "$image@$RECOVERY_IMAGE_DIGEST" "$artifact@$RECOVERY_MANIFESTS_DIGEST"; do + cosign verify --certificate-identity "$SERVER_URL/$original_workflow" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com "${annotations[@]}" \ + "$target" >/dev/null || fail 'original signature or paired publication claims failed verification' +done +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +accept='application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json' +read_version() { + local repository="$1" expected="$2" status token observed + status="$(curl -q --silent --show-error --proto '=https' --tlsv1.2 \ + --connect-timeout 10 --max-time 30 --max-filesize 1048576 \ + --output "$work/token.json" --write-out '%{http_code}' --get \ + --data-urlencode "service=$REGISTRY" --data-urlencode "scope=repository:$repository:pull" \ + --user "$ACTOR:$GH_TOKEN" "https://$REGISTRY/token")" || fail 'registry authentication request failed' + [[ "$status" == 200 ]] || fail "registry authentication returned HTTP $status" + token="$(jq -ser 'select(length == 1) | .[0] | select(type == "object") | + select(.token == null or .access_token == null or .token == .access_token) | + (.token // .access_token) | select(type == "string" and length > 0)' "$work/token.json" 2>/dev/null)" || fail 'invalid registry token response' + [[ "$token" =~ ^[A-Za-z0-9._~+/-]+=*$ ]] || fail 'invalid registry bearer token' + status="$(curl -q --silent --show-error --proto '=https' --tlsv1.2 \ + --connect-timeout 10 --max-time 30 --max-filesize 1048576 \ + --output "$work/manifest" --write-out '%{http_code}' \ + --header "Authorization: Bearer $token" --header "Accept: $accept" \ + "https://$REGISTRY/v2/$repository/manifests/$VERSION")" || fail 'registry version read failed' + unset token + case "$status" in + 200) + observed="$(shasum -a 256 "$work/manifest" | cut -d ' ' -f1)" || fail 'manifest hashing failed' + [[ "sha256:$observed" == "$expected" ]] || fail 'version has a conflicting digest' + printf 'matching\n' ;; + 404) + jq -se 'length == 1 and (.[0] | type == "object" and (.errors | type == "array" and length > 0 and + all(.[]; type == "object" and (.code == "MANIFEST_UNKNOWN" or .code == "NAME_UNKNOWN"))))' \ + "$work/manifest" >/dev/null 2>&1 || fail 'registry did not establish version absence' + printf 'missing\n' ;; + *) fail "registry version read returned HTTP $status" ;; + esac +} +# Both complete observations must agree with the authenticated pair before any tag is written. +image_state="$(read_version "$name" "$RECOVERY_IMAGE_DIGEST")" || exit 1 +manifests_state="$(read_version "$name/manifests" "$RECOVERY_MANIFESTS_DIGEST")" || exit 1 +# Registry V2 has no compare-and-set or cross-repository transaction. The shared +# queue serializes cooperating publishers; readback detects other writers' races. +if [[ "$image_state" == missing ]]; then + docker buildx imagetools create --prefer-index=false --tag "$image:$VERSION" "$image@$RECOVERY_IMAGE_DIGEST" + [[ "$(read_version "$name" "$RECOVERY_IMAGE_DIGEST")" == matching ]] || fail 'recovered image version is not present' +fi +if [[ "$manifests_state" == missing ]]; then + flux tag artifact "oci://$artifact@$RECOVERY_MANIFESTS_DIGEST" --tag "$VERSION" --creds "$ACTOR:$GH_TOKEN" +fi +[[ "$(read_version "$name" "$RECOVERY_IMAGE_DIGEST")" == matching && + "$(read_version "$name/manifests" "$RECOVERY_MANIFESTS_DIGEST")" == matching ]] || fail 'recovered pair is not present' +echo 'Original signed application version is complete; latest tags are unchanged.' diff --git a/.github/tests/test-app-recovery.sh b/.github/tests/test-app-recovery.sh new file mode 100644 index 00000000..a00039dc --- /dev/null +++ b/.github/tests/test-app-recovery.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# Execute paired recovery with controlled external failures and exclusive workflow admission. +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +workflow="$root/.github/workflows/publish-app.yaml" +fail() { echo "app-recovery: $*" >&2; exit 1; } +yq -o=json '.' "$workflow" | jq -e ' + .on.workflow_call.inputs as $inputs | + $inputs["enable-signed-recovery"].default == false and + (["recovery-image-digest", "recovery-manifests-digest", "recovery-run-id", "recovery-run-attempt", "recovery-workflow-sha"] | + all(. as $name | $inputs[$name].type == "string" and $inputs[$name].default == "" and $inputs[$name].required == false)) and + (.jobs.publish.steps | map(select(.name == "🔏 Recover original application version")) | length == 1) +' >/dev/null || fail 'default-off paired recovery is absent' +# A recovery request cannot reach normal artifact creation or signing. +# shellcheck disable=SC2016 # Literal GitHub expressions are the admission contract. +yq -o=json '.' "$workflow" | jq -e ' + .jobs.publish.steps as $steps | + (["🔍 Validate the deployment manifest", "🏷️ Derive image tags", "🐳 Build & push image", "📌 Pin image digest in manifests"] | + all(. as $name | ($steps | map(select(.name == $name)) | length == 1) and + ($steps | map(select(.name == $name))[0].if == "${{ !inputs.enable-signed-recovery }}"))) and + (["🔒 Prepare signed image staging", "📦 Sign & promote image and manifests"] | + all(. as $name | ($steps | map(select(.name == $name))[0].if == "${{ !inputs.enable-signed-recovery && (inputs.enable-signed-promotion == true || inputs.enable-signed-promotion == '\''true'\'') }}"))) and + ($steps | map(select(.name == "🔏 Recover original application version"))[0] | + .if == "${{ inputs.enable-signed-recovery }}" and + .env.RECOVERY_IMAGE_DIGEST == "${{ inputs.recovery-image-digest }}" and + .env.RECOVERY_MANIFESTS_DIGEST == "${{ inputs.recovery-manifests-digest }}" and + .env.RECOVERY_RUN_ID == "${{ inputs.recovery-run-id }}" and + .env.RECOVERY_RUN_ATTEMPT == "${{ inputs.recovery-run-attempt }}" and + .env.RECOVERY_WORKFLOW_SHA == "${{ inputs.recovery-workflow-sha }}" and + .env.JOB_WORKFLOW_REF == "${{ steps.caller.outputs.ref }}" and + .env.WORKFLOW_REPOSITORY == "${{ job.workflow_repository }}" and + .env.REPOSITORY == "${{ github.repository }}" and .env.SHA == "${{ github.sha }}" and + .env.REF_NAME == "${{ github.ref_name }}" and .env.VERSION == "${{ steps.version.outputs.version }}" and + .env.SERVER_URL == "${{ github.server_url }}" and + .env.ENABLE_SIGNED_PROMOTION == "${{ inputs.enable-signed-promotion }}" and + .env.ENABLE_CALLER_PIN == "${{ inputs.enable-caller-pin }}" and + .run == "bash \"$RUNNER_TEMP/recover-app-version.sh\"") +' >/dev/null || fail 'recovery routing or source binding is unsafe' +prepare="$(yq -r '.jobs.publish.steps[] | select(.name == "🔒 Prepare immutable publication guard") | .run' "$workflow")" +# shellcheck disable=SC2016 # Match the runner variable literally in the shipped step. +grep -qxF 'cp .devantler-tech-publisher/.github/scripts/recover-app-version.sh "$RUNNER_TEMP/recover-app-version.sh"' <<<"$prepare" || fail 'recovery helper is not copied from immutable publisher checkout' +grep -qxF 'rm -rf -- .devantler-tech-publisher' <<<"$prepare" || fail 'publisher helpers remain in the consumer build context' +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir "$work/bin" +yq -r '.jobs.publish.steps[] | select(.name == "🔒 Admit explicit recovery") | .run' "$workflow" >"$work/admit.sh" +for promotion in true false; do + for pin in true false; do + status=0 + ENABLE_SIGNED_PROMOTION="$promotion" ENABLE_CALLER_PIN="$pin" bash -euo pipefail "$work/admit.sh" >"$work/log" 2>&1 || status=$? + if [[ "$promotion" == true && "$pin" == true ]]; then [[ "$status" == 0 ]]; else [[ "$status" != 0 ]]; fi + done +done +printf '{"schemaVersion":2,"manifests":[]}' >"$work/image" +printf '{"schemaVersion":2,"manifests":[],"annotations":{"fixture":"manifests"}}' >"$work/manifests" +image_digest="sha256:$(shasum -a 256 "$work/image" | cut -d ' ' -f1)" +manifests_digest="sha256:$(shasum -a 256 "$work/manifests" | cut -d ' ' -f1)" +cat >"$work/bin/cosign" <<'COSIGN' +#!/usr/bin/env bash +set -euo pipefail +kind=image +[[ "${!#}" != *'/manifests@'* ]] || kind=manifests +printf 'verify-%s\n' "$kind" >>"$TRACE" +expected=(verify --certificate-identity https://github.com/devantler-tech/.github/.github/workflows/publish-app.yaml@0123456789abcdef0123456789abcdef01234567 --certificate-oidc-issuer https://token.actions.githubusercontent.com) +for claim in 'repository=devantler-tech/app' 'source-sha=fedcba9876543210fedcba9876543210fedcba98' 'source-ref=v1.2.3+proof' "version=$VERSION" 'run-id=123' 'run-attempt=2' 'workflow-ref=devantler-tech/.github/.github/workflows/publish-app.yaml@0123456789abcdef0123456789abcdef01234567' "image-digest=$RECOVERY_IMAGE_DIGEST" "manifests-digest=$RECOVERY_MANIFESTS_DIGEST"; do expected+=(-a "devantler.$claim"); done +target="ghcr.io/devantler-tech/app@$RECOVERY_IMAGE_DIGEST" +[[ "$kind" != manifests ]] || target="ghcr.io/devantler-tech/app/manifests@$RECOVERY_MANIFESTS_DIGEST" +expected+=("$target") +[[ "$#" == "${#expected[@]}" ]] || exit 94 +index=0 +for arg in "$@"; do [[ "$arg" == "${expected[index]}" ]] || exit 95; index=$((index + 1)); done +[[ "$SCENARIO" != "signature-$kind" ]] || exit 1 +COSIGN +cat >"$work/bin/curl" <<'CURL' +#!/usr/bin/env bash +set -euo pipefail +[[ "$(head -2 "$TRACE")" == $'verify-image\nverify-manifests' ]] || exit 90 +output='' previous='' url='' kind=image +for arg in "$@"; do + [[ "$previous" != --output ]] || output="$arg" + case "$arg" in https://*) url="$arg" ;; scope=repository:*/manifests:pull) kind=manifests ;; esac + previous="$arg" +done +[[ -n "$output" && "$*" == *'--proto =https'* && "$*" == *'--max-time 30'* && "$*" != *'--location'* ]] || exit 96 +if [[ "$url" == https://ghcr.io/token ]]; then + [[ "$*" == *'--user fixture:synthetic-token'* ]] || exit 97 + printf 'auth-%s\n' "$kind" >>"$TRACE" + printf '{"token":"synthetic-bearer"}' >"$output" + [[ "$SCENARIO" != auth-partial ]] || exit 7 + printf 200 + exit +fi +[[ "$*" == *'Authorization: Bearer synthetic-bearer'* ]] || exit 98 +[[ "$url" != *'/app/manifests/manifests/'* ]] || kind=manifests +[[ "$url" == "https://ghcr.io/v2/devantler-tech/app/manifests/$VERSION" || "$url" == "https://ghcr.io/v2/devantler-tech/app/manifests/manifests/$VERSION" ]] || exit 99 +printf 'read-%s\n' "$kind" >>"$TRACE" +case "$SCENARIO" in + partial-*) [[ "$SCENARIO" != "partial-$kind" ]] || { cp "$STATE/$kind" "$output"; exit 7; } ;; + forbidden-*) [[ "$SCENARIO" != "forbidden-$kind" ]] || { printf '{}' >"$output"; printf 403; exit; } ;; + redirect-*) [[ "$SCENARIO" != "redirect-$kind" ]] || { printf '{}' >"$output"; printf 302; exit; } ;; + mixed-*) [[ "$SCENARIO" != "mixed-$kind" ]] || { printf '{"errors":[{"code":"MANIFEST_UNKNOWN"},{"code":"DENIED"}]}' >"$output"; printf 404; exit; } ;; +esac +if [[ -e "$STATE/present-$kind" ]]; then + if [[ "$SCENARIO" == "conflict-$kind" || "$SCENARIO" == "readback-$kind" ]]; then printf '{}' >"$output"; else cp "$STATE/$kind" "$output"; fi + printf 200 +else printf '{"errors":[{"code":"MANIFEST_UNKNOWN"}]}' >"$output"; printf 404; fi +CURL +cat >"$work/bin/docker" <<'DOCKER' +#!/usr/bin/env bash +set -euo pipefail +[[ "$*" == "buildx imagetools create --prefer-index=false --tag ghcr.io/devantler-tech/app:$VERSION ghcr.io/devantler-tech/app@$RECOVERY_IMAGE_DIGEST" ]] || exit 99 +grep -qx read-image "$TRACE" +grep -qx read-manifests "$TRACE" +printf 'write-image\n' >>"$TRACE" +touch "$STATE/present-image" +[[ "$SCENARIO" != interrupted-image ]] || exit 1 +DOCKER +cat >"$work/bin/flux" <<'FLUX' +#!/usr/bin/env bash +set -euo pipefail +[[ "$*" == "tag artifact oci://ghcr.io/devantler-tech/app/manifests@$RECOVERY_MANIFESTS_DIGEST --tag $VERSION --creds fixture:synthetic-token" ]] || exit 99 +grep -qx read-image "$TRACE" +grep -qx read-manifests "$TRACE" +printf 'write-manifests\n' >>"$TRACE" +touch "$STATE/present-manifests" +[[ "$SCENARIO" != interrupted-manifests ]] || exit 1 +FLUX +chmod +x "$work/bin/"* +run() { + local scenario="$1" + shift + env -i PATH="$work/bin:$PATH" TRACE="$work/trace" STATE="$work" SCENARIO="$scenario" \ + REGISTRY=ghcr.io ACTOR=fixture GH_TOKEN=synthetic-token IMAGE_NAME=devantler-tech/app REPOSITORY=devantler-tech/app \ + SHA=fedcba9876543210fedcba9876543210fedcba98 REF_NAME=v1.2.3+proof VERSION=1.2.3 SERVER_URL=https://github.com \ + WORKFLOW_REPOSITORY=devantler-tech/.github JOB_WORKFLOW_REF=devantler-tech/.github/.github/workflows/publish-app.yaml@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ + RECOVERY_IMAGE_DIGEST="$image_digest" RECOVERY_MANIFESTS_DIGEST="$manifests_digest" RECOVERY_RUN_ID=123 RECOVERY_RUN_ATTEMPT=2 RECOVERY_WORKFLOW_SHA=0123456789abcdef0123456789abcdef01234567 \ + ENABLE_SIGNED_PROMOTION=true ENABLE_CALLER_PIN=true "$@" bash "$root/.github/scripts/recover-app-version.sh" >"$work/log" 2>&1 +} +for scenario in missing matching image-only manifests-only signature-image signature-manifests auth-partial partial-image partial-manifests forbidden-image forbidden-manifests redirect-image redirect-manifests mixed-image mixed-manifests conflict-image conflict-manifests readback-image readback-manifests interrupted-image interrupted-manifests; do + : >"$work/trace" + rm -f "$work/present-image" "$work/present-manifests" + case "$scenario" in + matching) touch "$work/present-image" "$work/present-manifests" ;; + image-only|conflict-image) touch "$work/present-image" ;; + manifests-only|conflict-manifests) touch "$work/present-manifests" ;; + esac + status=0 + run "$scenario" env || status=$? + count="$(grep -c '^write-' "$work/trace" || true)" + case "$scenario" in + missing) [[ "$status" == 0 && "$count" == 2 ]] || { cat "$work/log"; fail 'missing pair not restored'; } ;; + matching) [[ "$status" == 0 && "$count" == 0 ]] || fail 'matching pair changed' ;; + image-only|manifests-only) [[ "$status" == 0 && "$count" == 1 ]] || fail 'partial pair not restored' ;; + readback-*|interrupted-*) [[ "$status" != 0 ]] || fail 'incomplete publication succeeded' ;; + *) [[ "$status" != 0 && "$count" == 0 ]] || fail "$scenario authorized a write" ;; + esac + ! grep -Eq 'synthetic-token|synthetic-bearer' "$work/log" || fail 'credentials printed' +done +for scenario in interrupted-image interrupted-manifests; do + rm -f "$work/present-image" "$work/present-manifests" + : >"$work/trace" + if run "$scenario" env; then fail 'interrupted write succeeded'; fi + : >"$work/trace" + run missing env || { cat "$work/log"; fail 'interrupted pair could not resume'; } + count="$(grep -c '^write-' "$work/trace" || true)" + if [[ "$scenario" == interrupted-image ]]; then [[ "$count" == 1 ]]; else [[ "$count" == 0 ]]; fi +done +for assignment in ENABLE_SIGNED_PROMOTION=false ENABLE_CALLER_PIN=false RECOVERY_WORKFLOW_SHA=main RECOVERY_RUN_ID=wrong RECOVERY_RUN_ATTEMPT=wrong RECOVERY_IMAGE_DIGEST=sha256:bad RECOVERY_MANIFESTS_DIGEST=sha256:bad; do + : >"$work/trace" + if run missing env "$assignment"; then fail "invalid $assignment accepted"; fi + [[ ! -s "$work/trace" ]] || fail 'invalid admission reached an external command' +done +echo 'PASS: paired missing/matching/partial recovery, both signature and observation failures, conflicts, interrupted writes and idempotent retries' diff --git a/.github/tests/test-publish-preflight.sh b/.github/tests/test-publish-preflight.sh index 99e484ba..a02bcb24 100755 --- a/.github/tests/test-publish-preflight.sh +++ b/.github/tests/test-publish-preflight.sh @@ -165,6 +165,9 @@ lookup_expr() { # — the value GitHub would substitute for ${{ &2; exit 1; } } read_digest() { - curl -fsS --max-time 10 -H 'Authorization: Bearer synthetic-bearer' -H 'Accept: application/vnd.oci.image.index.v1+json' \ + curl -fsS --max-time 10 -H 'Authorization: Bearer synthetic-bearer' \ + -H 'Accept: application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json' \ "https://$REGISTRY/v2/$1/manifests/$2" >"$work/readback" printf 'sha256:%s\n' "$(sha256sum "$work/readback" | cut -d ' ' -f1)" } @@ -76,11 +77,30 @@ echo 'PASS: native new/partial/paired versions and normalized aliases; version/l # production identity/issuer enforcement remains covered by the publisher steps. export COSIGN_PASSWORD='' cosign generate-key-pair --output-key-prefix "$work/claims" >/dev/null -image_digest="$digest" +# A real single-platform image verifies that Buildx preserves the manifest bytes +# instead of wrapping the image in a new index (and changing its signed digest). +printf '{"architecture":"amd64","os":"linux","rootfs":{"type":"layers","diff_ids":[]},"config":{}}' >"$work/config.json" +config_digest="sha256:$(sha256sum "$work/config.json" | cut -d ' ' -f1)" +curl -fsS --user fixture:synthetic-token --max-time 10 -D "$work/upload-headers" -X POST \ + "https://$REGISTRY/v2/devantler-tech/app/blobs/uploads/" >/dev/null +location="$(sed -n 's/^[Ll]ocation: //p' "$work/upload-headers" | tr -d '\r')" +# Follow only a validated fixture path, never a response-selected authority. +upload_path="${location#*://}" +upload_path="/${upload_path#*/}" +[[ "$upload_path" == /v2/devantler-tech/app/blobs/uploads/* ]] || { echo 'FAIL: unexpected fixture upload path'; exit 1; } +separator='?' +[[ "$upload_path" != *'?'* ]] || separator='&' +curl -fsS --user fixture:synthetic-token --max-time 10 -X PUT -H 'Content-Type: application/octet-stream' \ + --data-binary "@$work/config.json" "https://$REGISTRY$upload_path${separator}digest=$config_digest" >/dev/null +jq -nc --arg digest "$config_digest" --argjson size "$(wc -c <"$work/config.json")" \ + '{schemaVersion:2,mediaType:"application/vnd.oci.image.manifest.v1+json",config:{mediaType:"application/vnd.oci.image.config.v1+json",digest:$digest,size:$size},layers:[]}' >"$work/index.json" +image_digest="sha256:$(sha256sum "$work/index.json" | cut -d ' ' -f1)" +cp "$work/index.json" "$work/image.json" put devantler-tech/app claims-staging printf '{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[],"annotations":{"fixture":"manifests"}}' >"$work/index.json" digest="sha256:$(sha256sum "$work/index.json" | cut -d ' ' -f1)" manifest_digest="$digest" +cp "$work/index.json" "$work/manifests.json" put devantler-tech/app/manifests claims-staging export REPOSITORY=devantler-tech/native-fixture SHA=fedcba9876543210fedcba9876543210fedcba98 export REF_NAME=v1.2.4+proof VERSION=1.2.4 RUN_ID=123 RUN_ATTEMPT=2 @@ -140,7 +160,8 @@ set -euo pipefail [[ "$1" == verify && "$2" == --certificate-identity && "$4" == --certificate-oidc-issuer && "$5" == https://token.actions.githubusercontent.com ]] || exit 91 identity="$3" -[[ "$identity" =~ ^https://github.com/devantler-tech/\.github/\.github/workflows/publish-manifests\.yaml@[0-9a-f]{40}$ ]] || exit 92 +[[ "$identity" =~ ^https://github.com/devantler-tech/\.github/\.github/workflows/publish-(app|manifests)\.yaml@[0-9a-f]{40}$ ]] || exit 92 +family="${BASH_REMATCH[1]}" shift 5 claims=() keys=() @@ -152,8 +173,13 @@ while [[ "${1:-}" == -a ]]; do claims+=(-a "$2") shift 2 done -[[ "${#keys[@]}" == 7 && "$#" == 1 && - "$1" == "registry.test:5443/devantler-tech/app/manifests@$RECOVERY_DIGEST" ]] || exit 95 +[[ "$#" == 1 ]] || exit 95 +if [[ "$family" == manifests ]]; then + [[ "${#keys[@]}" == 7 && "$1" == "registry.test:5443/devantler-tech/app/manifests@$RECOVERY_DIGEST" ]] || exit 95 +else + [[ "${#keys[@]}" == 9 && ( "$1" == "registry.test:5443/devantler-tech/app@$RECOVERY_IMAGE_DIGEST" || + "$1" == "registry.test:5443/devantler-tech/app/manifests@$RECOVERY_MANIFESTS_DIGEST" ) ]] || exit 95 +fi exec "$REAL_COSIGN" verify --allow-http-registry --allow-insecure-registry --insecure-ignore-tlog \ --key "$FIXTURE_PUBLIC_KEY" "${claims[@]}" "127.0.0.1:5000/${1#registry.test:5443/}" COSIGN @@ -194,3 +220,82 @@ grep -q 'version has a conflicting digest' "$work/recovery.log" "$(read_digest devantler-tech/app/manifests latest)" == "$latest_before" ]] if grep -Eq 'synthetic-token|synthetic-bearer' "$work/recovery.log"; then echo 'FAIL: recovery printed credentials'; exit 1; fi echo 'PASS: actual Flux restores original signed bytes; matching retry is a no-op; every wrong claim and conflicting version is refused; latest retained' + +# Independently sign paired recovery fixtures. Do not derive these annotations +# from recovery code: its claim bindings must agree with this literal contract. +export IMAGE_NAME=devantler-tech/app RECOVERY_IMAGE_DIGEST="$image_digest" RECOVERY_MANIFESTS_DIGEST="$manifest_digest" +export JOB_WORKFLOW_REF=devantler-tech/.github/.github/workflows/publish-app.yaml@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +export DOCKER_CONFIG="$work/docker-config" +mkdir "$DOCKER_CONFIG" +jq -nc --arg registry "$REGISTRY" --arg auth "$(printf fixture:synthetic-token | base64 | tr -d '\n')" \ + '{auths:{($registry):{auth:$auth}}}' >"$DOCKER_CONFIG/config.json" +docker buildx version >/dev/null +recovery="$root/.github/scripts/recover-app-version.sh" +image_latest_before="$(read_digest devantler-tech/app latest)" +[[ "$image_latest_before" != "$image_digest" ]] +sign_pair() { + export VERSION="$1" REF_NAME="v$1+proof" + local target + for target in "devantler-tech/app@$image_digest" "devantler-tech/app/manifests@$manifest_digest"; do + "$real_cosign" sign --allow-http-registry --allow-insecure-registry --use-signing-config=false --tlog-upload=false --yes --key "$work/claims.key" \ + -a 'devantler.repository=devantler-tech/native-fixture' \ + -a 'devantler.source-sha=fedcba9876543210fedcba9876543210fedcba98' \ + -a "devantler.source-ref=v$1+proof" -a "devantler.version=$1" \ + -a 'devantler.run-id=123' -a 'devantler.run-attempt=2' \ + -a 'devantler.workflow-ref=devantler-tech/.github/.github/workflows/publish-app.yaml@0123456789abcdef0123456789abcdef01234567' \ + -a "devantler.image-digest=$image_digest" -a "devantler.manifests-digest=$manifest_digest" \ + "127.0.0.1:5000/$target" >"$work/sign.log" 2>&1 + done +} +pair_writes() { grep -cE "\"PUT\" \"/v2/devantler-tech/app(/manifests)?/manifests/$VERSION\"" "$work/requests" || true; } +assert_pair() { + [[ "$(read_digest devantler-tech/app "$VERSION")" == "$image_digest" && + "$(read_digest devantler-tech/app/manifests "$VERSION")" == "$manifest_digest" && + "$(read_digest devantler-tech/app latest)" == "$image_latest_before" && + "$(read_digest devantler-tech/app/manifests latest)" == "$latest_before" ]] +} +sign_pair 1.2.5 +recover || { cat "$work/recovery.log" >&2; exit 1; } +assert_pair +before="$(pair_writes)" +recover +[[ "$(pair_writes)" == "$before" ]] || { echo 'FAIL: matching paired recovery wrote again'; exit 1; } +for assignment in REPOSITORY=devantler-tech/wrong SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa REF_NAME=v1.2.5+wrong \ + VERSION=1.2.5-rc.1 RECOVERY_RUN_ID=124 RECOVERY_RUN_ATTEMPT=3 RECOVERY_WORKFLOW_SHA=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb; do + if (export "${assignment?}"; recover); then echo 'FAIL: wrong paired claim recovered'; exit 1; fi + grep -qi annotation "$work/recovery.log" || { cat "$work/recovery.log" >&2; echo 'FAIL: paired claim refused for unrelated reason'; exit 1; } + [[ "$(pair_writes)" == "$before" ]] +done +for partial in image manifests; do + version=1.2.6 + [[ "$partial" != manifests ]] || version=1.2.7 + sign_pair "$version" + target=devantler-tech/app + [[ "$partial" != manifests ]] || target+=/manifests + cp "$work/$partial.json" "$work/index.json" + put "$target" "$VERSION" + before="$(pair_writes)" + recover || { cat "$work/recovery.log" >&2; exit 1; } + assert_pair + [[ "$(pair_writes)" == "$((before + 1))" ]] || { echo 'FAIL: partial recovery did not write exactly one alias'; exit 1; } +done +for conflicting in image manifests; do + version=1.2.8 + [[ "$conflicting" != manifests ]] || version=1.2.9 + sign_pair "$version" + target=devantler-tech/app + missing=devantler-tech/app/manifests + if [[ "$conflicting" == manifests ]]; then target+=/manifests; missing=devantler-tech/app; fi + printf '{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[],"annotations":{"fixture":"conflict"}}' >"$work/index.json" + conflict_digest="sha256:$(sha256sum "$work/index.json" | cut -d ' ' -f1)" + put "$target" "$VERSION" + before="$(pair_writes)" + if recover; then echo 'FAIL: conflicting pair recovered'; exit 1; fi + grep -q 'version has a conflicting digest' "$work/recovery.log" + [[ "$(pair_writes)" == "$before" && "$(read_digest "$target" "$VERSION")" == "$conflict_digest" ]] + status="$(curl -sS --max-time 10 -o "$work/response" -w '%{http_code}' -H 'Authorization: Bearer synthetic-bearer' "https://$REGISTRY/v2/$missing/manifests/$VERSION")" + [[ "$status" == 404 ]] || { echo 'FAIL: conflict wrote the previously absent member'; exit 1; } +done +[[ "$(read_digest devantler-tech/app latest)" == "$image_latest_before" && "$(read_digest devantler-tech/app/manifests latest)" == "$latest_before" ]] +! grep -Eq 'synthetic-token|synthetic-bearer' "$work/recovery.log" || { echo 'FAIL: paired recovery printed credentials'; exit 1; } +echo 'PASS: actual Buildx and Flux preserve paired digests, restore either partial release, perform no matching retry writes, refuse both conflict directions and wrong original claims, and retain latest' diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3ee9ff7d..262f0dff 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -3256,6 +3256,10 @@ jobs: shell: bash run: bash .github/tests/test-manifests-recovery.sh + - name: 🔏 Recover only the original signed application pair + shell: bash + run: bash .github/tests/test-app-recovery.sh + - name: ✍️ Install Cosign for disposable cryptographic publication tests uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 diff --git a/.github/workflows/publish-app.yaml b/.github/workflows/publish-app.yaml index 59c009e3..502d89ce 100644 --- a/.github/workflows/publish-app.yaml +++ b/.github/workflows/publish-app.yaml @@ -26,6 +26,36 @@ on: required: false default: false type: boolean + enable-signed-recovery: + description: "Restore only missing version tags from the original signed pair, preserving latest. Requires signed promotion and caller pinning; default-off (#425)." + required: false + default: false + type: boolean + recovery-image-digest: + description: "Original signed image sha256 digest, required for explicit recovery." + required: false + default: "" + type: string + recovery-manifests-digest: + description: "Original signed manifests sha256 digest, required for explicit recovery." + required: false + default: "" + type: string + recovery-run-id: + description: "Original publication run ID recorded in both signatures." + required: false + default: "" + type: string + recovery-run-attempt: + description: "Original publication attempt recorded in both signatures." + required: false + default: "" + type: string + recovery-workflow-sha: + description: "Original immutable publish-app workflow commit in this catalogue repository." + required: false + default: "" + type: string permissions: {} @@ -51,6 +81,17 @@ jobs: with: egress-policy: audit + - name: 🔒 Admit explicit recovery + if: ${{ inputs.enable-signed-recovery }} + env: + ENABLE_SIGNED_PROMOTION: ${{ inputs.enable-signed-promotion }} + ENABLE_CALLER_PIN: ${{ inputs.enable-caller-pin }} + run: | + [[ "$ENABLE_SIGNED_PROMOTION" == true && "$ENABLE_CALLER_PIN" == true ]] || { + echo '::error::recovery requires signed promotion and caller pinning' + exit 1 + } + - name: 🔒 Require a semantic-version tag id: version env: @@ -130,6 +171,7 @@ jobs: persist-credentials: false - name: 🔍 Validate the deployment manifest + if: ${{ !inputs.enable-signed-recovery }} env: APP_NAME: ${{ inputs.app-name }} DEPLOY_PATH: ${{ inputs.deploy-path }} @@ -183,11 +225,12 @@ jobs: if: ${{ inputs.enable-signed-promotion == true || inputs.enable-signed-promotion == 'true' }} run: | cp .devantler-tech-publisher/.github/scripts/require-unpublished-version.sh "$RUNNER_TEMP/require-unpublished-version.sh" + cp .devantler-tech-publisher/.github/scripts/recover-app-version.sh "$RUNNER_TEMP/recover-app-version.sh" rm -rf -- .devantler-tech-publisher - name: 🔒 Prepare signed image staging id: staging - if: ${{ inputs.enable-signed-promotion == true || inputs.enable-signed-promotion == 'true' }} + if: ${{ !inputs.enable-signed-recovery && (inputs.enable-signed-promotion == true || inputs.enable-signed-promotion == 'true') }} shell: bash env: JOB_WORKFLOW_REF: ${{ steps.caller.outputs.ref }} @@ -213,6 +256,7 @@ jobs: echo "tag=${image}:staging-${RUN_ID}-${RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" - name: 🏷️ Derive image tags + if: ${{ !inputs.enable-signed-recovery }} uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 id: meta with: @@ -223,6 +267,7 @@ jobs: type=raw,value=latest - name: 🐳 Build & push image + if: ${{ !inputs.enable-signed-recovery }} uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 id: build with: @@ -232,6 +277,7 @@ jobs: labels: ${{ steps.meta.outputs.labels }} - name: 📌 Pin image digest in manifests + if: ${{ !inputs.enable-signed-recovery }} env: IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} DIGEST: ${{ steps.build.outputs.digest }} @@ -287,7 +333,7 @@ jobs: cosign sign --yes "${IMAGE}@${DIGEST}" - name: 📦 Sign & promote image and manifests - if: ${{ inputs.enable-signed-promotion == true || inputs.enable-signed-promotion == 'true' }} + if: ${{ !inputs.enable-signed-recovery && (inputs.enable-signed-promotion == true || inputs.enable-signed-promotion == 'true') }} shell: bash env: DIGEST: ${{ steps.build.outputs.digest }} @@ -360,3 +406,25 @@ jobs: flux tag artifact "oci://${ARTIFACT}@${ARTIFACT_DIGEST}" --tag latest --creds "${ACTOR}:${GH_TOKEN}" ;; esac + + - name: 🔏 Recover original application version + if: ${{ inputs.enable-signed-recovery }} + shell: bash + env: + VERSION: ${{ steps.version.outputs.version }} + REF_NAME: ${{ github.ref_name }} + SHA: ${{ github.sha }} + SERVER_URL: ${{ github.server_url }} + REPOSITORY: ${{ github.repository }} + ACTOR: ${{ github.actor }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + JOB_WORKFLOW_REF: ${{ steps.caller.outputs.ref }} + WORKFLOW_REPOSITORY: ${{ job.workflow_repository }} + RECOVERY_IMAGE_DIGEST: ${{ inputs.recovery-image-digest }} + RECOVERY_MANIFESTS_DIGEST: ${{ inputs.recovery-manifests-digest }} + RECOVERY_RUN_ID: ${{ inputs.recovery-run-id }} + RECOVERY_RUN_ATTEMPT: ${{ inputs.recovery-run-attempt }} + RECOVERY_WORKFLOW_SHA: ${{ inputs.recovery-workflow-sha }} + ENABLE_SIGNED_PROMOTION: ${{ inputs.enable-signed-promotion }} + ENABLE_CALLER_PIN: ${{ inputs.enable-caller-pin }} + run: bash "$RUNNER_TEMP/recover-app-version.sh" diff --git a/AGENTS.md b/AGENTS.md index c84a1bb4..0a4fdba3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -480,6 +480,8 @@ required; this fixture never proves live token generation or project association **Explicit manifests recovery:** Default-off `enable-signed-recovery` requests only the original version alias, with signed promotion and caller pinning required before any legacy write. Verify the original catalogue identity and all original source/version/run claims, restore only authenticated missing versions, treat matching versions as no-ops, refuse contradictory or ambiguous reads, and always preserve `latest`. Keep recovery boundary, interruption/readback controls and native Flux/registry/cryptographic tests in required CI. The native key fixture proves signed claims and registry bytes, not production OIDC. Cross-repository publisher identity migration remains #242; complete recovery rollout remains #425. +**Explicit application recovery:** The same default-off mode verifies both original signatures with all nine paired publication claims before reading both version aliases. Any conflict or unknown read prevents all writes; only missing aliases are restored, with digest readback and matching retries producing no writes. Keep `.github/tests/test-app-recovery.sh` and the native single-platform Buildx/Flux proof required. Never rebuild, re-sign or move `latest` during recovery. Registry writes have no compare-and-set or cross-repository transaction; the shared queue and final readback cannot fence unrelated writers. + **Manifest naming:** Every `validate-naming` call validates the caller's versioned configuration. Keep the Linux/macOS clean fixtures, seeded naming violations and missing-configuration failures required in CI. Failed calls never report a diff --git a/README.md b/README.md index c03285ad..719c4176 100644 --- a/README.md +++ b/README.md @@ -629,9 +629,17 @@ jobs: | `dry-run` | Input (boolean) | `false` | No | Skip publication and validate only the workflow interface | | `enable-caller-pin` | Input (boolean) | `true` | No | Require a 40-character commit-SHA caller before signing. The authenticated OIDC calling ref is the certificate identity. Explicit false remains supported during caller cleanup in #284 | | `enable-signed-promotion` | Input (boolean) | `false` | No | Stage the image and manifests, sign and verify both exact digests before exposing version tags, then move latest only for stable releases. Requires `enable-caller-pin`; rollout is tracked in #371 | +| `enable-signed-recovery` | Input (boolean) | `false` | No | Restore missing version tags from the original signed pair; requires signed promotion and caller pinning, and preserves both latest tags | +| `recovery-image-digest` | Input (string) | empty | No | Original signed image `sha256` digest, required for recovery | +| `recovery-manifests-digest` | Input (string) | empty | No | Original signed manifests `sha256` digest, required for recovery | +| `recovery-run-id` | Input (string) | empty | No | Original publication run ID, required for recovery | +| `recovery-run-attempt` | Input (string) | empty | No | Original publication attempt, required for recovery | +| `recovery-workflow-sha` | Input (string) | empty | No | Original publisher commit in this catalogue repository, required for recovery | Opt in with both `enable-signed-promotion: true` and `enable-caller-pin: true` to stage the image and manifests under run-specific tags. Authenticated registry reads must establish that the normalized version is absent from both repositories before staging, and again before promotion. Existing versions and failed or ambiguous reads stop publication. Both signatures bind the publishing repository, source revision and tag, normalized version, run and attempt, immutable workflow identity, and the exact image/manifests digest pair. Both exact digests must pass signing, identity and claim verification before version tags are exposed; prereleases never move `latest`. Image promotion preserves the verified manifest format and checks its produced digest. Opted-in publishers for the same target in one caller repository wait in a shared queue without canceling pending releases. Registry tag writes are sequential; other writers can race the final absence check, and a promotion failure can leave only one version or latest tag updated. Production caller adoption remains in #371 and #242. +For an interrupted version publication, run explicit recovery on the original semantic-version tag and source revision. Enable signed recovery, signed promotion and caller pinning, and supply both original digests, the original run ID/attempt and publisher SHA. Both signatures must authenticate the same source, version, run and digest pair before either version is read. Both reads must prove absence or match the original bytes before any write. Recovery restores only missing version tags, performs no build or signing, and always preserves both latest tags. A matching retry makes no writes; a contradictory version or incomplete observation stops recovery. Image tagging preserves the signed manifest format and verifies the complete registry bytes. The two writes are not atomic: an interruption can leave one version present, and readback detects external races without preventing them. Retry with the same original pair. This accepts only the original catalogue repository identity; #242 governs migration from another publisher repository. Disposable native CI proves real registry tagging and local-key signature claims; it does not establish production OIDC or authorize rollout. + ### 📦 Publish Manifests From 445da550d757535440f30af1af9291cd8df29226 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 5 Oct 2026 01:40:53 +0200 Subject: [PATCH 2/2] test: bind recovery no-write proofs to every tag --- .../tests/test-unpublished-version-native.sh | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/.github/tests/test-unpublished-version-native.sh b/.github/tests/test-unpublished-version-native.sh index eef40f6c..d0c61bcc 100644 --- a/.github/tests/test-unpublished-version-native.sh +++ b/.github/tests/test-unpublished-version-native.sh @@ -194,28 +194,29 @@ recovery="$root/.github/scripts/recover-manifests-version.sh" latest_before="$(read_digest devantler-tech/app/manifests latest)" [[ "$latest_before" != "$manifest_digest" ]] recover() { PATH="$work/bin:$PATH" bash "$recovery" >"$work/recovery.log" 2>&1; } -writes() { grep -c '"PUT" "/v2/devantler-tech/app/manifests/manifests/1.2.4"' "$work/requests" || true; } +# Count every manifest tag, including a changed VERSION, staging and latest. +manifest_writes() { grep -cE '"PUT" "/v2/.+/manifests/[^\"]+"' "$work/requests" || true; } recover || { cat "$work/recovery.log" >&2; exit 1; } [[ "$(read_digest devantler-tech/app/manifests 1.2.4)" == "$manifest_digest" && "$(read_digest devantler-tech/app/manifests latest)" == "$latest_before" ]] -before="$(writes)" +before="$(manifest_writes)" recover -[[ "$(writes)" == "$before" ]] || { echo 'FAIL: matching recovery wrote again'; exit 1; } +[[ "$(manifest_writes)" == "$before" ]] || { echo 'FAIL: matching recovery wrote again'; exit 1; } # Valid but wrong claims must reach actual cryptographic annotation rejection. for assignment in REPOSITORY=devantler-tech/wrong SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa REF_NAME=v1.2.4+wrong \ VERSION=1.2.4-rc.1 RECOVERY_RUN_ID=124 RECOVERY_RUN_ATTEMPT=3 RECOVERY_WORKFLOW_SHA=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb; do if (export "${assignment?}"; recover); then echo 'FAIL: wrong original claim recovered'; exit 1; fi grep -qi annotation "$work/recovery.log" || { cat "$work/recovery.log" >&2; echo 'FAIL: claim refused for unrelated reason'; exit 1; } - [[ "$(writes)" == "$before" ]] + [[ "$(manifest_writes)" == "$before" ]] done # Replacing the tag with contradictory bytes must never overwrite it. printf '{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[],"annotations":{"fixture":"conflicting-version"}}' >"$work/index.json" conflict_digest="sha256:$(sha256sum "$work/index.json" | cut -d ' ' -f1)" put devantler-tech/app/manifests 1.2.4 -before="$(writes)" +before="$(manifest_writes)" if recover; then echo 'FAIL: conflicting version was overwritten'; exit 1; fi grep -q 'version has a conflicting digest' "$work/recovery.log" -[[ "$(writes)" == "$before" && +[[ "$(manifest_writes)" == "$before" && "$(read_digest devantler-tech/app/manifests 1.2.4)" == "$conflict_digest" && "$(read_digest devantler-tech/app/manifests latest)" == "$latest_before" ]] if grep -Eq 'synthetic-token|synthetic-bearer' "$work/recovery.log"; then echo 'FAIL: recovery printed credentials'; exit 1; fi @@ -235,15 +236,17 @@ image_latest_before="$(read_digest devantler-tech/app latest)" [[ "$image_latest_before" != "$image_digest" ]] sign_pair() { export VERSION="$1" REF_NAME="v$1+proof" - local target + local target claimed_image_digest for target in "devantler-tech/app@$image_digest" "devantler-tech/app/manifests@$manifest_digest"; do + claimed_image_digest="$image_digest" + if [[ "${2:-}" == wrong-manifests && "$target" == */manifests@* ]]; then claimed_image_digest="$manifest_digest"; fi "$real_cosign" sign --allow-http-registry --allow-insecure-registry --use-signing-config=false --tlog-upload=false --yes --key "$work/claims.key" \ -a 'devantler.repository=devantler-tech/native-fixture' \ -a 'devantler.source-sha=fedcba9876543210fedcba9876543210fedcba98' \ -a "devantler.source-ref=v$1+proof" -a "devantler.version=$1" \ -a 'devantler.run-id=123' -a 'devantler.run-attempt=2' \ -a 'devantler.workflow-ref=devantler-tech/.github/.github/workflows/publish-app.yaml@0123456789abcdef0123456789abcdef01234567' \ - -a "devantler.image-digest=$image_digest" -a "devantler.manifests-digest=$manifest_digest" \ + -a "devantler.image-digest=$claimed_image_digest" -a "devantler.manifests-digest=$manifest_digest" \ "127.0.0.1:5000/$target" >"$work/sign.log" 2>&1 done } @@ -257,15 +260,22 @@ assert_pair() { sign_pair 1.2.5 recover || { cat "$work/recovery.log" >&2; exit 1; } assert_pair -before="$(pair_writes)" +before="$(manifest_writes)" recover -[[ "$(pair_writes)" == "$before" ]] || { echo 'FAIL: matching paired recovery wrote again'; exit 1; } +[[ "$(manifest_writes)" == "$before" ]] || { echo 'FAIL: matching paired recovery wrote again'; exit 1; } for assignment in REPOSITORY=devantler-tech/wrong SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa REF_NAME=v1.2.5+wrong \ VERSION=1.2.5-rc.1 RECOVERY_RUN_ID=124 RECOVERY_RUN_ATTEMPT=3 RECOVERY_WORKFLOW_SHA=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb; do if (export "${assignment?}"; recover); then echo 'FAIL: wrong paired claim recovered'; exit 1; fi grep -qi annotation "$work/recovery.log" || { cat "$work/recovery.log" >&2; echo 'FAIL: paired claim refused for unrelated reason'; exit 1; } - [[ "$(pair_writes)" == "$before" ]] + [[ "$(manifest_writes)" == "$before" ]] done +# This fresh version has a healthy image signature but only a contradictory +# manifests pair claim. The second cryptographic verification must refuse it. +sign_pair 1.2.10 wrong-manifests +before="$(manifest_writes)" +if recover; then echo 'FAIL: wrong second paired signature recovered'; exit 1; fi +grep -qi annotation "$work/recovery.log" || { cat "$work/recovery.log" >&2; echo 'FAIL: second signature refused for unrelated reason'; exit 1; } +[[ "$(manifest_writes)" == "$before" ]] for partial in image manifests; do version=1.2.6 [[ "$partial" != manifests ]] || version=1.2.7 @@ -275,9 +285,11 @@ for partial in image manifests; do cp "$work/$partial.json" "$work/index.json" put "$target" "$VERSION" before="$(pair_writes)" + total_before="$(manifest_writes)" recover || { cat "$work/recovery.log" >&2; exit 1; } assert_pair [[ "$(pair_writes)" == "$((before + 1))" ]] || { echo 'FAIL: partial recovery did not write exactly one alias'; exit 1; } + [[ "$(manifest_writes)" == "$((total_before + 1))" ]] || { echo 'FAIL: partial recovery wrote an unrelated alias'; exit 1; } done for conflicting in image manifests; do version=1.2.8 @@ -289,10 +301,10 @@ for conflicting in image manifests; do printf '{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[],"annotations":{"fixture":"conflict"}}' >"$work/index.json" conflict_digest="sha256:$(sha256sum "$work/index.json" | cut -d ' ' -f1)" put "$target" "$VERSION" - before="$(pair_writes)" + before="$(manifest_writes)" if recover; then echo 'FAIL: conflicting pair recovered'; exit 1; fi grep -q 'version has a conflicting digest' "$work/recovery.log" - [[ "$(pair_writes)" == "$before" && "$(read_digest "$target" "$VERSION")" == "$conflict_digest" ]] + [[ "$(manifest_writes)" == "$before" && "$(read_digest "$target" "$VERSION")" == "$conflict_digest" ]] status="$(curl -sS --max-time 10 -o "$work/response" -w '%{http_code}' -H 'Authorization: Bearer synthetic-bearer' "https://$REGISTRY/v2/$missing/manifests/$VERSION")" [[ "$status" == 404 ]] || { echo 'FAIL: conflict wrote the previously absent member'; exit 1; } done