From a1b706d05a8e21f1ae3447df171d1c1c1d8e271d Mon Sep 17 00:00:00 2001 From: leigh capili Date: Wed, 17 Jun 2026 22:06:11 -0600 Subject: [PATCH 1/6] hack: add sigstore e2e test harness Add a self-contained harness for exercising cosign verification against a local Sigstore stack on kind. The Makefile drives cluster creation, zot (referrers API) and registry:2 (tag fallback) registries, the sigstore scaffold Helm release, an RFC3161 timestamp authority, the source-controller image build and load, and the cosign CLI fetch. Run the sigstore suite in CI in parallel with the existing e2e job via make targets, installing cosign and the flux CLI through their actions. Assisted-by: GitHub Copilot CLI/gpt-5.5 Assisted-by: Kiro/opus-4.8 Signed-off-by: leigh capili --- .github/workflows/e2e.yaml | 31 +++ hack/sigstore-test/.gitignore | 2 + hack/sigstore-test/Makefile | 52 ++++ hack/sigstore-test/build-and-load.sh | 31 +++ hack/sigstore-test/fetch-cosign.sh | 77 ++++++ hack/sigstore-test/kind-cluster.yaml | 20 ++ hack/sigstore-test/kind-cluster.yaml.tpl | 20 ++ hack/sigstore-test/kind-down.sh | 39 +++ hack/sigstore-test/kind-up.sh | 38 +++ hack/sigstore-test/port-forward.sh | 55 +++++ hack/sigstore-test/registries-up.sh | 60 +++++ hack/sigstore-test/render-kind-config.sh | 26 ++ hack/sigstore-test/setup-sigstore.sh | 229 ++++++++++++++++++ hack/sigstore-test/setup-tsa.sh | 109 +++++++++ hack/sigstore-test/trillian.mysql.values.yaml | 41 ++++ 15 files changed, 830 insertions(+) create mode 100644 hack/sigstore-test/.gitignore create mode 100644 hack/sigstore-test/Makefile create mode 100755 hack/sigstore-test/build-and-load.sh create mode 100755 hack/sigstore-test/fetch-cosign.sh create mode 100644 hack/sigstore-test/kind-cluster.yaml create mode 100644 hack/sigstore-test/kind-cluster.yaml.tpl create mode 100755 hack/sigstore-test/kind-down.sh create mode 100755 hack/sigstore-test/kind-up.sh create mode 100755 hack/sigstore-test/port-forward.sh create mode 100755 hack/sigstore-test/registries-up.sh create mode 100755 hack/sigstore-test/render-kind-config.sh create mode 100755 hack/sigstore-test/setup-sigstore.sh create mode 100755 hack/sigstore-test/setup-tsa.sh create mode 100644 hack/sigstore-test/trillian.mysql.values.yaml diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 570d4edd5..65d658ab1 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -33,3 +33,34 @@ jobs: continue-on-error: true run: | kubectl -n source-system logs -l app=source-controller + + sigstore-linux-amd64: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Test suite setup + uses: fluxcd/gha-workflows/.github/actions/setup-kubernetes@v0.12.0 + with: + go-version: 1.26.x + cluster-name: sigstore-test + kind-config: hack/sigstore-test/kind-cluster.yaml + - name: Setup cosign + uses: sigstore/cosign-installer@v3 + - name: Setup Flux CLI + uses: fluxcd/flux2/action@v2.8.8 + - name: Start registries + run: make -C hack/sigstore-test registries + - name: Install sigstore stack + run: make -C hack/sigstore-test sigstore + - name: Install timestamp authority + run: make -C hack/sigstore-test tsa + - name: Build and deploy source-controller + run: BUILD_PLATFORM=linux/amd64 make -C hack/sigstore-test build + - name: Run sigstore verification tests + run: make -C hack/sigstore-test test + - name: Print controller logs + if: always() + continue-on-error: true + run: | + kubectl -n source-system logs deploy/source-controller diff --git a/hack/sigstore-test/.gitignore b/hack/sigstore-test/.gitignore new file mode 100644 index 000000000..657abe04f --- /dev/null +++ b/hack/sigstore-test/.gitignore @@ -0,0 +1,2 @@ +keys/ +pki/ diff --git a/hack/sigstore-test/Makefile b/hack/sigstore-test/Makefile new file mode 100644 index 000000000..6b5e3ce68 --- /dev/null +++ b/hack/sigstore-test/Makefile @@ -0,0 +1,52 @@ +# Sigstore test harness +# Usage: +# make -f hack/sigstore-test/Makefile up # create cluster + registry +# make -f hack/sigstore-test/Makefile sigstore # install sigstore stack +# make -f hack/sigstore-test/Makefile build # build and load source-controller +# make -f hack/sigstore-test/Makefile cosign # fetch cosign v2 and v3 binaries +# make -f hack/sigstore-test/Makefile test # run signing/verification tests +# make -f hack/sigstore-test/Makefile all # do everything +# make -f hack/sigstore-test/Makefile down # tear down +# +# kind-cluster.yaml is committed with the default cluster name +# (sigstore-test) and registry ports (5555, 5557). To target a different +# cluster name or ports, render an override out-of-tree first: +# +# make -f hack/sigstore-test/Makefile kind-config \ +# CLUSTER_NAME=foo KIND_CONFIG_PATH=/tmp/foo.yaml +# KIND_CONFIG_PATH=/tmp/foo.yaml make -f hack/sigstore-test/Makefile up + +SCRIPT_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) + +.PHONY: all kind-config up registries sigstore tsa down build cosign test + +all: up registries sigstore tsa cosign build test + +# Optional: re-render kind-cluster.yaml from the template. Only needed when +# overriding CLUSTER_NAME / REG_LOCALHOST_PORT / REG2_LOCALHOST_PORT. +kind-config: + bash $(SCRIPT_DIR)/render-kind-config.sh + +up: + bash $(SCRIPT_DIR)/kind-up.sh + +registries: + bash $(SCRIPT_DIR)/registries-up.sh + +sigstore: + bash $(SCRIPT_DIR)/setup-sigstore.sh + +tsa: + bash $(SCRIPT_DIR)/setup-tsa.sh + +down: + bash $(SCRIPT_DIR)/kind-down.sh + +build: + bash $(SCRIPT_DIR)/build-and-load.sh + +cosign: + bash $(SCRIPT_DIR)/fetch-cosign.sh + +test: + bash $(SCRIPT_DIR)/test-signing.sh diff --git a/hack/sigstore-test/build-and-load.sh b/hack/sigstore-test/build-and-load.sh new file mode 100755 index 000000000..79c07469b --- /dev/null +++ b/hack/sigstore-test/build-and-load.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Build source-controller and load it into the kind cluster. +set -euo pipefail + +CLUSTER_NAME="${CLUSTER_NAME:-sigstore-test}" +IMG="${IMG:-test/source-controller}" +TAG="${TAG:-latest}" +BUILD_PLATFORM="${BUILD_PLATFORM:-linux/arm64}" + +REPO_ROOT="$(git rev-parse --show-toplevel)" + +echo ">>> building source-controller image" +cd "${REPO_ROOT}" +make docker-build IMG="${IMG}" TAG="${TAG}" BUILD_PLATFORMS="${BUILD_PLATFORM}" BUILD_ARGS=--load + +echo ">>> loading image into kind cluster ${CLUSTER_NAME}" +kind load docker-image --name "${CLUSTER_NAME}" "${IMG}:${TAG}" + +echo ">>> deploying source-controller" +make dev-deploy IMG="${IMG}" TAG="${TAG}" + +# dev-deploy reapplies the same :latest tag, so the Deployment spec is +# unchanged and an already-running pod will keep the old image. Force a +# rollout so re-runs of this script pick up the freshly loaded binary. +kubectl -n source-system rollout restart deploy/source-controller + +echo ">>> waiting for source-controller rollout" +kubectl -n source-system rollout status deploy/source-controller --timeout=2m + +echo ">>> source-controller deployed" +kubectl -n source-system get pods diff --git a/hack/sigstore-test/fetch-cosign.sh b/hack/sigstore-test/fetch-cosign.sh new file mode 100755 index 000000000..e93506bdf --- /dev/null +++ b/hack/sigstore-test/fetch-cosign.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Fetch a cosign v3 binary for the local sigstore test harness. +# +# CI guidance: when running this harness from a GitHub Actions workflow, do +# NOT run this script. Install cosign with the official action instead, which +# puts `cosign` on PATH where test-signing.sh expects it: +# +# - uses: sigstore/cosign-installer@v3 +# +# sigstore/cosign-installer only accepts `cosign-release`, `install-dir`, and +# `use-sudo` inputs. Each released version of the action hardcodes a default +# `cosign-release` (v3.0.6 at time of writing), so letting dependabot's +# github-actions ecosystem bump the action ref is what advances cosign. Avoid +# pinning `cosign-release:` unless you need a specific version, because +# dependabot does not edit `with:` input values. +# +# This script is a local-dev fallback that downloads cosign directly from +# GitHub releases. If a `cosign` (or `cosign-v3`) is already on PATH the +# download is skipped and the on-PATH binary is symlinked into ./bin. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BIN_DIR="${SCRIPT_DIR}/bin" +mkdir -p "${BIN_DIR}" + +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +ARCH="$(uname -m)" +case "${ARCH}" in + x86_64|amd64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + *) echo "unsupported arch: ${ARCH}"; exit 1 ;; +esac + +# cosign v3 (latest). Update via dependabot when invoked from CI through +# sigstore/cosign-installer. +COSIGN_V3_VERSION="${COSIGN_V3_VERSION:-v3.0.6}" +COSIGN_V3_URL="https://github.com/sigstore/cosign/releases/download/${COSIGN_V3_VERSION}/cosign-${OS}-${ARCH}" + +# fetch_binary stages a binary at ${dest} unless one of the following is true: +# - a binary named like ${dest} (or one of the extra PATH aliases in $4..) is +# already on PATH (e.g. `cosign` installed by sigstore/cosign-installer) +# - the file already exists (cached local copy) +# In the on-PATH cases the binary is symlinked into ./bin rather than fetched. +fetch_binary() { + local name="$1" url="$2" dest="$3" + shift 3 + local candidates=("$(basename "${dest}")" "$@") + local on_path="" + local c + for c in "${candidates[@]}"; do + on_path="$(command -v "${c}" || true)" + if [ -n "${on_path}" ] && [ "${on_path}" != "${dest}" ]; then + echo ">>> ${name} satisfied by '${c}' on PATH at ${on_path}; skipping download" + ln -sf "${on_path}" "${dest}" + break + fi + on_path="" + done + if [ -z "${on_path}" ]; then + if [ -f "${dest}" ]; then + echo ">>> ${name} already exists at ${dest}" + else + echo ">>> downloading ${name} from ${url}" + curl -fSL -o "${dest}" "${url}" + chmod +x "${dest}" + fi + fi + "${dest}" version 2>&1 | head -3 + echo "" +} + +# v3 is also satisfied by a plain `cosign` on PATH, which is what +# sigstore/cosign-installer provides. +fetch_binary "cosign-v3" "${COSIGN_V3_URL}" "${BIN_DIR}/cosign-v3" cosign + +echo "=== Cosign binary ready ===" +echo " v3: ${BIN_DIR}/cosign-v3" diff --git a/hack/sigstore-test/kind-cluster.yaml b/hack/sigstore-test/kind-cluster.yaml new file mode 100644 index 000000000..2c425b758 --- /dev/null +++ b/hack/sigstore-test/kind-cluster.yaml @@ -0,0 +1,20 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: +- role: control-plane + kubeadmConfigPatches: + - | + kind: ClusterConfiguration + apiServer: + extraArgs: + service-account-jwks-uri: "https://kubernetes.default.svc.cluster.local/openid/v1/jwks" +containerdConfigPatches: +- |- + [plugins."io.containerd.grpc.v1.cri".registry.mirrors."localhost:5555"] + endpoint = ["http://sigstore-test-registry:5000"] + [plugins."io.containerd.grpc.v1.cri".registry.mirrors."sigstore-test-registry:5000"] + endpoint = ["http://sigstore-test-registry:5000"] + [plugins."io.containerd.grpc.v1.cri".registry.mirrors."localhost:5557"] + endpoint = ["http://sigstore-test-registry2:5000"] + [plugins."io.containerd.grpc.v1.cri".registry.mirrors."sigstore-test-registry2:5000"] + endpoint = ["http://sigstore-test-registry2:5000"] diff --git a/hack/sigstore-test/kind-cluster.yaml.tpl b/hack/sigstore-test/kind-cluster.yaml.tpl new file mode 100644 index 000000000..971a365ef --- /dev/null +++ b/hack/sigstore-test/kind-cluster.yaml.tpl @@ -0,0 +1,20 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: +- role: control-plane + kubeadmConfigPatches: + - | + kind: ClusterConfiguration + apiServer: + extraArgs: + service-account-jwks-uri: "https://kubernetes.default.svc.cluster.local/openid/v1/jwks" +containerdConfigPatches: +- |- + [plugins."io.containerd.grpc.v1.cri".registry.mirrors."localhost:${REG_LOCALHOST_PORT}"] + endpoint = ["http://${CLUSTER_NAME}-registry:5000"] + [plugins."io.containerd.grpc.v1.cri".registry.mirrors."${CLUSTER_NAME}-registry:5000"] + endpoint = ["http://${CLUSTER_NAME}-registry:5000"] + [plugins."io.containerd.grpc.v1.cri".registry.mirrors."localhost:${REG2_LOCALHOST_PORT}"] + endpoint = ["http://${CLUSTER_NAME}-registry2:5000"] + [plugins."io.containerd.grpc.v1.cri".registry.mirrors."${CLUSTER_NAME}-registry2:5000"] + endpoint = ["http://${CLUSTER_NAME}-registry2:5000"] diff --git a/hack/sigstore-test/kind-down.sh b/hack/sigstore-test/kind-down.sh new file mode 100755 index 000000000..6d0615c1a --- /dev/null +++ b/hack/sigstore-test/kind-down.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +CLUSTER_NAME="${CLUSTER_NAME:-sigstore-test}" +REG_NAME="${CLUSTER_NAME}-registry" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PF_PID_FILE="${SCRIPT_DIR}/.port-forward.pids" + +echo ">>> tearing down sigstore test environment" + +echo ">>> killing port-forwards" +if [ -f "${PF_PID_FILE}" ]; then + cmd="" + while IFS= read -r pid; do + if [ -z "${pid}" ]; then + continue + fi + cmd="$(ps -p "${pid}" -o command= 2>/dev/null || true)" + if [[ "${cmd}" == *kubectl*"port-forward"* ]]; then + kill "${pid}" 2>/dev/null || true + fi + done < "${PF_PID_FILE}" + rm -f "${PF_PID_FILE}" +fi + +echo ">>> uninstalling scaffold Helm release" +helm uninstall scaffold -n sigstore 2>/dev/null || true + +echo ">>> deleting kind cluster ${CLUSTER_NAME}" +kind delete cluster --name "${CLUSTER_NAME}" 2>/dev/null || true + +echo ">>> removing registries" +docker rm -f "${REG_NAME}" 2>/dev/null || true +docker rm -f "${CLUSTER_NAME}-registry2" 2>/dev/null || true + +echo ">>> clearing cluster-bound PKI material" +rm -rf "${SCRIPT_DIR}/pki" "${SCRIPT_DIR}/keys" + +echo ">>> done" diff --git a/hack/sigstore-test/kind-up.sh b/hack/sigstore-test/kind-up.sh new file mode 100755 index 000000000..7046c60c7 --- /dev/null +++ b/hack/sigstore-test/kind-up.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Create the kind cluster for the sigstore harness. +# +# Reads the rendered kind cluster config from $KIND_CONFIG_PATH (defaults to +# ./kind-cluster.yaml). Run `make kind-config` first, or invoke `make up` +# which depends on the kind-config target. +# +# Registries are spun up by registries-up.sh; the sigstore stack is installed +# by setup-sigstore.sh. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLUSTER_NAME="${CLUSTER_NAME:-sigstore-test}" +NODE_IMAGE="${KIND_NODE_IMAGE:-kindest/node:v1.32.2}" +KIND_CONFIG_PATH="${KIND_CONFIG_PATH:-${SCRIPT_DIR}/kind-cluster.yaml}" + +if [ ! -s "${KIND_CONFIG_PATH}" ]; then + echo "kind cluster config not found at ${KIND_CONFIG_PATH}" >&2 + echo "run 'make kind-config' first" >&2 + exit 1 +fi + +if ! kind get clusters 2>/dev/null | grep -q "^${CLUSTER_NAME}$"; then + echo ">>> creating kind cluster ${CLUSTER_NAME} from ${KIND_CONFIG_PATH}" + kind create cluster --name "${CLUSTER_NAME}" --image "${NODE_IMAGE}" --config "${KIND_CONFIG_PATH}" +else + echo ">>> cluster ${CLUSTER_NAME} already exists" +fi + +echo ">>> waiting for cluster readiness" +kubectl wait node "${CLUSTER_NAME}-control-plane" --for=condition=ready --timeout=2m +kubectl wait --for=condition=ready -n kube-system -l k8s-app=kube-dns pod --timeout=2m + +echo "" +echo "=== Cluster Ready ===" +echo " cluster: ${CLUSTER_NAME}" +echo "" +echo "Next: registries-up.sh, setup-sigstore.sh" diff --git a/hack/sigstore-test/port-forward.sh b/hack/sigstore-test/port-forward.sh new file mode 100755 index 000000000..f7c138118 --- /dev/null +++ b/hack/sigstore-test/port-forward.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Set up port-forwarding to sigstore services and export env vars. +# Source this file: source hack/sigstore-test/port-forward.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PF_PID_FILE="${SCRIPT_DIR}/.port-forward.pids" + +stop_port_forwards() { + if [ ! -f "${PF_PID_FILE}" ]; then + return + fi + + local cmd + while IFS= read -r pid; do + if [ -z "${pid}" ]; then + continue + fi + cmd="$(ps -p "${pid}" -o command= 2>/dev/null || true)" + if [[ "${cmd}" == *kubectl*"port-forward"* ]]; then + kill "${pid}" 2>/dev/null || true + fi + done < "${PF_PID_FILE}" + rm -f "${PF_PID_FILE}" +} + +start_port_forward() { + kubectl "$@" &>/dev/null & + echo "$!" >> "${PF_PID_FILE}" +} + +echo ">>> setting up port-forwarding to sigstore services" + +stop_port_forwards +: > "${PF_PID_FILE}" +sleep 1 + +# Rekor +start_port_forward -n rekor-system port-forward svc/rekor-server 3000:80 +# Fulcio +start_port_forward -n fulcio-system port-forward svc/fulcio-server 5555:80 +# TUF +start_port_forward -n tuf-system port-forward svc/tuf 8081:80 + +sleep 2 + +export REKOR_URL="http://localhost:3000" +export FULCIO_URL="http://localhost:5555" +export TUF_MIRROR="http://localhost:8081" + +echo " REKOR_URL=${REKOR_URL}" +echo " FULCIO_URL=${FULCIO_URL}" +echo " TUF_MIRROR=${TUF_MIRROR}" +echo "" +echo "Port-forwarding active. PIDs recorded in ${PF_PID_FILE}." diff --git a/hack/sigstore-test/registries-up.sh b/hack/sigstore-test/registries-up.sh new file mode 100755 index 000000000..b2574340d --- /dev/null +++ b/hack/sigstore-test/registries-up.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Spin up the two local OCI registries the sigstore harness consumes, +# coupled to CLUSTER_NAME so each kind cluster has its own pair. +# +# ${CLUSTER_NAME}-registry zot, OCI 1.1 referrers API +# ${CLUSTER_NAME}-registry2 registry:2, tag-based referrers fallback +# +# Both containers attach to the kind Docker network so kubelet can pull +# images by their in-cluster DNS name (which matches the container name). +# Run kind-up.sh first so the kind network exists. +set -euo pipefail + +CLUSTER_NAME="${CLUSTER_NAME:-sigstore-test}" +REG_NAME="${CLUSTER_NAME}-registry" +REG2_NAME="${CLUSTER_NAME}-registry2" +REG_LOCALHOST_PORT="${REG_LOCALHOST_PORT:-5555}" +REG2_LOCALHOST_PORT="${REG2_LOCALHOST_PORT:-5557}" +REG_CLUSTER_PORT=5000 +ZOT_VERSION="${ZOT_VERSION:-v2.1.7}" +REGISTRY_VERSION="${REGISTRY_VERSION:-2.8.3}" + +ARCH=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/amd64/') +ZOT_IMAGE="ghcr.io/project-zot/zot-linux-${ARCH}:${ZOT_VERSION}" +REGISTRY_IMAGE="registry:${REGISTRY_VERSION}" + +# Primary registry: zot (supports OCI 1.1 referrers API natively) +if [ "$(docker inspect -f '{{.State.Running}}' "${REG_NAME}" 2>/dev/null || true)" != 'true' ]; then + echo ">>> starting ${ZOT_IMAGE} as ${REG_NAME} on localhost:${REG_LOCALHOST_PORT}" + docker run -d --restart=always \ + -p "127.0.0.1:${REG_LOCALHOST_PORT}:${REG_CLUSTER_PORT}" \ + --name "${REG_NAME}" \ + "${ZOT_IMAGE}" +else + echo ">>> registry ${REG_NAME} already running" +fi + +# Fallback registry: registry:2 (tag-based referrers only) +if [ "$(docker inspect -f '{{.State.Running}}' "${REG2_NAME}" 2>/dev/null || true)" != 'true' ]; then + echo ">>> starting ${REGISTRY_IMAGE} as ${REG2_NAME} on localhost:${REG2_LOCALHOST_PORT}" + docker run -d --restart=always \ + -p "127.0.0.1:${REG2_LOCALHOST_PORT}:${REG_CLUSTER_PORT}" \ + --name "${REG2_NAME}" \ + "${REGISTRY_IMAGE}" +else + echo ">>> registry:2 ${REG2_NAME} already running" +fi + +# Connect both registries to the kind Docker network so the cluster +# resolves them via their container names. +for name in "${REG_NAME}" "${REG2_NAME}"; do + if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${name}" 2>/dev/null)" = 'null' ]; then + echo ">>> connecting ${name} to kind network" + docker network connect "kind" "${name}" + fi +done + +echo "" +echo "=== Registries Ready ===" +echo " primary: localhost:${REG_LOCALHOST_PORT} (in-cluster: ${REG_NAME}:${REG_CLUSTER_PORT})" +echo " fallback: localhost:${REG2_LOCALHOST_PORT} (in-cluster: ${REG2_NAME}:${REG_CLUSTER_PORT})" diff --git a/hack/sigstore-test/render-kind-config.sh b/hack/sigstore-test/render-kind-config.sh new file mode 100755 index 000000000..229c891d2 --- /dev/null +++ b/hack/sigstore-test/render-kind-config.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Render hack/sigstore-test/kind-cluster.yaml from kind-cluster.yaml.tpl, +# substituting CLUSTER_NAME and registry host ports. The rendered file is +# .gitignored; regenerate it via `make kind-config` whenever cluster name +# or ports change. CI workflows can override the output path via +# KIND_CONFIG_PATH so the rendered config survives a subsequent +# actions/checkout step run by setup-kubernetes. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLUSTER_NAME="${CLUSTER_NAME:-sigstore-test}" +REG_LOCALHOST_PORT="${REG_LOCALHOST_PORT:-5555}" +REG2_LOCALHOST_PORT="${REG2_LOCALHOST_PORT:-5557}" +KIND_CONFIG_PATH="${KIND_CONFIG_PATH:-${SCRIPT_DIR}/kind-cluster.yaml}" + +mkdir -p "$(dirname "${KIND_CONFIG_PATH}")" +sed \ + -e "s|\${CLUSTER_NAME}|${CLUSTER_NAME}|g" \ + -e "s|\${REG_LOCALHOST_PORT}|${REG_LOCALHOST_PORT}|g" \ + -e "s|\${REG2_LOCALHOST_PORT}|${REG2_LOCALHOST_PORT}|g" \ + "${SCRIPT_DIR}/kind-cluster.yaml.tpl" > "${KIND_CONFIG_PATH}" + +echo "rendered kind cluster config to ${KIND_CONFIG_PATH}" +echo " CLUSTER_NAME=${CLUSTER_NAME}" +echo " REG_LOCALHOST_PORT=${REG_LOCALHOST_PORT}" +echo " REG2_LOCALHOST_PORT=${REG2_LOCALHOST_PORT}" diff --git a/hack/sigstore-test/setup-sigstore.sh b/hack/sigstore-test/setup-sigstore.sh new file mode 100755 index 000000000..d329d076a --- /dev/null +++ b/hack/sigstore-test/setup-sigstore.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash +# Install sigstore stack into the kind cluster using the scaffold Helm chart. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SIGSTORE_SCAFFOLD_VERSION="${SIGSTORE_SCAFFOLD_VERSION:-0.6.109}" + +echo "=== Installing Sigstore Stack ===" +echo ">>> cluster node / Kubernetes version:" +kubectl get nodes -o wide || true +kubectl version -o yaml 2>/dev/null | grep -iE 'gitVersion|major|minor' || true + +helm repo add sigstore https://sigstore.github.io/helm-charts 2>/dev/null || true +helm repo update sigstore + +# Allow unauthenticated OIDC discovery so Fulcio can fetch the cluster's +# JWKS to validate service-account tokens. Without this the scaffold's +# Fulcio/CTLog jobs never reach Complete and the helm install blocks until +# timeout. Paired with the apiserver service-account-jwks-uri set on the +# kind cluster config. +kubectl create clusterrolebinding oidc-reviewer \ + --clusterrole=system:service-account-issuer-discovery \ + --group=system:unauthenticated 2>/dev/null || true + +# DEBUG: dump cluster state across all sigstore namespaces. Called on a +# failed/timed-out helm install so the Actions log shows which pods or jobs +# are unhealthy instead of a silent hang. Remove once CI is green. +dump_sigstore_state() { + echo "::group::sigstore cluster state (debug)" + + echo "--- nodes ---" + kubectl get nodes -o wide 2>/dev/null || true + echo "--- node conditions / capacity / allocatable ---" + kubectl describe nodes 2>/dev/null \ + | grep -iE 'Name:|MemoryPressure|DiskPressure|PIDPressure|Ready|cpu:|memory:|ephemeral-storage:|Allocated resources|Non-terminated' || true + echo "--- node-level kernel/OOM hints (kind node is a container) ---" + for node in $(kubectl get nodes -o name 2>/dev/null | sed 's|node/||'); do + docker exec "${node}" sh -c 'dmesg 2>/dev/null | grep -iE "oom|killed process|out of memory" | tail -15' 2>/dev/null || true + done + + for ns in sigstore trillian-system rekor-system fulcio-system ctlog-system tuf-system; do + kubectl get ns "${ns}" &>/dev/null || continue + echo "--- namespace ${ns}: pods (with restart counts) ---" + kubectl -n "${ns}" get pods -o wide 2>/dev/null || true + echo "--- namespace ${ns}: jobs ---" + kubectl -n "${ns}" get jobs 2>/dev/null || true + # Dump every pod that is not both Running and Ready. A pod can be phase + # Running yet crash-looping (e.g. trillian-mysql), so filter on the Ready + # condition rather than phase, and prefer --previous logs to catch the + # output from the container instance that just died. + for pod in $(kubectl -n "${ns}" get pods -o name 2>/dev/null); do + ready=$(kubectl -n "${ns}" get "${pod}" \ + -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true) + [ "${ready}" = "True" ] && continue + echo "--- ${ns}/${pod} (Ready=${ready:-}) describe ---" + kubectl -n "${ns}" describe "${pod}" 2>/dev/null \ + | grep -iE 'State:|Reason:|Exit Code:|Restart Count:|Message:|Last State:|Started:|Finished:|Events:|Warning|Liveness|Readiness' || true + echo "--- ${ns}/${pod} previous-instance logs ---" + kubectl -n "${ns}" logs "${pod}" --all-containers --previous --tail=80 2>/dev/null \ + || echo " (no previous logs)" + echo "--- ${ns}/${pod} current logs ---" + kubectl -n "${ns}" logs "${pod}" --all-containers --tail=80 2>/dev/null \ + || echo " (no current logs)" + done + done + + echo "--- recent warning events (all namespaces) ---" + kubectl get events -A --field-selector type=Warning \ + --sort-by=.lastTimestamp 2>/dev/null | tail -60 || true + echo "::endgroup::" +} + +# Override the Trillian database image and probes for MySQL 8.x via a +# checked-in values file (see trillian.mysql.values.yaml for the rationale). +echo ">>> installing sigstore/scaffold ${SIGSTORE_SCAFFOLD_VERSION} (this takes a few minutes)..." + +# DEBUG: while `helm --wait` blocks (up to 10m), snapshot trillian-mysql a few +# times in the background. By the time the wait fails the pod is deep in +# CrashLoopBackOff and --previous only shows the latest crash, so capture the +# early instances too. Remove once CI is green. +watch_mysql() { + local ns=trillian-system + for delay in 45 90 150 240; do + sleep "${delay}" + echo "::group::trillian-mysql snapshot @ ${delay}s" + kubectl -n "${ns}" get pods -l app.kubernetes.io/name=mysql -o wide 2>/dev/null || true + for pod in $(kubectl -n "${ns}" get pods -l app.kubernetes.io/name=mysql -o name 2>/dev/null); do + kubectl -n "${ns}" describe "${pod}" 2>/dev/null \ + | grep -iE 'State:|Reason:|Exit Code:|Restart Count:|Last State:|Message:' || true + echo " -- current logs --" + kubectl -n "${ns}" logs "${pod}" --tail=60 2>/dev/null || true + echo " -- previous logs --" + kubectl -n "${ns}" logs "${pod}" --previous --tail=60 2>/dev/null || true + done + echo "::endgroup::" + done +} +watch_mysql & +WATCH_PID=$! + +if ! helm upgrade --install scaffold sigstore/scaffold \ + --version "${SIGSTORE_SCAFFOLD_VERSION}" \ + --namespace sigstore --create-namespace \ + --values "${SCRIPT_DIR}/trillian.mysql.values.yaml" \ + --timeout 10m \ + --wait; +then + echo "ERROR: sigstore scaffold install failed or timed out; dumping state" >&2 + kill "${WATCH_PID}" 2>/dev/null || true + dump_sigstore_state + exit 1 +fi +kill "${WATCH_PID}" 2>/dev/null || true + +echo ">>> waiting for sigstore namespaces" +for ns in trillian-system rekor-system fulcio-system ctlog-system tuf-system; do + if kubectl get ns "${ns}" &>/dev/null; then + echo " ${ns}: waiting for deployments..." + for deploy in $(kubectl get deploy -n "${ns}" -o name 2>/dev/null); do + echo " >>> rollout status ${ns}/${deploy} (timeout 5m)" + kubectl rollout status --timeout=5m -n "${ns}" "${deploy}" 2>/dev/null || true + done + echo " ${ns}: waiting for jobs to complete (timeout 5m)" + kubectl wait --timeout=5m -n "${ns}" --for=condition=Complete jobs --all 2>/dev/null || true + fi +done + +echo "=== Extracting PKI Material ===" +mkdir -p "${SCRIPT_DIR}/pki" + +kubectl -n fulcio-system get secrets fulcio-pub-key -ojsonpath='{.data.cert}' 2>/dev/null \ + | base64 -d > "${SCRIPT_DIR}/pki/fulcio.crt.pem" && echo " extracted fulcio.crt.pem" || echo " WARN: fulcio cert not found" + +kubectl -n ctlog-system get secret ctlog-public-key -ojsonpath='{.data.public}' 2>/dev/null \ + | base64 -d > "${SCRIPT_DIR}/pki/ctfe.pub" && echo " extracted ctfe.pub" || echo " WARN: ctlog pub key not found" + +# Rekor public key is fetched via API since the scaffold chart uses an in-memory signer +echo " fetching rekor public key via API..." +kubectl -n rekor-system port-forward svc/rekor-server 3000:80 &>/dev/null & +PF_PID=$! +sleep 2 +if curl -sf http://localhost:3000/api/v1/log/publicKey > "${SCRIPT_DIR}/pki/rekor.pub" 2>/dev/null; then + echo " extracted rekor.pub" +else + echo " WARN: could not fetch rekor public key" +fi +kill $PF_PID 2>/dev/null || true + +echo "" +echo "=== Patching Fulcio config for cluster.local issuer ===" +# kind clusters use https://kubernetes.default.svc.cluster.local as the OIDC +# issuer for ServiceAccount tokens; the scaffold chart's default Fulcio +# config only accepts https://kubernetes.default.svc, so we replace the +# configmap with one that accepts both and restart the server. +kubectl apply -f - <<'EOF' +apiVersion: v1 +kind: ConfigMap +metadata: + name: fulcio-server-config + namespace: fulcio-system +data: + config.json: | + { + "OIDCIssuers": { + "https://kubernetes.default.svc": { + "IssuerURL": "https://kubernetes.default.svc", + "ClientID": "sigstore", + "Type": "kubernetes" + }, + "https://kubernetes.default.svc.cluster.local": { + "IssuerURL": "https://kubernetes.default.svc.cluster.local", + "ClientID": "sigstore", + "Type": "kubernetes" + } + }, + "MetaIssuers": { + "https://kubernetes.*.svc": { + "ClientID": "sigstore", + "Type": "kubernetes" + } + } + } +EOF +kubectl -n fulcio-system rollout restart deploy/fulcio-server +echo ">>> waiting for fulcio-server rollout (timeout 2m)" +kubectl -n fulcio-system rollout status deploy/fulcio-server --timeout=2m + +echo "" +echo "=== Exposing rekor and fulcio via NodePort ===" +# The cosign CLI runs outside the cluster during test-signing.sh; expose +# rekor-server and fulcio-server as NodePorts so the host can reach them +# via the kind control-plane node IP. +kubectl apply -f - <<'EOF' +apiVersion: v1 +kind: Service +metadata: + name: rekor-np + namespace: rekor-system +spec: + type: NodePort + selector: + app.kubernetes.io/component: server + app.kubernetes.io/instance: scaffold + app.kubernetes.io/name: rekor + ports: + - name: http + port: 80 + targetPort: 3000 +--- +apiVersion: v1 +kind: Service +metadata: + name: fulcio-np + namespace: fulcio-system +spec: + type: NodePort + selector: + app.kubernetes.io/instance: scaffold + app.kubernetes.io/name: fulcio + ports: + - name: http + port: 80 + targetPort: 5555 +EOF + +echo "" +echo "=== Sigstore Stack Ready ===" +echo " pki: ${SCRIPT_DIR}/pki/" +ls -la "${SCRIPT_DIR}/pki/" 2>/dev/null diff --git a/hack/sigstore-test/setup-tsa.sh b/hack/sigstore-test/setup-tsa.sh new file mode 100755 index 000000000..cd1fffc64 --- /dev/null +++ b/hack/sigstore-test/setup-tsa.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Generate a TSA cert chain + encrypted private key and reconfigure the +# scaffold helm release to deploy timestamp-server with the `file` signer. +# This is the simplest path to a running TSA inside kind because the chart's +# default tink signer requires keysets the scaffold chart does not ship. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TSA_DIR="${SCRIPT_DIR}/pki/tsa" +SIGSTORE_SCAFFOLD_VERSION="${SIGSTORE_SCAFFOLD_VERSION:-0.6.109}" +mkdir -p "${TSA_DIR}" + +PASSWORD="${TSA_PASSWORD:-flux-tsa-test}" + +if [ ! -s "${TSA_DIR}/leaf.key" ]; then + echo ">>> generating TSA cert chain (root + leaf)" + # Root CA + openssl req -x509 -newkey rsa:2048 -days 3650 -nodes \ + -keyout "${TSA_DIR}/root.key" \ + -out "${TSA_DIR}/root.crt" \ + -subj '/CN=Flux Test TSA Root/O=Flux/C=US' \ + -addext 'basicConstraints=critical,CA:TRUE' \ + -addext 'keyUsage=critical,digitalSignature,keyCertSign,cRLSign' \ + >/dev/null 2>&1 + + # Leaf with timestamping EKU. timestamp-server v2 requires ECDSA keys. + # We skip the intermediate so timestamp-authority's enforce-intermediate-eku + # check (default true) does not apply. + openssl ecparam -name prime256v1 -genkey -noout -out "${TSA_DIR}/leaf.key.unencrypted" + openssl req -new -key "${TSA_DIR}/leaf.key.unencrypted" \ + -out "${TSA_DIR}/leaf.csr" \ + -subj '/CN=Flux Test TSA/O=Flux/C=US' \ + >/dev/null 2>&1 + cat > "${TSA_DIR}/leaf.ext" </dev/null 2>&1 + + # Encrypt the leaf key with the password (PKCS#8 PEM, AES-256). + openssl pkcs8 -topk8 -v2 aes-256-cbc \ + -in "${TSA_DIR}/leaf.key.unencrypted" \ + -out "${TSA_DIR}/leaf.key" \ + -passout "pass:${PASSWORD}" + rm -f "${TSA_DIR}/leaf.key.unencrypted" + + # Chain order expected by timestamp-server v2: leaf, ..., root. + cat "${TSA_DIR}/leaf.crt" "${TSA_DIR}/root.crt" > "${TSA_DIR}/chain.pem" +fi + +CHAIN_CONTENT="$(cat "${TSA_DIR}/chain.pem")" + +echo ">>> creating tsa-server-secret" +# Pre-create the namespace and stamp Helm ownership metadata so the next +# `helm upgrade scaffold` can adopt it (the scaffold chart's TSA subchart +# templates a Namespace resource for tsa-system, which would otherwise +# collide with the namespace we need now to hold tsa-server-secret). +kubectl create namespace tsa-system --dry-run=client -o yaml | kubectl apply -f - +kubectl label namespace tsa-system \ + app.kubernetes.io/managed-by=Helm --overwrite +kubectl annotate namespace tsa-system \ + meta.helm.sh/release-name=scaffold \ + meta.helm.sh/release-namespace=sigstore --overwrite +kubectl -n tsa-system create secret generic tsa-server-secret \ + --from-file=private="${TSA_DIR}/leaf.key" \ + --from-literal=password="${PASSWORD}" \ + --dry-run=client -o yaml | kubectl apply -f - + +echo ">>> upgrading scaffold ${SIGSTORE_SCAFFOLD_VERSION} helm release with file-signer TSA" +helm upgrade scaffold sigstore/scaffold \ + --version "${SIGSTORE_SCAFFOLD_VERSION}" \ + --namespace sigstore \ + --reuse-values \ + --set tsa.enabled=true \ + --set tsa.server.args.signer=file \ + --set-string tsa.server.args.cert_chain="${CHAIN_CONTENT}" \ + --timeout 5m --wait + +echo ">>> waiting for tsa-server rollout" +kubectl -n tsa-system rollout status deploy/tsa-server --timeout=2m + +echo ">>> creating tsa-np NodePort service" +kubectl apply -f - <<'EOF' +--- +apiVersion: v1 +kind: Service +metadata: + name: tsa-np + namespace: tsa-system +spec: + type: NodePort + selector: + app.kubernetes.io/instance: scaffold + app.kubernetes.io/name: tsa + ports: + - name: http + port: 80 + targetPort: 5555 +EOF + +echo "=== TSA Ready ===" +echo " chain: ${TSA_DIR}/chain.pem" +echo " leaf cert: ${TSA_DIR}/leaf.crt" +kubectl -n tsa-system get pods,svc diff --git a/hack/sigstore-test/trillian.mysql.values.yaml b/hack/sigstore-test/trillian.mysql.values.yaml new file mode 100644 index 000000000..a06c07bcb --- /dev/null +++ b/hack/sigstore-test/trillian.mysql.values.yaml @@ -0,0 +1,41 @@ +# Trillian database overrides for the sigstore scaffold Helm release. +# +# The chart defaults to an old db_server tag built on MySQL 5.7, which hits +# MySQL bug #96525: on newer kindest/node images (kind v0.32.0 ships v1.36.1) +# containerd runs with an effectively unbounded RLIMIT_NOFILE (~1e9), and +# MySQL 5.7 sizes an allocation against that limit, ballooning to >8GiB RSS +# and getting OOM-killed in seconds. MySQL 8.x fixed the bug; v1.7.3 ships +# MySQL 8.4.8, which Trillian and Sigstore both run in production. +# +# Two compatibility fixups for the 8.4 image: +# - args: the chart default --ignore-db-dir=lost+found was removed in MySQL +# 8.0 and makes 8.x abort on startup, so clear it. +# - probes: the chart execs /etc/init.d/mysql, which the Oracle-Linux-based +# 8.4 image does not ship; use `mysqladmin ping` instead (exit 0 once the +# server answers, even before credentials are configured). The chart only +# renders liveness/readiness (no startupProbe), so the liveness probe uses +# a generous initialDelaySeconds + failureThreshold to avoid killing the +# container during a slow first-run data-dir init on a CI runner. +trillian: + mysql: + image: + # Same db_server image/registry the chart uses by default, pinned to + # v1.7.3 (MySQL 8.4.8) instead of the chart's v1.5.3 (MySQL 5.7). + registry: gcr.io + repository: trillian-opensource-ci/db_server + version: v1.7.3 + args: [] + livenessProbe: + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 18 + exec: + command: ["mysqladmin", "ping"] + readinessProbe: + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + exec: + command: ["mysqladmin", "ping"] From 858404eae108310f18ffeede2155b597ab23ea47 Mon Sep 17 00:00:00 2001 From: leigh capili Date: Wed, 17 Jun 2026 22:06:15 -0600 Subject: [PATCH 2/6] hack: match local ARCH for ci scripts Signed-off-by: leigh capili --- hack/ci/e2e.sh | 3 ++- hack/sigstore-test/build-and-load.sh | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/hack/ci/e2e.sh b/hack/ci/e2e.sh index ee567c70d..e91e00bcc 100755 --- a/hack/ci/e2e.sh +++ b/hack/ci/e2e.sh @@ -5,7 +5,8 @@ set -eoux pipefail CREATE_CLUSTER="${CREATE_CLUSTER:-true}" KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-kind}" LOAD_IMG_INTO_KIND="${LOAD_IMG_INTO_KIND:-true}" -BUILD_PLATFORM="${BUILD_PLATFORM:-linux/amd64}" +ARCH=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/amd64/') +BUILD_PLATFORM="${BUILD_PLATFORM:-linux/${ARCH}}" IMG=test/source-controller TAG=latest diff --git a/hack/sigstore-test/build-and-load.sh b/hack/sigstore-test/build-and-load.sh index 79c07469b..7f132a3db 100755 --- a/hack/sigstore-test/build-and-load.sh +++ b/hack/sigstore-test/build-and-load.sh @@ -5,7 +5,8 @@ set -euo pipefail CLUSTER_NAME="${CLUSTER_NAME:-sigstore-test}" IMG="${IMG:-test/source-controller}" TAG="${TAG:-latest}" -BUILD_PLATFORM="${BUILD_PLATFORM:-linux/arm64}" +ARCH=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/amd64/') +BUILD_PLATFORM="${BUILD_PLATFORM:-linux/${ARCH}}" REPO_ROOT="$(git rev-parse --show-toplevel)" From ec741e732064ddfa6411622c3998e6e230baa610 Mon Sep 17 00:00:00 2001 From: leigh capili Date: Wed, 17 Jun 2026 22:06:15 -0600 Subject: [PATCH 3/6] hack: cover existing cosign verification in e2e Add test-signing.sh and OCIRepository fixtures exercising the cosign verification flows source-controller already supports: v2/v3 key-pair, v2/v3 keyless with a custom trusted_root.json, v3 key-pair with tlog, combined secretRef plus trustedRootSecretRef, registry auth, and registry:2 tag fallback. Negative cases assert Ready=False with reason VerificationError for a wrong key, wrong identity, and wrong Rekor key. Assisted-by: GitHub Copilot CLI/gpt-5.5 Assisted-by: Kiro/opus-4.8 Signed-off-by: leigh capili --- hack/sigstore-test/test-signing.sh | 251 ++++++++++++++++++ .../combined-secretref-trustedroot.yaml | 17 ++ .../sigstore-test/testdata/registry-auth.yaml | 17 ++ .../testdata/registry2-fallback.yaml | 48 ++++ hack/sigstore-test/testdata/v2-key.yaml | 15 ++ .../testdata/v2-keyless-trustedroot.yaml | 18 ++ hack/sigstore-test/testdata/v3-key-tlog.yaml | 15 ++ hack/sigstore-test/testdata/v3-key.yaml | 15 ++ .../testdata/v3-keyless-trustedroot.yaml | 18 ++ .../testdata/wrong-identity.yaml | 18 ++ hack/sigstore-test/testdata/wrong-key.yaml | 15 ++ .../testdata/wrong-rekor-key.yaml | 18 ++ 12 files changed, 465 insertions(+) create mode 100755 hack/sigstore-test/test-signing.sh create mode 100644 hack/sigstore-test/testdata/combined-secretref-trustedroot.yaml create mode 100644 hack/sigstore-test/testdata/registry-auth.yaml create mode 100644 hack/sigstore-test/testdata/registry2-fallback.yaml create mode 100644 hack/sigstore-test/testdata/v2-key.yaml create mode 100644 hack/sigstore-test/testdata/v2-keyless-trustedroot.yaml create mode 100644 hack/sigstore-test/testdata/v3-key-tlog.yaml create mode 100644 hack/sigstore-test/testdata/v3-key.yaml create mode 100644 hack/sigstore-test/testdata/v3-keyless-trustedroot.yaml create mode 100644 hack/sigstore-test/testdata/wrong-identity.yaml create mode 100644 hack/sigstore-test/testdata/wrong-key.yaml create mode 100644 hack/sigstore-test/testdata/wrong-rekor-key.yaml diff --git a/hack/sigstore-test/test-signing.sh b/hack/sigstore-test/test-signing.sh new file mode 100755 index 000000000..f323dec07 --- /dev/null +++ b/hack/sigstore-test/test-signing.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +# test-signing.sh: Validate cosign v2/v3 x key-pair/keyless verification flows +# against a custom Sigstore trusted root. +# +# Prerequisites (driven by hack/sigstore-test/Makefile): +# - kind cluster + registries: make up registries +# - sigstore stack + fulcio config + rekor/fulcio NodePorts: make sigstore +# - source-controller deployed: make build +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PKI_DIR="${SCRIPT_DIR}/pki" +KEYS_DIR="${SCRIPT_DIR}/keys" +TESTDATA="${SCRIPT_DIR}/testdata" + +REG_LOCALHOST_PORT="${REG_LOCALHOST_PORT:-5555}" +REG2_LOCALHOST_PORT="${REG2_LOCALHOST_PORT:-5557}" +REG="localhost:${REG_LOCALHOST_PORT}" +REG2="localhost:${REG2_LOCALHOST_PORT}" +NS="source-system" + +NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}') +REKOR_NP=$(kubectl -n rekor-system get svc rekor-np -o jsonpath='{.spec.ports[0].nodePort}') +FULCIO_NP=$(kubectl -n fulcio-system get svc fulcio-np -o jsonpath='{.spec.ports[0].nodePort}') +REKOR_URL="http://${NODE_IP}:${REKOR_NP}" +FULCIO_URL="http://${NODE_IP}:${FULCIO_NP}" + +# --- Setup keys and secrets --- + +mkdir -p "$KEYS_DIR" "$PKI_DIR" + +if [ ! -f "$KEYS_DIR/test.key" ]; then + COSIGN_PASSWORD="" cosign generate-key-pair --output-key-prefix="$KEYS_DIR/test" +fi +if [ ! -f "$KEYS_DIR/wrong.key" ]; then + COSIGN_PASSWORD="" cosign generate-key-pair --output-key-prefix="$KEYS_DIR/wrong" +fi +if [ ! -f "$KEYS_DIR/signing-config-notlog.json" ]; then + cosign signing-config create --out "$KEYS_DIR/signing-config-notlog.json" +fi + +# Full trusted root: Fulcio + Rekor + ctfe. Regenerated every run because +# the underlying ctfe / rekor / fulcio keys are cluster-scoped and a stale +# cache against a fresh cluster would mis-verify SCTs and inclusion proofs. +cosign trusted-root create \ + --fulcio="url=http://fulcio-server.fulcio-system.svc,certificate-chain=$PKI_DIR/fulcio.crt.pem" \ + --rekor="url=http://rekor-server.rekor-system.svc,public-key=$PKI_DIR/rekor.pub,start-time=2024-01-01T00:00:00Z" \ + --ctfe="url=http://ctlog.ctlog-system.svc,public-key=$PKI_DIR/ctfe.pub,start-time=2024-01-01T00:00:00Z" \ + --out "$PKI_DIR/trusted_root.json" + +# Wrong trusted root: replace the Rekor public key with a key that did not +# sign the entries. Used for negative tests. +cosign trusted-root create \ + --fulcio="url=http://fulcio-server.fulcio-system.svc,certificate-chain=$PKI_DIR/fulcio.crt.pem" \ + --rekor="url=http://rekor-server.rekor-system.svc,public-key=$KEYS_DIR/wrong.pub,start-time=2024-01-01T00:00:00Z" \ + --ctfe="url=http://ctlog.ctlog-system.svc,public-key=$PKI_DIR/ctfe.pub,start-time=2024-01-01T00:00:00Z" \ + --out "$PKI_DIR/wrong_trusted_root.json" + +kubectl -n "$NS" create secret generic cosign-test-key \ + --from-file=cosign.pub="$KEYS_DIR/test.pub" --dry-run=client -o yaml | kubectl apply -f - +kubectl -n "$NS" create secret generic cosign-wrong-key \ + --from-file=cosign.pub="$KEYS_DIR/wrong.pub" --dry-run=client -o yaml | kubectl apply -f - +kubectl -n "$NS" create secret generic sigstore-trusted-root \ + --from-file=trusted_root.json="$PKI_DIR/trusted_root.json" --dry-run=client -o yaml | kubectl apply -f - +kubectl -n "$NS" create secret generic sigstore-wrong-root \ + --from-file=trusted_root.json="$PKI_DIR/wrong_trusted_root.json" --dry-run=client -o yaml | kubectl apply -f - +kubectl -n "$NS" create secret docker-registry registry-creds \ + --docker-server="sigstore-test-registry:5000" \ + --docker-username=user --docker-password=pass \ + --dry-run=client -o yaml | kubectl apply -f - + +# --- Helper --- + +push_artifact() { + local ref="$1" + local tmp + tmp=$(mktemp -d) + echo "{\"test\":\"$(basename "$ref")\"}" > "$tmp/data.yaml" + flux push artifact "oci://$ref" --path="$tmp" --source=test --revision=v1 + rm -rf "$tmp" +} + +# cosign_sign_keyless runs `cosign sign` with a freshly minted Kubernetes +# service-account token appended as --identity-token. cosign sends that token +# to Fulcio to obtain a signing certificate, so it is a bearer credential and +# must never reach the logs. If xtrace is active (detected via $-, which +# contains 'x' when `set -x` is on), the helper disables it while the token is +# in scope and restores it afterward, so the JWT is never traced. All +# arguments are forwarded verbatim to `cosign sign`. +cosign_sign_keyless() { + local was_x=0 + case "$-" in *x*) was_x=1 ;; esac + set +x + local token rc=0 + token="$(kubectl create token default -n default --audience=sigstore)" + cosign sign "$@" --identity-token="${token}" || rc=$? + unset token + [ "${was_x}" = 1 ] && set -x + return "${rc}" +} + +# --- Sign artifacts --- +# +# The "v2-style" cases below produce cosign v2-format signatures by passing +# --tlog-upload=false, --use-signing-config=false, and --new-bundle-format=false +# to a v3 cosign binary. These flags are deprecated in cosign v3 (the v3 CLI +# already prints a "Flag --tlog-upload has been deprecated" warning) and may +# be removed in a future cosign release. If that happens, rewrite the affected +# cases to drop the flags and use a transparency-log-less --signing-config +# instead, or replace them with v3-bundle equivalents. + +echo "Run cosign v2-style key-pair tests" +push_artifact "$REG/test/v2-key:v1" +# DEPRECATED-FLAGS: --tlog-upload=false / --new-bundle-format=false +COSIGN_PASSWORD="" cosign sign --key="$KEYS_DIR/test.key" \ + --tlog-upload=false --use-signing-config=false --new-bundle-format=false \ + --allow-insecure-registry "$REG/test/v2-key:v1" + +echo "Run cosign v3 bundle key-pair tests" +push_artifact "$REG/test/v3-key:v1" +COSIGN_PASSWORD="" cosign sign --key="$KEYS_DIR/test.key" \ + --signing-config="$KEYS_DIR/signing-config-notlog.json" \ + --allow-insecure-registry "$REG/test/v3-key:v1" + +echo "Run cosign v2-style keyless tests" +push_artifact "$REG/test/v2-keyless:v1" +# DEPRECATED-FLAGS: --new-bundle-format=false +cosign_sign_keyless \ + --trusted-root="$PKI_DIR/trusted_root.json" \ + --fulcio-url="$FULCIO_URL" --rekor-url="$REKOR_URL" \ + --allow-insecure-registry --use-signing-config=false --new-bundle-format=false \ + --yes "$REG/test/v2-keyless:v1" + +echo "Run cosign v3 bundle keyless tests" +push_artifact "$REG/test/v3-keyless:v1" +cosign_sign_keyless \ + --trusted-root="$PKI_DIR/trusted_root.json" \ + --fulcio-url="$FULCIO_URL" --rekor-url="$REKOR_URL" \ + --allow-insecure-registry --use-signing-config=false \ + --yes "$REG/test/v3-keyless:v1" + +echo "Run cosign v3 key-pair with tlog tests" +push_artifact "$REG/test/v3-key-tlog:v1" +COSIGN_PASSWORD="" cosign sign --key="$KEYS_DIR/test.key" \ + --trusted-root="$PKI_DIR/trusted_root.json" \ + --rekor-url="$REKOR_URL" \ + --allow-insecure-registry --use-signing-config=false \ + --yes "$REG/test/v3-key-tlog:v1" + +echo "Run registry auth test" +push_artifact "$REG/test/authed:v1" +# DEPRECATED-FLAGS: --tlog-upload=false / --new-bundle-format=false +COSIGN_PASSWORD="" cosign sign --key="$KEYS_DIR/test.key" \ + --tlog-upload=false --use-signing-config=false --new-bundle-format=false \ + --allow-insecure-registry "$REG/test/authed:v1" + +echo "Run registry:2 fallback tests" +push_artifact "$REG2/test/v3-key-fallback:v1" +COSIGN_PASSWORD="" cosign sign --key="$KEYS_DIR/test.key" \ + --signing-config="$KEYS_DIR/signing-config-notlog.json" \ + --allow-insecure-registry "$REG2/test/v3-key-fallback:v1" + +push_artifact "$REG2/test/v3-keyless-fallback:v1" +cosign_sign_keyless \ + --trusted-root="$PKI_DIR/trusted_root.json" \ + --fulcio-url="$FULCIO_URL" --rekor-url="$REKOR_URL" \ + --allow-insecure-registry --use-signing-config=false \ + --yes "$REG2/test/v3-keyless-fallback:v1" + +push_artifact "$REG2/test/v3-key-tlog-fallback:v1" +COSIGN_PASSWORD="" cosign sign --key="$KEYS_DIR/test.key" \ + --trusted-root="$PKI_DIR/trusted_root.json" \ + --rekor-url="$REKOR_URL" \ + --allow-insecure-registry --use-signing-config=false \ + --yes "$REG2/test/v3-key-tlog-fallback:v1" + +# --- Apply and verify --- + +echo "Run OCIRepository verify tests" +kubectl -n "$NS" apply -f "${TESTDATA}/v2-key.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/v3-key.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/v2-keyless-trustedroot.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/v3-keyless-trustedroot.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/v3-key-tlog.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/combined-secretref-trustedroot.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/registry-auth.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/registry2-fallback.yaml" + +# wait_ready logs before blocking on each OCIRepository so a hang names the +# object it is stuck on instead of stalling silently. +wait_ready() { + local name="$1" + echo ">>> waiting for ocirepository/${name} Ready (timeout 1m)" + kubectl -n "$NS" wait "ocirepository/${name}" --for=condition=ready --timeout=1m +} + +wait_ready test-v2-key +wait_ready test-v3-key +wait_ready test-v2-keyless +wait_ready test-v3-keyless +wait_ready test-v3-key-tlog +wait_ready test-combined +wait_ready test-authed +wait_ready test-v3-key-fallback +wait_ready test-v3-keyless-fallback +wait_ready test-v3-key-tlog-fallback + +echo "Run negative verification tests" +kubectl -n "$NS" apply -f "${TESTDATA}/wrong-key.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/wrong-identity.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/wrong-rekor-key.yaml" + +NEGATIVE_CASES=( + test-wrong-key + test-wrong-identity + test-wrong-rekor +) + +# Assert each negative case reaches Ready=False with reason VerificationError +# and never flips to Ready=True. A plain one-shot grep races reconciliation: +# an object that has not reconciled yet shows an empty reason (false pass) and +# an object that wrongly verifies later would be missed. Poll until each case +# reports the failure reason, treating Ready=True as an immediate hard failure. +assert_verification_error() { + local name="$1" + local deadline=$((SECONDS + 90)) + local ready reason + while [ "${SECONDS}" -lt "${deadline}" ]; do + ready=$(kubectl -n "$NS" get ocirepository "${name}" \ + -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true) + reason=$(kubectl -n "$NS" get ocirepository "${name}" \ + -o jsonpath='{.status.conditions[?(@.type=="Ready")].reason}' 2>/dev/null || true) + if [ "${ready}" = "True" ]; then + echo "FAIL: ${name} unexpectedly verified (Ready=True)" >&2 + return 1 + fi + if [ "${ready}" = "False" ] && [ "${reason}" = "VerificationError" ]; then + echo " ok: ${name} rejected with VerificationError" + return 0 + fi + sleep 3 + done + echo "FAIL: ${name} did not report VerificationError within 90s (ready=${ready:-} reason=${reason:-})" >&2 + return 1 +} + +for case in "${NEGATIVE_CASES[@]}"; do + assert_verification_error "${case}" +done + +echo "All sigstore verification tests passed!" diff --git a/hack/sigstore-test/testdata/combined-secretref-trustedroot.yaml b/hack/sigstore-test/testdata/combined-secretref-trustedroot.yaml new file mode 100644 index 000000000..af45c4c0f --- /dev/null +++ b/hack/sigstore-test/testdata/combined-secretref-trustedroot.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-combined +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v2-key + ref: + tag: v1 + insecure: true + verify: + provider: cosign + secretRef: + name: cosign-test-key + trustedRootSecretRef: + name: sigstore-trusted-root diff --git a/hack/sigstore-test/testdata/registry-auth.yaml b/hack/sigstore-test/testdata/registry-auth.yaml new file mode 100644 index 000000000..dd27bce12 --- /dev/null +++ b/hack/sigstore-test/testdata/registry-auth.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-authed +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/authed + ref: + tag: v1 + insecure: true + secretRef: + name: registry-creds + verify: + provider: cosign + secretRef: + name: cosign-test-key diff --git a/hack/sigstore-test/testdata/registry2-fallback.yaml b/hack/sigstore-test/testdata/registry2-fallback.yaml new file mode 100644 index 000000000..aa0437ce8 --- /dev/null +++ b/hack/sigstore-test/testdata/registry2-fallback.yaml @@ -0,0 +1,48 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-v3-key-fallback +spec: + interval: 5m + url: oci://sigstore-test-registry2:5000/test/v3-key-fallback + ref: + tag: v1 + insecure: true + verify: + provider: cosign + secretRef: + name: cosign-test-key +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-v3-keyless-fallback +spec: + interval: 5m + url: oci://sigstore-test-registry2:5000/test/v3-keyless-fallback + ref: + tag: v1 + insecure: true + verify: + provider: cosign + trustedRootSecretRef: + name: sigstore-trusted-root + matchOIDCIdentity: + - issuer: "^https://kubernetes\\.default\\.svc" + subject: "^https://kubernetes\\.io/namespaces/default/serviceaccounts/default$" +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-v3-key-tlog-fallback +spec: + interval: 5m + url: oci://sigstore-test-registry2:5000/test/v3-key-tlog-fallback + ref: + tag: v1 + insecure: true + verify: + provider: cosign + secretRef: + name: cosign-test-key diff --git a/hack/sigstore-test/testdata/v2-key.yaml b/hack/sigstore-test/testdata/v2-key.yaml new file mode 100644 index 000000000..bc3bfaa49 --- /dev/null +++ b/hack/sigstore-test/testdata/v2-key.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-v2-key +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v2-key + ref: + tag: v1 + insecure: true + verify: + provider: cosign + secretRef: + name: cosign-test-key diff --git a/hack/sigstore-test/testdata/v2-keyless-trustedroot.yaml b/hack/sigstore-test/testdata/v2-keyless-trustedroot.yaml new file mode 100644 index 000000000..be437b5f9 --- /dev/null +++ b/hack/sigstore-test/testdata/v2-keyless-trustedroot.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-v2-keyless +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v2-keyless + ref: + tag: v1 + insecure: true + verify: + provider: cosign + trustedRootSecretRef: + name: sigstore-trusted-root + matchOIDCIdentity: + - issuer: "^https://kubernetes\\.default\\.svc" + subject: "^https://kubernetes\\.io/namespaces/default/serviceaccounts/default$" diff --git a/hack/sigstore-test/testdata/v3-key-tlog.yaml b/hack/sigstore-test/testdata/v3-key-tlog.yaml new file mode 100644 index 000000000..1e4b21915 --- /dev/null +++ b/hack/sigstore-test/testdata/v3-key-tlog.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-v3-key-tlog +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v3-key-tlog + ref: + tag: v1 + insecure: true + verify: + provider: cosign + secretRef: + name: cosign-test-key diff --git a/hack/sigstore-test/testdata/v3-key.yaml b/hack/sigstore-test/testdata/v3-key.yaml new file mode 100644 index 000000000..97b5e46e2 --- /dev/null +++ b/hack/sigstore-test/testdata/v3-key.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-v3-key +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v3-key + ref: + tag: v1 + insecure: true + verify: + provider: cosign + secretRef: + name: cosign-test-key diff --git a/hack/sigstore-test/testdata/v3-keyless-trustedroot.yaml b/hack/sigstore-test/testdata/v3-keyless-trustedroot.yaml new file mode 100644 index 000000000..e81324884 --- /dev/null +++ b/hack/sigstore-test/testdata/v3-keyless-trustedroot.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-v3-keyless +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v3-keyless + ref: + tag: v1 + insecure: true + verify: + provider: cosign + trustedRootSecretRef: + name: sigstore-trusted-root + matchOIDCIdentity: + - issuer: "^https://kubernetes\\.default\\.svc" + subject: "^https://kubernetes\\.io/namespaces/default/serviceaccounts/default$" diff --git a/hack/sigstore-test/testdata/wrong-identity.yaml b/hack/sigstore-test/testdata/wrong-identity.yaml new file mode 100644 index 000000000..3c6b67ec2 --- /dev/null +++ b/hack/sigstore-test/testdata/wrong-identity.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-wrong-identity +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v2-keyless + ref: + tag: v1 + insecure: true + verify: + provider: cosign + trustedRootSecretRef: + name: sigstore-trusted-root + matchOIDCIdentity: + - issuer: "^https://wrong-issuer\\.example\\.com$" + subject: "^wrong-subject@example\\.com$" diff --git a/hack/sigstore-test/testdata/wrong-key.yaml b/hack/sigstore-test/testdata/wrong-key.yaml new file mode 100644 index 000000000..42ba87c39 --- /dev/null +++ b/hack/sigstore-test/testdata/wrong-key.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-wrong-key +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v2-key + ref: + tag: v1 + insecure: true + verify: + provider: cosign + secretRef: + name: cosign-wrong-key diff --git a/hack/sigstore-test/testdata/wrong-rekor-key.yaml b/hack/sigstore-test/testdata/wrong-rekor-key.yaml new file mode 100644 index 000000000..bdcf5482a --- /dev/null +++ b/hack/sigstore-test/testdata/wrong-rekor-key.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-wrong-rekor +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v2-keyless + ref: + tag: v1 + insecure: true + verify: + provider: cosign + trustedRootSecretRef: + name: sigstore-wrong-root + matchOIDCIdentity: + - issuer: "^https://kubernetes\\.default\\.svc" + subject: "^https://kubernetes\\.io/namespaces/default/serviceaccounts/default$" From 82b52105f013d89e9abfb0feabf41b666fbd89da Mon Sep 17 00:00:00 2001 From: leigh capili Date: Wed, 17 Jun 2026 22:06:15 -0600 Subject: [PATCH 4/6] hack: cover trusted-root auto-detection in e2e Extend test-signing.sh and add fixtures that exercise verification policy derived from the trusted_root.json contents rather than fixed flags: a Fulcio-only root for keyed signatures, a Rekor-only root that enforces tlog inclusion alongside a public key, a Fulcio+Rekor+CT root that requires SCT verification, and a Fulcio+TSA root that verifies a signed timestamp without a transparency log. Negative cases cover an empty root, a wrong CT key, a wrong keyed Rekor requirement, and a keyless signature missing the required TSA timestamp. These cases fail against a controller that hard-codes verification policy; they pass once auto-detection derives policy from the bundle. Assisted-by: GitHub Copilot CLI/gpt-5.5 Assisted-by: Kiro/opus-4.8 Signed-off-by: leigh capili --- hack/sigstore-test/test-signing.sh | 121 +++++++++++++++++- .../combined-secretref-trustedroot.yaml | 7 +- .../keyed-rekor-only-trustedroot.yaml | 22 ++++ .../testdata/keyed-rekor-required.yaml | 21 +++ .../testdata/keyless-fulcio-ct.yaml | 22 ++++ .../testdata/keyless-fulcio-tsa.yaml | 25 ++++ hack/sigstore-test/testdata/wrong-ct-key.yaml | 23 ++++ .../testdata/wrong-empty-trustedroot.yaml | 21 +++ .../testdata/wrong-keyed-rekor-required.yaml | 21 +++ hack/sigstore-test/testdata/wrong-no-tsa.yaml | 27 ++++ 10 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 hack/sigstore-test/testdata/keyed-rekor-only-trustedroot.yaml create mode 100644 hack/sigstore-test/testdata/keyed-rekor-required.yaml create mode 100644 hack/sigstore-test/testdata/keyless-fulcio-ct.yaml create mode 100644 hack/sigstore-test/testdata/keyless-fulcio-tsa.yaml create mode 100644 hack/sigstore-test/testdata/wrong-ct-key.yaml create mode 100644 hack/sigstore-test/testdata/wrong-empty-trustedroot.yaml create mode 100644 hack/sigstore-test/testdata/wrong-keyed-rekor-required.yaml create mode 100644 hack/sigstore-test/testdata/wrong-no-tsa.yaml diff --git a/hack/sigstore-test/test-signing.sh b/hack/sigstore-test/test-signing.sh index f323dec07..463276264 100755 --- a/hack/sigstore-test/test-signing.sh +++ b/hack/sigstore-test/test-signing.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash # test-signing.sh: Validate cosign v2/v3 x key-pair/keyless verification flows -# against a custom Sigstore trusted root. +# including custom Sigstore trusted-root auto-detection of Rekor / Fulcio / TSA. # # Prerequisites (driven by hack/sigstore-test/Makefile): # - kind cluster + registries: make up registries # - sigstore stack + fulcio config + rekor/fulcio NodePorts: make sigstore +# - timestamp authority + tsa-np NodePort: make tsa # - source-controller deployed: make build set -euo pipefail @@ -24,6 +25,12 @@ REKOR_NP=$(kubectl -n rekor-system get svc rekor-np -o jsonpath='{.spec.ports[0] FULCIO_NP=$(kubectl -n fulcio-system get svc fulcio-np -o jsonpath='{.spec.ports[0].nodePort}') REKOR_URL="http://${NODE_IP}:${REKOR_NP}" FULCIO_URL="http://${NODE_IP}:${FULCIO_NP}" +TSA_NP=$(kubectl -n tsa-system get svc tsa-np -o jsonpath='{.spec.ports[0].nodePort}' 2>/dev/null || true) +if [ -n "${TSA_NP}" ]; then + TSA_URL="http://${NODE_IP}:${TSA_NP}/api/v1/timestamp" +else + TSA_URL="" +fi # --- Setup keys and secrets --- @@ -56,6 +63,54 @@ cosign trusted-root create \ --ctfe="url=http://ctlog.ctlog-system.svc,public-key=$PKI_DIR/ctfe.pub,start-time=2024-01-01T00:00:00Z" \ --out "$PKI_DIR/wrong_trusted_root.json" +# Fulcio-only trusted root: no Rekor, no ctfe. Drives auto-detection to +# IgnoreTlog=true, IgnoreSCT=true, UseSignedTimestamps=false. +cosign trusted-root create \ + --fulcio="url=http://fulcio-server.fulcio-system.svc,certificate-chain=$PKI_DIR/fulcio.crt.pem" \ + --out "$PKI_DIR/trusted_root_fulcio.json" + +# Fulcio + Rekor + CT trusted root. Drives auto-detection to require both +# tlog and SCT verification for keyless signatures without requiring TSA. +cosign trusted-root create \ + --fulcio="url=http://fulcio-server.fulcio-system.svc,certificate-chain=$PKI_DIR/fulcio.crt.pem" \ + --rekor="url=http://rekor-server.rekor-system.svc,public-key=$PKI_DIR/rekor.pub,start-time=2024-01-01T00:00:00Z" \ + --ctfe="url=http://ctlog.ctlog-system.svc,public-key=$PKI_DIR/ctfe.pub,start-time=2024-01-01T00:00:00Z" \ + --out "$PKI_DIR/trusted_root_fulcio_ct.json" + +# Fulcio + Rekor + wrong CT trusted root. Negative counterpart that pins +# IgnoreSCT=false: SCT verification must run and fail against the wrong key. +cosign trusted-root create \ + --fulcio="url=http://fulcio-server.fulcio-system.svc,certificate-chain=$PKI_DIR/fulcio.crt.pem" \ + --rekor="url=http://rekor-server.rekor-system.svc,public-key=$PKI_DIR/rekor.pub,start-time=2024-01-01T00:00:00Z" \ + --ctfe="url=http://ctlog.ctlog-system.svc,public-key=$KEYS_DIR/wrong.pub,start-time=2024-01-01T00:00:00Z" \ + --out "$PKI_DIR/wrong_ct_root.json" + +# Rekor-only trusted root: no Fulcio, no ctfe. Drives auto-detection to +# IgnoreTlog=false, IgnoreSCT=true. Used to enforce tlog inclusion alongside +# a private-key signature, without keyless cert chain validation. +cosign trusted-root create \ + --rekor="url=http://rekor-server.rekor-system.svc,public-key=$PKI_DIR/rekor.pub,start-time=2024-01-01T00:00:00Z" \ + --out "$PKI_DIR/trusted_root_rekor.json" + +# Empty trusted root: no components at all. The verifier must reject this +# configuration for keyless because there is nothing to verify against. +cat > "$PKI_DIR/trusted_root_empty.json" <<'EOF' +{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1"} +EOF + +# Fulcio + TSA trusted root: keyless verification with RFC3161 signed +# timestamps instead of a Rekor inclusion proof. Models GitHub-style +# immutable releases where the bundle ships a TSA timestamp and skips +# the transparency log. Only created when a TSA is reachable. +TSA_CHAIN="${SCRIPT_DIR}/pki/tsa/chain.pem" +if [ -n "${TSA_URL}" ] && [ -s "${TSA_CHAIN}" ]; then + cosign trusted-root create \ + --fulcio="url=http://fulcio-server.fulcio-system.svc,certificate-chain=$PKI_DIR/fulcio.crt.pem" \ + --ctfe="url=http://ctlog.ctlog-system.svc,public-key=$PKI_DIR/ctfe.pub,start-time=2024-01-01T00:00:00Z" \ + --tsa="url=http://tsa-server.tsa-system.svc,certificate-chain=${TSA_CHAIN}" \ + --out "$PKI_DIR/trusted_root_fulcio_tsa.json" +fi + kubectl -n "$NS" create secret generic cosign-test-key \ --from-file=cosign.pub="$KEYS_DIR/test.pub" --dry-run=client -o yaml | kubectl apply -f - kubectl -n "$NS" create secret generic cosign-wrong-key \ @@ -64,6 +119,20 @@ kubectl -n "$NS" create secret generic sigstore-trusted-root \ --from-file=trusted_root.json="$PKI_DIR/trusted_root.json" --dry-run=client -o yaml | kubectl apply -f - kubectl -n "$NS" create secret generic sigstore-wrong-root \ --from-file=trusted_root.json="$PKI_DIR/wrong_trusted_root.json" --dry-run=client -o yaml | kubectl apply -f - +kubectl -n "$NS" create secret generic sigstore-trusted-root-fulcio \ + --from-file=trusted_root.json="$PKI_DIR/trusted_root_fulcio.json" --dry-run=client -o yaml | kubectl apply -f - +kubectl -n "$NS" create secret generic sigstore-trusted-root-fulcio-ct \ + --from-file=trusted_root.json="$PKI_DIR/trusted_root_fulcio_ct.json" --dry-run=client -o yaml | kubectl apply -f - +kubectl -n "$NS" create secret generic sigstore-wrong-ct-root \ + --from-file=trusted_root.json="$PKI_DIR/wrong_ct_root.json" --dry-run=client -o yaml | kubectl apply -f - +kubectl -n "$NS" create secret generic sigstore-trusted-root-rekor \ + --from-file=trusted_root.json="$PKI_DIR/trusted_root_rekor.json" --dry-run=client -o yaml | kubectl apply -f - +kubectl -n "$NS" create secret generic sigstore-empty-root \ + --from-file=trusted_root.json="$PKI_DIR/trusted_root_empty.json" --dry-run=client -o yaml | kubectl apply -f - +if [ -s "$PKI_DIR/trusted_root_fulcio_tsa.json" ]; then + kubectl -n "$NS" create secret generic sigstore-trusted-root-fulcio-tsa \ + --from-file=trusted_root.json="$PKI_DIR/trusted_root_fulcio_tsa.json" --dry-run=client -o yaml | kubectl apply -f - +fi kubectl -n "$NS" create secret docker-registry registry-creds \ --docker-server="sigstore-test-registry:5000" \ --docker-username=user --docker-password=pass \ @@ -139,6 +208,31 @@ cosign_sign_keyless \ --allow-insecure-registry --use-signing-config=false \ --yes "$REG/test/v3-keyless:v1" +if [ -s "$PKI_DIR/trusted_root_fulcio_tsa.json" ] && [ -n "${TSA_URL}" ]; then + echo "Run cosign v3 bundle keyless + TSA tests (no tlog)" + # Image-level cosign sign omits NewBundleFormat from KeyOpts in v3.0.6, + # so the rfc3161 timestamp path requires going through a signing config + # rather than the explicit --timestamp-server-url flag. + # + # Always regenerate: the config embeds FULCIO_URL/TSA_URL, which include + # the cluster's NodePorts. Those are allocated dynamically and change when + # the cluster is recreated, so a cached config would pin stale ports. + cosign signing-config create \ + --no-default-fulcio --no-default-rekor --no-default-tsa --no-default-oidc \ + --fulcio="url=${FULCIO_URL},api-version=1,start-time=2024-01-01T00:00:00Z,operator=flux-test" \ + --tsa="url=${TSA_URL},api-version=1,start-time=2024-01-01T00:00:00Z,operator=flux-test" \ + --tsa-config="EXACT:1" \ + --oidc-provider="url=https://kubernetes.default.svc.cluster.local,api-version=1,start-time=2024-01-01T00:00:00Z,operator=flux-test" \ + --out "$KEYS_DIR/signing-config-keyless-tsa.json" + push_artifact "$REG/test/v3-keyless-tsa:v1" + cosign_sign_keyless \ + --trusted-root="$PKI_DIR/trusted_root_fulcio_tsa.json" \ + --signing-config="$KEYS_DIR/signing-config-keyless-tsa.json" \ + --new-bundle-format=true \ + --allow-insecure-registry \ + --yes "$REG/test/v3-keyless-tsa:v1" +fi + echo "Run cosign v3 key-pair with tlog tests" push_artifact "$REG/test/v3-key-tlog:v1" COSIGN_PASSWORD="" cosign sign --key="$KEYS_DIR/test.key" \ @@ -183,9 +277,16 @@ kubectl -n "$NS" apply -f "${TESTDATA}/v2-keyless-trustedroot.yaml" kubectl -n "$NS" apply -f "${TESTDATA}/v3-keyless-trustedroot.yaml" kubectl -n "$NS" apply -f "${TESTDATA}/v3-key-tlog.yaml" kubectl -n "$NS" apply -f "${TESTDATA}/combined-secretref-trustedroot.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/keyed-rekor-required.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/keyed-rekor-only-trustedroot.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/keyless-fulcio-ct.yaml" kubectl -n "$NS" apply -f "${TESTDATA}/registry-auth.yaml" kubectl -n "$NS" apply -f "${TESTDATA}/registry2-fallback.yaml" +if [ -s "$PKI_DIR/trusted_root_fulcio_tsa.json" ] && [ -n "${TSA_URL}" ]; then + kubectl -n "$NS" apply -f "${TESTDATA}/keyless-fulcio-tsa.yaml" +fi + # wait_ready logs before blocking on each OCIRepository so a hang names the # object it is stuck on instead of stalling silently. wait_ready() { @@ -200,21 +301,39 @@ wait_ready test-v2-keyless wait_ready test-v3-keyless wait_ready test-v3-key-tlog wait_ready test-combined +wait_ready test-keyed-rekor-required +wait_ready test-keyed-rekor-only +wait_ready test-v3-keyless-ct wait_ready test-authed wait_ready test-v3-key-fallback wait_ready test-v3-keyless-fallback wait_ready test-v3-key-tlog-fallback +if [ -s "$PKI_DIR/trusted_root_fulcio_tsa.json" ] && [ -n "${TSA_URL}" ]; then + wait_ready test-v3-keyless-tsa +fi + echo "Run negative verification tests" kubectl -n "$NS" apply -f "${TESTDATA}/wrong-key.yaml" kubectl -n "$NS" apply -f "${TESTDATA}/wrong-identity.yaml" kubectl -n "$NS" apply -f "${TESTDATA}/wrong-rekor-key.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/wrong-keyed-rekor-required.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/wrong-empty-trustedroot.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/wrong-ct-key.yaml" +# Negative cases that depend on a reachable TSA. NEGATIVE_CASES=( test-wrong-key test-wrong-identity test-wrong-rekor + test-wrong-keyed-rekor-required + test-wrong-empty-trustedroot + test-wrong-ct ) +if [ -s "$PKI_DIR/trusted_root_fulcio_tsa.json" ] && [ -n "${TSA_URL}" ]; then + kubectl -n "$NS" apply -f "${TESTDATA}/wrong-no-tsa.yaml" + NEGATIVE_CASES+=(test-wrong-no-tsa) +fi # Assert each negative case reaches Ready=False with reason VerificationError # and never flips to Ready=True. A plain one-shot grep races reconciliation: diff --git a/hack/sigstore-test/testdata/combined-secretref-trustedroot.yaml b/hack/sigstore-test/testdata/combined-secretref-trustedroot.yaml index af45c4c0f..31c8daa73 100644 --- a/hack/sigstore-test/testdata/combined-secretref-trustedroot.yaml +++ b/hack/sigstore-test/testdata/combined-secretref-trustedroot.yaml @@ -1,4 +1,9 @@ --- +# Combined secretRef + trustedRootSecretRef (fulcio-only). +# Auto-detection: Rekor absent -> IgnoreTlog=true, TSA absent -> no signed +# timestamp requirement, CT log absent -> IgnoreSCT=true (moot for keyed). +# Effective behavior: public-key signature verification with the trusted +# root acting as additional context but not enforcing tlog or TSA. apiVersion: source.toolkit.fluxcd.io/v1 kind: OCIRepository metadata: @@ -14,4 +19,4 @@ spec: secretRef: name: cosign-test-key trustedRootSecretRef: - name: sigstore-trusted-root + name: sigstore-trusted-root-fulcio diff --git a/hack/sigstore-test/testdata/keyed-rekor-only-trustedroot.yaml b/hack/sigstore-test/testdata/keyed-rekor-only-trustedroot.yaml new file mode 100644 index 000000000..5ee7a2b31 --- /dev/null +++ b/hack/sigstore-test/testdata/keyed-rekor-only-trustedroot.yaml @@ -0,0 +1,22 @@ +--- +# Keyed signature combined with a Rekor-only trusted root (no Fulcio, no +# ctfe). Auto-detection: Rekor present -> tlog inclusion required; Fulcio +# absent -> moot for keyed verification. +# This demonstrates that the keyed path can enforce tlog policy while +# ignoring the keyless certificate machinery entirely. +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-keyed-rekor-only +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v3-key-tlog + ref: + tag: v1 + insecure: true + verify: + provider: cosign + secretRef: + name: cosign-test-key + trustedRootSecretRef: + name: sigstore-trusted-root-rekor diff --git a/hack/sigstore-test/testdata/keyed-rekor-required.yaml b/hack/sigstore-test/testdata/keyed-rekor-required.yaml new file mode 100644 index 000000000..bd4bb5a44 --- /dev/null +++ b/hack/sigstore-test/testdata/keyed-rekor-required.yaml @@ -0,0 +1,21 @@ +--- +# Keyed signature combined with full trusted root (Fulcio + Rekor + ctfe). +# Auto-detection: Rekor present -> tlog inclusion required. +# The artifact at test/v3-key-tlog is signed with --tlog-upload (default), +# so it carries a Rekor inclusion proof and the verification succeeds. +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-keyed-rekor-required +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v3-key-tlog + ref: + tag: v1 + insecure: true + verify: + provider: cosign + secretRef: + name: cosign-test-key + trustedRootSecretRef: + name: sigstore-trusted-root diff --git a/hack/sigstore-test/testdata/keyless-fulcio-ct.yaml b/hack/sigstore-test/testdata/keyless-fulcio-ct.yaml new file mode 100644 index 000000000..0ab4a4e6b --- /dev/null +++ b/hack/sigstore-test/testdata/keyless-fulcio-ct.yaml @@ -0,0 +1,22 @@ +--- +# Positive test: keyless verification against a Fulcio + Rekor + CT trusted +# root. Auto-detection requires both the Rekor tlog inclusion proof and the +# embedded SCT. The signed image carries a valid SCT from the cluster CT log, +# so verification succeeds. +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-v3-keyless-ct +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v3-keyless + ref: + tag: v1 + insecure: true + verify: + provider: cosign + trustedRootSecretRef: + name: sigstore-trusted-root-fulcio-ct + matchOIDCIdentity: + - issuer: "^https://kubernetes\\.default\\.svc" + subject: "^https://kubernetes\\.io/namespaces/default/serviceaccounts/default$" diff --git a/hack/sigstore-test/testdata/keyless-fulcio-tsa.yaml b/hack/sigstore-test/testdata/keyless-fulcio-tsa.yaml new file mode 100644 index 000000000..94e77d970 --- /dev/null +++ b/hack/sigstore-test/testdata/keyless-fulcio-tsa.yaml @@ -0,0 +1,25 @@ +--- +# Keyless verification against a trusted root that contains Fulcio, CT log, +# and a Timestamp Authority but no Rekor. Auto-detection: +# - Rekor absent -> IgnoreTlog=true +# - TSA present -> UseSignedTimestamps=true +# - CT log present -> IgnoreSCT=false +# Models the GitHub-style immutable release flow where TSA timestamps +# anchor signature time without requiring a transparency log. +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-v3-keyless-tsa +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v3-keyless-tsa + ref: + tag: v1 + insecure: true + verify: + provider: cosign + trustedRootSecretRef: + name: sigstore-trusted-root-fulcio-tsa + matchOIDCIdentity: + - issuer: "^https://kubernetes\\.default\\.svc" + subject: "^https://kubernetes\\.io/namespaces/default/serviceaccounts/default$" diff --git a/hack/sigstore-test/testdata/wrong-ct-key.yaml b/hack/sigstore-test/testdata/wrong-ct-key.yaml new file mode 100644 index 000000000..28cf12c13 --- /dev/null +++ b/hack/sigstore-test/testdata/wrong-ct-key.yaml @@ -0,0 +1,23 @@ +--- +# Negative test: keyless verification against a Fulcio + Rekor + CT trusted +# root whose CT public key did NOT sign the certificate's embedded SCT. This +# pins IgnoreSCT=false: because a CT log is present, SCT verification must run +# and fail against the wrong CT key. If the verifier regressed IgnoreSCT to +# true, this fixture would wrongly succeed. +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-wrong-ct +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v3-keyless + ref: + tag: v1 + insecure: true + verify: + provider: cosign + trustedRootSecretRef: + name: sigstore-wrong-ct-root + matchOIDCIdentity: + - issuer: "^https://kubernetes\\.default\\.svc" + subject: "^https://kubernetes\\.io/namespaces/default/serviceaccounts/default$" diff --git a/hack/sigstore-test/testdata/wrong-empty-trustedroot.yaml b/hack/sigstore-test/testdata/wrong-empty-trustedroot.yaml new file mode 100644 index 000000000..0e909d4b8 --- /dev/null +++ b/hack/sigstore-test/testdata/wrong-empty-trustedroot.yaml @@ -0,0 +1,21 @@ +--- +# Negative test: keyless verification with an empty trusted root that has +# no Fulcio, no Rekor and no TSA material. The verifier must reject the +# configuration because there is nothing to verify against. +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-wrong-empty-trustedroot +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v3-keyless + ref: + tag: v1 + insecure: true + verify: + provider: cosign + trustedRootSecretRef: + name: sigstore-empty-root + matchOIDCIdentity: + - issuer: "^https://kubernetes\\.default\\.svc" + subject: "^https://kubernetes\\.io/namespaces/default/serviceaccounts/default$" diff --git a/hack/sigstore-test/testdata/wrong-keyed-rekor-required.yaml b/hack/sigstore-test/testdata/wrong-keyed-rekor-required.yaml new file mode 100644 index 000000000..0834685cb --- /dev/null +++ b/hack/sigstore-test/testdata/wrong-keyed-rekor-required.yaml @@ -0,0 +1,21 @@ +--- +# Negative test: keyed signature without a Rekor inclusion proof combined +# with a trusted root that contains Rekor logs. Auto-detection requires +# tlog verification, the v3-key signature does not carry one, so the +# OCIRepository must surface a VerificationError. +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-wrong-keyed-rekor-required +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v3-key + ref: + tag: v1 + insecure: true + verify: + provider: cosign + secretRef: + name: cosign-test-key + trustedRootSecretRef: + name: sigstore-trusted-root diff --git a/hack/sigstore-test/testdata/wrong-no-tsa.yaml b/hack/sigstore-test/testdata/wrong-no-tsa.yaml new file mode 100644 index 000000000..c2e9fe476 --- /dev/null +++ b/hack/sigstore-test/testdata/wrong-no-tsa.yaml @@ -0,0 +1,27 @@ +--- +# Negative test: keyless verification of an image that has NO RFC3161 signed +# timestamp against a Fulcio + CT + TSA trusted root. Auto-detection: +# - TSA present -> UseSignedTimestamps=true +# The image at test/v3-keyless was signed without a TSA timestamp, so +# verification must fail for lack of a signed timestamp. This pins +# UseSignedTimestamps to true: if it ever regressed to false, this fixture +# would wrongly succeed. It is the negative counterpart to +# keyless-fulcio-tsa.yaml (which verifies an image that DOES carry a TSA +# timestamp against the same trusted root). +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-wrong-no-tsa +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v3-keyless + ref: + tag: v1 + insecure: true + verify: + provider: cosign + trustedRootSecretRef: + name: sigstore-trusted-root-fulcio-tsa + matchOIDCIdentity: + - issuer: "^https://kubernetes\\.default\\.svc" + subject: "^https://kubernetes\\.io/namespaces/default/serviceaccounts/default$" From a1dba3f54bc3b22c360bcef0f0e6de2bba031b69 Mon Sep 17 00:00:00 2001 From: leigh capili Date: Wed, 17 Jun 2026 22:06:16 -0600 Subject: [PATCH 5/6] cosign: auto-detect trusted root components When a custom Sigstore trusted root is provided, derive Rekor, TSA, and SCT enforcement from the bundle contents instead of hard-coding policy. RekorLogs presence sets IgnoreTlog=false and retries verification across each Rekor base URL for legacy online lookups; TimestampingAuthorities presence sets UseSignedTimestamps=true; CTLogs presence sets IgnoreSCT=false for keyless signatures. Keyless verification now requires Fulcio plus at least one of Rekor or TSA material in the trusted root. Keyed verification combined with a custom trusted root no longer forces Offline=true and IgnoreTlog=true; tlog or TSA material in the bundle is verified alongside the public key. Keyed verification without a custom trusted root retains the legacy offline behavior. Document the auto-detection policy on TrustedRootSecretRef and regenerate the CRDs and API reference. Assisted-by: GitHub Copilot CLI/gpt-5.5 Assisted-by: Kiro/opus-4.8 Signed-off-by: leigh capili --- api/v1/ociverification_types.go | 11 +- .../source.toolkit.fluxcd.io_helmcharts.yaml | 11 +- ...rce.toolkit.fluxcd.io_ocirepositories.yaml | 11 +- docs/api/v1/source.md | 11 +- docs/spec/v1/ocirepositories.md | 39 ++- internal/oci/cosign/cosign.go | 246 ++++++++++++++---- internal/oci/cosign/cosign_test.go | 80 ++++-- 7 files changed, 322 insertions(+), 87 deletions(-) diff --git a/api/v1/ociverification_types.go b/api/v1/ociverification_types.go index a2cf4c4ed..cb632bcf3 100644 --- a/api/v1/ociverification_types.go +++ b/api/v1/ociverification_types.go @@ -40,9 +40,14 @@ type OCIRepositoryVerification struct { MatchOIDCIdentity []OIDCIdentityMatch `json:"matchOIDCIdentity,omitempty"` // TrustedRootSecretRef specifies the Kubernetes Secret containing a - // Sigstore trusted_root.json file. This enables verification against - // self-hosted Sigstore infrastructure (custom Fulcio CA, self-hosted - // Rekor instance). The Secret must contain a key named "trusted_root.json". + // Sigstore trusted_root.json file. This enables verification against custom + // Sigstore trust material, including private Fulcio CAs, Rekor logs, CT + // logs and TSAs. The Secret must contain a key named "trusted_root.json". + // Verification policy is auto-detected from the trusted root contents: + // Rekor entries require tlog inclusion proofs, TSA entries require signed + // timestamps, and CT log entries require SCT verification for keyless + // signatures. Keyless verification requires Fulcio and at least one of + // Rekor or TSA material. // +optional TrustedRootSecretRef *meta.LocalObjectReference `json:"trustedRootSecretRef,omitempty"` } diff --git a/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml b/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml index 4b57126c7..a06c609bd 100644 --- a/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml +++ b/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml @@ -187,9 +187,14 @@ spec: trustedRootSecretRef: description: |- TrustedRootSecretRef specifies the Kubernetes Secret containing a - Sigstore trusted_root.json file. This enables verification against - self-hosted Sigstore infrastructure (custom Fulcio CA, self-hosted - Rekor instance). The Secret must contain a key named "trusted_root.json". + Sigstore trusted_root.json file. This enables verification against custom + Sigstore trust material, including private Fulcio CAs, Rekor logs, CT + logs and TSAs. The Secret must contain a key named "trusted_root.json". + Verification policy is auto-detected from the trusted root contents: + Rekor entries require tlog inclusion proofs, TSA entries require signed + timestamps, and CT log entries require SCT verification for keyless + signatures. Keyless verification requires Fulcio and at least one of + Rekor or TSA material. properties: name: description: Name of the referent. diff --git a/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml b/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml index b39556340..4fc029038 100644 --- a/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml +++ b/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml @@ -249,9 +249,14 @@ spec: trustedRootSecretRef: description: |- TrustedRootSecretRef specifies the Kubernetes Secret containing a - Sigstore trusted_root.json file. This enables verification against - self-hosted Sigstore infrastructure (custom Fulcio CA, self-hosted - Rekor instance). The Secret must contain a key named "trusted_root.json". + Sigstore trusted_root.json file. This enables verification against custom + Sigstore trust material, including private Fulcio CAs, Rekor logs, CT + logs and TSAs. The Secret must contain a key named "trusted_root.json". + Verification policy is auto-detected from the trusted root contents: + Rekor entries require tlog inclusion proofs, TSA entries require signed + timestamps, and CT log entries require SCT verification for keyless + signatures. Keyless verification requires Fulcio and at least one of + Rekor or TSA material. properties: name: description: Name of the referent. diff --git a/docs/api/v1/source.md b/docs/api/v1/source.md index 647fa7269..9b455049c 100644 --- a/docs/api/v1/source.md +++ b/docs/api/v1/source.md @@ -3673,9 +3673,14 @@ github.com/fluxcd/pkg/apis/meta.LocalObjectReference (Optional)

TrustedRootSecretRef specifies the Kubernetes Secret containing a -Sigstore trusted_root.json file. This enables verification against -self-hosted Sigstore infrastructure (custom Fulcio CA, self-hosted -Rekor instance). The Secret must contain a key named “trusted_root.json”.

+Sigstore trusted_root.json file. This enables verification against custom +Sigstore trust material, including private Fulcio CAs, Rekor logs, CT +logs and TSAs. The Secret must contain a key named “trusted_root.json”. +Verification policy is auto-detected from the trusted root contents: +Rekor entries require tlog inclusion proofs, TSA entries require signed +timestamps, and CT log entries require SCT verification for keyless +signatures. Keyless verification requires Fulcio and at least one of +Rekor or TSA material.

diff --git a/docs/spec/v1/ocirepositories.md b/docs/spec/v1/ocirepositories.md index fa3c5a2d7..9da45799a 100644 --- a/docs/spec/v1/ocirepositories.md +++ b/docs/spec/v1/ocirepositories.md @@ -644,16 +644,37 @@ spec: By default, the controller verifies the signatures using the Fulcio root CA and the Rekor instance hosted at [rekor.sigstore.dev](https://rekor.sigstore.dev/). -##### Custom Sigstore infrastructure (self-hosted Rekor / Fulcio) +##### Custom Sigstore infrastructure -To verify artifacts signed with a self-hosted Sigstore deployment, provide a -Sigstore `trusted_root.json` via the `.spec.verify.trustedRootSecretRef` field. -The trusted root bundles the Fulcio root CA chain, Rekor public key and URL, -CT log keys, and optionally TSA certificates. The Rekor URL is extracted -automatically from the `baseUrl` field in the transparency log entries. +To verify artifacts signed with private or self-hosted Sigstore components, +provide a Sigstore `trusted_root.json` via the +`.spec.verify.trustedRootSecretRef` field. The trusted root can contain Fulcio +root CA chains, Rekor transparency logs, CT logs and timestamping authorities +(TSAs). The Secret must contain a key named `trusted_root.json`. The `trusted_root.json` file follows the [Sigstore trusted root format](https://github.com/sigstore/protobuf-specs). +Its Fulcio CAs, Rekor logs, CT logs and TSAs are sets of trust material. A +trusted root can include multiple entries of each type, for example during log +or CA rotation. + +The controller auto-detects verification policy from the trusted root contents: + +| Trusted root contents | Verification behavior | +| --- | --- | +| Rekor transparency logs | Rekor inclusion proof is required. Bundled signatures are verified against the Rekor log IDs and public keys in the trusted root. For legacy online lookup, all non-empty Rekor `baseUrl` values are tried deterministically. | +| No Rekor transparency logs | Rekor inclusion proof verification is skipped. | +| Timestamping authorities | RFC3161 signed timestamps are required. Existing signatures without a valid timestamp from one of the trusted TSAs will fail verification. | +| No timestamping authorities | Signed timestamps are not required. | +| CT logs | Keyless signatures require an embedded SCT that verifies against one of the trusted CT logs. CT logs are ignored for public-key signatures. | +| No CT logs | SCT verification is skipped. | + +For keyless verification, the trusted root must contain Fulcio and at least one +durable time source, either Rekor or TSA. This prevents accepting a keyless +signature with only an identity certificate and no transparency log inclusion or +trusted timestamp. For public-key signatures, Fulcio and CT log material are not +used, but Rekor and TSA material can be used to require tlog inclusion and +signed timestamps in addition to the public-key check. Generate the file using `cosign trusted-root create`: @@ -665,7 +686,11 @@ cosign trusted-root create \ --out trusted_root.json ``` -The `--tsa` flag can also be used if a custom timestamp authority is deployed: +The `--rekor`, `--fulcio`, `--ctfe`, and `--tsa` flags may be repeated when the +trusted root needs to contain multiple instances. Include older trust material +while existing signed artifacts depend on it. + +The `--tsa` flag can be used when a custom timestamp authority is deployed: ```sh cosign trusted-root create \ diff --git a/internal/oci/cosign/cosign.go b/internal/oci/cosign/cosign.go index 3be4ab03e..c8b2c38b4 100644 --- a/internal/oci/cosign/cosign.go +++ b/internal/oci/cosign/cosign.go @@ -20,7 +20,9 @@ import ( "context" "crypto" "crypto/tls" + "errors" "fmt" + "sort" "sync" "time" @@ -79,8 +81,23 @@ func WithIdentities(identities []cosign.Identity) Options { // WithTrustedRoot sets the Sigstore trusted root JSON bytes. When provided, // verification uses the custom trusted root instead of the public Sigstore -// TUF root. The Rekor URL is extracted from the trusted root's transparency -// log entries. +// TUF root. Rekor, Fulcio, CT log, and TSA enforcement are auto-detected +// from the trusted root contents: +// - If the trusted root contains transparency log entries, Rekor inclusion +// verification is required. Bundled verification uses the log IDs and +// keys in the trusted root; legacy online lookup tries all Rekor URLs +// declared in the trusted root. Otherwise tlog verification is skipped. +// - If the trusted root contains timestamping authorities, RFC3161 signed +// timestamps are required. Otherwise they are not enforced. +// - If the trusted root contains certificate transparency logs, an embedded +// SCT is required for keyless verification. Otherwise SCT verification +// is skipped. SCT enforcement is moot for keyed signatures. +// - If the trusted root contains Fulcio certificate authorities, it is +// used to validate the keyless signing certificate chain. It is moot +// for keyed signatures. +// +// For keyless verification, Fulcio and at least one durable time source +// (Rekor or TSA) must be present. func WithTrustedRoot(trustedRoot []byte) Options { return func(opts *options) { opts.trustedRoot = trustedRoot @@ -106,8 +123,10 @@ func WithTLSConfig(tlsConfig *tls.Config) Options { // CosignVerifier is a struct which is responsible for executing verification logic. type CosignVerifier struct { - opts *cosign.CheckOpts - insecure bool + opts *cosign.CheckOpts + insecure bool + rekorURLs []string + tlsConfig *tls.Config } // CosignVerifierFactory is a factory for creating Verifiers with shared state. @@ -156,14 +175,23 @@ func (f *CosignVerifierFactory) NewCosignVerifier(ctx context.Context, opts ...O checkOpts.RegistryClientOpts = co + // Parse the optional custom trusted root once so it can drive + // auto-detection in both the keyed and keyless paths. + var ( + customRoot *root.TrustedRoot + caps trustedRootCapabilities + ) + if len(o.trustedRoot) > 0 { + customRoot, err = root.NewTrustedRootFromJSON(o.trustedRoot) + if err != nil { + return nil, fmt.Errorf("unable to parse trusted root: %w", err) + } + caps = detectTrustedRootCapabilities(customRoot) + } + // If a public key is provided, use it to verify the signature. // https://github.com/sigstore/cosign/blob/main/KEYLESS.md. if len(o.publicKey) > 0 { - checkOpts.Offline = true - // TODO(hidde): this is an oversight in our implementation. As it is - // theoretically possible to have a custom PK, without disabling tlog. - checkOpts.IgnoreTlog = true - pubKeyRaw, err := cryptoutils.UnmarshalPEMToPublicKey(o.publicKey) if err != nil { return nil, err @@ -174,31 +202,40 @@ func (f *CosignVerifierFactory) NewCosignVerifier(ctx context.Context, opts ...O return nil, err } - return &CosignVerifier{opts: checkOpts, insecure: o.insecure}, nil - } - - // Keyless verification: when a custom trusted root is provided, use it - // directly instead of the public Sigstore infrastructure. The Rekor URL - // is extracted from the trusted root's transparency log entries. - if len(o.trustedRoot) > 0 { - customRoot, err := root.NewTrustedRootFromJSON(o.trustedRoot) - if err != nil { - return nil, fmt.Errorf("unable to parse trusted root: %w", err) + // Without a custom trusted root, retain the legacy behavior of + // disabling tlog verification entirely. + if customRoot == nil { + checkOpts.Offline = true + checkOpts.IgnoreTlog = true + return &CosignVerifier{opts: checkOpts, insecure: o.insecure, tlsConfig: o.tlsConfig}, nil } - checkOpts.TrustedMaterial = customRoot - - rekorURL, err := rekorURLFromTrustedRoot(customRoot) - if err != nil { - return nil, fmt.Errorf("unable to extract Rekor URL from trusted root: %w", err) - } + // With a custom trusted root, opt into verifying any tlog or TSA + // material the user provided alongside the public key. CT logs and + // Fulcio CAs in the bundle are not meaningful for keyed signatures. + applyTrustedRootAutoDetection(checkOpts, customRoot, caps) + return &CosignVerifier{ + opts: checkOpts, + insecure: o.insecure, + rekorURLs: rekorURLsFromTrustedRoot(customRoot), + tlsConfig: o.tlsConfig, + }, nil + } - checkOpts.RekorClient, err = newRekorClient(rekorURL, o.tlsConfig) - if err != nil { - return nil, fmt.Errorf("unable to create Rekor client: %w", err) + // Keyless verification: when a custom trusted root is provided, use it + // directly instead of the public Sigstore infrastructure. Rekor, Fulcio, + // CT log, and TSA enforcement are auto-detected from the bundle contents. + if customRoot != nil { + if !caps.HasFulcio || (!caps.HasRekor && !caps.HasTSA) { + return nil, fmt.Errorf("custom trusted root for keyless verification must contain Fulcio and at least one of Rekor or TSA material") } - - return &CosignVerifier{opts: checkOpts, insecure: o.insecure}, nil + applyTrustedRootAutoDetection(checkOpts, customRoot, caps) + return &CosignVerifier{ + opts: checkOpts, + insecure: o.insecure, + rekorURLs: rekorURLsFromTrustedRoot(customRoot), + tlsConfig: o.tlsConfig, + }, nil } // Keyless verification using the public Sigstore infrastructure. @@ -255,7 +292,7 @@ func (f *CosignVerifierFactory) NewCosignVerifier(ctx context.Context, opts ...O return nil, fmt.Errorf("unable to get Fulcio intermediate certs: %w", err) } - return &CosignVerifier{opts: checkOpts, insecure: o.insecure}, nil + return &CosignVerifier{opts: checkOpts, insecure: o.insecure, tlsConfig: o.tlsConfig}, nil } // newRekorClient creates a Rekor client with optional TLS configuration. @@ -268,22 +305,97 @@ func newRekorClient(rekorURL string, tlsConfig *tls.Config) (*rekorgenclient.Rek return rekorclient.GetRekorClient(rekorURL, opts...) } -// rekorURLFromTrustedRoot extracts the Rekor base URL from a trusted root's -// transparency log entries. It returns the BaseURL of the first entry that -// has one set. -func rekorURLFromTrustedRoot(tr *root.TrustedRoot) (string, error) { +// rekorURLsFromTrustedRoot extracts all Rekor base URLs from a trusted root's +// transparency log entries in deterministic order. Empty URLs are omitted so +// bundled verification can still use the log key material without enabling +// legacy online lookup. +func rekorURLsFromTrustedRoot(tr *root.TrustedRoot) []string { logs := tr.RekorLogs() - if len(logs) == 0 { - return "", fmt.Errorf("no transparency log entries found in trusted root") + logIDs := make([]string, 0, len(logs)) + for logID := range logs { + logIDs = append(logIDs, logID) } - - for _, log := range logs { - if log.BaseURL != "" { - return log.BaseURL, nil + sort.Strings(logIDs) + + urls := make([]string, 0, len(logIDs)) + seen := make(map[string]struct{}, len(logIDs)) + for _, logID := range logIDs { + baseURL := logs[logID].BaseURL + if baseURL == "" { + continue + } + if _, ok := seen[baseURL]; ok { + continue } + seen[baseURL] = struct{}{} + urls = append(urls, baseURL) } - return "", fmt.Errorf("no transparency log entry with a BaseURL found in trusted root") + return urls +} + +// trustedRootCapabilities summarizes which Sigstore components are present in +// a custom trusted root. It is used to derive cosign CheckOpts policy flags +// when a custom trusted root is provided so that the user does not have to +// configure Rekor/Fulcio/TSA enforcement separately from the bundle contents. +type trustedRootCapabilities struct { + // HasFulcio is true when the trusted root contains at least one Fulcio + // certificate authority. It is required for keyless certificate chain + // verification. + HasFulcio bool + // HasRekor is true when the trusted root contains at least one + // transparency log entry. When true the verifier requires a Rekor + // inclusion proof; when false tlog verification is skipped. + HasRekor bool + // HasTSA is true when the trusted root contains at least one timestamping + // authority. When true the verifier requires an RFC3161 signed timestamp. + HasTSA bool + // HasCTLog is true when the trusted root contains at least one + // certificate transparency log. When true keyless verification requires + // an embedded SCT in the signing certificate. It has no effect on keyed + // signatures. + HasCTLog bool +} + +// detectTrustedRootCapabilities inspects a trusted root and returns which +// Sigstore components are present. The returned struct drives auto-detection +// of cosign verification policy flags. +func detectTrustedRootCapabilities(tr *root.TrustedRoot) trustedRootCapabilities { + return trustedRootCapabilities{ + HasFulcio: len(tr.FulcioCertificateAuthorities()) > 0, + HasRekor: len(tr.RekorLogs()) > 0, + HasTSA: len(tr.TimestampingAuthorities()) > 0, + HasCTLog: len(tr.CTLogs()) > 0, + } +} + +// applyTrustedRootAutoDetection configures the cosign CheckOpts to require or +// ignore each Sigstore component (Rekor, TSA, CT log) based on the contents +// of the custom trusted root. +// +// The trustedRootCapabilities must already have been computed from tr; it is +// passed in to avoid recomputation. +// +// Notes on combinations: +// - Keyed (SigVerifier set) ignores HasFulcio and HasCTLog: a public key +// does not need a certificate chain or an SCT. +func applyTrustedRootAutoDetection(checkOpts *cosign.CheckOpts, tr *root.TrustedRoot, caps trustedRootCapabilities) { + checkOpts.TrustedMaterial = tr + + // Rekor: require a transparency log inclusion proof if and only if the + // trusted root contains at least one Rekor public key. Bundled + // verification matches log entries by ID against TrustedMaterial; legacy + // online lookup is handled during Verify by trying all declared BaseURLs. + checkOpts.IgnoreTlog = !caps.HasRekor + + // TSA: require an RFC3161 signed timestamp if and only if the trusted + // root contains at least one timestamping authority. + checkOpts.UseSignedTimestamps = caps.HasTSA + + // SCT: require an embedded signed certificate timestamp if and only if + // the trusted root contains at least one CT log. Has no effect when a + // public key is set, because SCT verification is gated on a certificate. + checkOpts.IgnoreSCT = checkOpts.SigVerifier != nil || !caps.HasCTLog } // Verify verifies the authenticity of the given ref OCI image. @@ -308,10 +420,10 @@ func (v *CosignVerifier) Verify(ctx context.Context, ref name.Reference) (soci.V // if no bundles are returned, let's fallback to the cosign v2 behavior, similar to the cosign CLI if len(newBundles) == 0 || err != nil { opts.NewBundleFormat = false - signatures, _, err = cosign.VerifyImageSignatures(ctx, ref, &opts) + signatures, err = v.verifyImageSignatures(ctx, ref, opts) } else { opts.NewBundleFormat = true - signatures, _, err = cosign.VerifyImageAttestations(ctx, ref, &opts, nameOpts...) + signatures, err = v.verifyImageAttestations(ctx, ref, opts, nameOpts...) } if err != nil { return soci.VerificationResultFailed, err @@ -323,3 +435,49 @@ func (v *CosignVerifier) Verify(ctx context.Context, ref name.Reference) (soci.V return soci.VerificationResultSuccess, nil } + +// verifyImageSignatures verifies legacy signatures, retrying online Rekor +// lookup against each Rekor URL from a custom trusted root when needed. +func (v *CosignVerifier) verifyImageSignatures(ctx context.Context, ref name.Reference, opts cosign.CheckOpts) ([]oci.Signature, error) { + return v.verifyWithRekorURLs(opts, func(opts cosign.CheckOpts) ([]oci.Signature, error) { + signatures, _, err := cosign.VerifyImageSignatures(ctx, ref, &opts) + return signatures, err + }) +} + +// verifyImageAttestations verifies attestations, retrying legacy online Rekor +// lookup against each Rekor URL from a custom trusted root when needed. +func (v *CosignVerifier) verifyImageAttestations(ctx context.Context, ref name.Reference, opts cosign.CheckOpts, nameOpts ...name.Option) ([]oci.Signature, error) { + return v.verifyWithRekorURLs(opts, func(opts cosign.CheckOpts) ([]oci.Signature, error) { + attestations, _, err := cosign.VerifyImageAttestations(ctx, ref, &opts, nameOpts...) + return attestations, err + }) +} + +type verifyFunc func(opts cosign.CheckOpts) ([]oci.Signature, error) + +func (v *CosignVerifier) verifyWithRekorURLs(opts cosign.CheckOpts, verify verifyFunc) ([]oci.Signature, error) { + signatures, err := verify(opts) + if err == nil || opts.NewBundleFormat || opts.IgnoreTlog || len(v.rekorURLs) == 0 { + return signatures, err + } + + errs := []error{err} + for _, rekorURL := range v.rekorURLs { + rekorClient, clientErr := newRekorClient(rekorURL, v.tlsConfig) + if clientErr != nil { + errs = append(errs, fmt.Errorf("unable to create Rekor client for %q: %w", rekorURL, clientErr)) + continue + } + + retryOpts := opts + retryOpts.RekorClient = rekorClient + signatures, err = verify(retryOpts) + if err == nil { + return signatures, nil + } + errs = append(errs, fmt.Errorf("rekor %q: %w", rekorURL, err)) + } + + return nil, errors.Join(errs...) +} diff --git a/internal/oci/cosign/cosign_test.go b/internal/oci/cosign/cosign_test.go index 36df501b5..29d49c800 100644 --- a/internal/oci/cosign/cosign_test.go +++ b/internal/oci/cosign/cosign_test.go @@ -22,6 +22,7 @@ import ( "net/http" "net/url" "reflect" + "strings" "testing" "github.com/google/go-containerregistry/pkg/authn" @@ -148,22 +149,38 @@ func TestOptions(t *testing.T) { } } -func TestRekorURLFromTrustedRoot(t *testing.T) { +func TestRekorURLsFromTrustedRoot(t *testing.T) { tests := []struct { - name string - json string - wantURL string - wantErr string + name string + json string + wantURLs []string }{ { - name: "extracts base URL from tlog entry", - json: trustedRootJSON("https://rekor.example.com"), - wantURL: "https://rekor.example.com", + name: "extracts base URL from tlog entry", + json: trustedRootJSON("https://rekor.example.com"), + wantURLs: []string{"https://rekor.example.com"}, + }, + { + name: "sorts and deduplicates base URLs from all tlog entries", + json: trustedRootWithTLogsJSON( + rekorTLogJSON("https://rekor-b.example.com", "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="), + rekorTLogJSON("https://rekor-a.example.com", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="), + rekorTLogJSON("https://rekor-b.example.com", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="), + rekorTLogJSON("", "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC="), + ), + wantURLs: []string{"https://rekor-a.example.com", "https://rekor-b.example.com"}, + }, + { + name: "returns empty slice when no tlogs", + json: `{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1","tlogs":[]}`, + wantURLs: nil, }, { - name: "error when no tlogs", - json: `{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1","tlogs":[]}`, - wantErr: "no transparency log entries found", + name: "returns empty slice when tlogs have no base URLs", + json: trustedRootWithTLogsJSON( + rekorTLogJSON("", "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="), + ), + wantURLs: nil, }, } @@ -172,20 +189,14 @@ func TestRekorURLFromTrustedRoot(t *testing.T) { g := NewWithT(t) tr, err := root.NewTrustedRootFromJSON([]byte(tt.json)) - if tt.wantErr != "" { - // If parsing succeeds with no tlogs, check rekorURLFromTrustedRoot. - if err == nil { - _, err = rekorURLFromTrustedRoot(tr) - g.Expect(err).To(HaveOccurred()) - g.Expect(err.Error()).To(ContainSubstring(tt.wantErr)) - } - return - } g.Expect(err).NotTo(HaveOccurred()) - gotURL, err := rekorURLFromTrustedRoot(tr) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(gotURL).To(Equal(tt.wantURL)) + gotURLs := rekorURLsFromTrustedRoot(tr) + if tt.wantURLs == nil { + g.Expect(gotURLs).To(BeEmpty()) + return + } + g.Expect(gotURLs).To(Equal(tt.wantURLs)) }) } } @@ -211,7 +222,8 @@ func TestNewCosignVerifierWithTrustedRoot(t *testing.T) { g.Expect(err).NotTo(HaveOccurred()) g.Expect(verifier).NotTo(BeNil()) g.Expect(verifier.opts.TrustedMaterial).NotTo(BeNil()) - g.Expect(verifier.opts.RekorClient).NotTo(BeNil()) + g.Expect(verifier.opts.RekorClient).To(BeNil()) + g.Expect(verifier.rekorURLs).To(Equal([]string{"https://rekor.custom.example.com"})) }) t.Run("invalid trusted root JSON", func(t *testing.T) { @@ -283,6 +295,26 @@ func trustedRootJSON(rekorURL string) string { }`, rekorURL) } +func trustedRootWithTLogsJSON(tlogs ...string) string { + return fmt.Sprintf(`{ + "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1", + "tlogs": [%s] +}`, strings.Join(tlogs, ",")) +} + +func rekorTLogJSON(rekorURL, keyID string) string { + return fmt.Sprintf(`{ + "baseUrl": %q, + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": {"start": "2021-01-12T11:53:27.000Z"} + }, + "logId": {"keyId": %q} +}`, rekorURL, keyID) +} + func TestPrivateKeyVerificationWithProxy(t *testing.T) { g := NewWithT(t) From 48ccc620c725c06864c2c61109f08c20475b5878 Mon Sep 17 00:00:00 2001 From: leigh capili Date: Wed, 17 Jun 2026 22:06:16 -0600 Subject: [PATCH 6/6] cosign: unit-test trusted-root auto-detection Add table-driven coverage for the capability detection and CheckOpts mutation: keyless and keyed auto-detection paths, Rekor URL extraction with sorting and deduplication, and the online Rekor retry loop that walks each base URL while skipping bundle-format and tlog-ignored verification. Exercise empty, Rekor-only, Fulcio-only, and combined trusted roots. Assisted-by: GitHub Copilot CLI/gpt-5.5 Assisted-by: Kiro/opus-4.8 Signed-off-by: leigh capili --- internal/oci/cosign/auto_detect_test.go | 388 ++++++++++++++++++++++++ internal/oci/cosign/cosign_test.go | 84 +++++ 2 files changed, 472 insertions(+) create mode 100644 internal/oci/cosign/auto_detect_test.go diff --git a/internal/oci/cosign/auto_detect_test.go b/internal/oci/cosign/auto_detect_test.go new file mode 100644 index 000000000..8a03f2488 --- /dev/null +++ b/internal/oci/cosign/auto_detect_test.go @@ -0,0 +1,388 @@ +/* +Copyright 2026 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cosign + +import ( + "context" + "fmt" + "strings" + "testing" + + . "github.com/onsi/gomega" + "github.com/sigstore/cosign/v3/pkg/cosign" + "github.com/sigstore/sigstore-go/pkg/root" +) + +// trustedRootHeader is the header for a trusted root JSON document. +const trustedRootHeader = `"mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1"` + +// rekorEntryJSON is a minimal but complete transparency log entry. The public +// key is the same test ECDSA P-256 key used elsewhere in this package. +const rekorEntryJSON = `{ + "baseUrl": "https://rekor.example.com", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": {"start": "2021-01-12T11:53:27.000Z"} + }, + "logId": {"keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="} +}` + +// fulcioEntryJSON is a minimal Fulcio CA entry. The certificate is the +// sigstore.dev test CA used in the public TUF repository. +const fulcioEntryJSON = `{ + "subject": {"organization": "test", "commonName": "test"}, + "uri": "https://fulcio.example.com", + "certChain": {"certificates": [{"rawBytes": "MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ=="}]}, + "validFor": {"start": "2021-03-07T03:20:29.000Z", "end": "2099-12-31T23:59:59.999Z"} +}` + +// ctlogEntryJSON is a minimal CT log entry sharing the same test public key. +const ctlogEntryJSON = `{ + "baseUrl": "https://ctfe.example.com", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": {"start": "2021-01-12T11:53:27.000Z"} + }, + "logId": {"keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="} +}` + +// tsaEntryJSON is a minimal timestamping authority entry. The certificate is +// reused for both the leaf and the chain root since the trusted root format +// only requires a non-empty cert chain to populate TimestampingAuthorities(). +const tsaEntryJSON = `{ + "subject": {"organization": "test", "commonName": "test-tsa"}, + "uri": "https://tsa.example.com", + "certChain": {"certificates": [{"rawBytes": "MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ=="}]}, + "validFor": {"start": "2021-03-07T03:20:29.000Z"} +}` + +// makeTrustedRoot composes a trusted root JSON document with the requested +// component sets. +func makeTrustedRoot(t *testing.T, withFulcio, withRekor, withCTLog, withTSA bool) *root.TrustedRoot { + t.Helper() + parts := []string{trustedRootHeader} + if withFulcio { + parts = append(parts, fmt.Sprintf(`"certificateAuthorities": [%s]`, fulcioEntryJSON)) + } + if withRekor { + parts = append(parts, fmt.Sprintf(`"tlogs": [%s]`, rekorEntryJSON)) + } + if withCTLog { + parts = append(parts, fmt.Sprintf(`"ctlogs": [%s]`, ctlogEntryJSON)) + } + if withTSA { + parts = append(parts, fmt.Sprintf(`"timestampAuthorities": [%s]`, tsaEntryJSON)) + } + jsonStr := "{" + strings.Join(parts, ",") + "}" + tr, err := root.NewTrustedRootFromJSON([]byte(jsonStr)) + if err != nil { + t.Fatalf("failed to parse composed trusted root: %v\nJSON: %s", err, jsonStr) + } + return tr +} + +func TestDetectTrustedRootCapabilities(t *testing.T) { + tests := []struct { + name string + fulcio bool + rekor bool + ctlog bool + tsa bool + wantCap trustedRootCapabilities + }{ + { + name: "all components", + fulcio: true, rekor: true, ctlog: true, tsa: true, + wantCap: trustedRootCapabilities{HasFulcio: true, HasRekor: true, HasCTLog: true, HasTSA: true}, + }, + { + name: "rekor only", + rekor: true, + wantCap: trustedRootCapabilities{HasRekor: true}, + }, + { + name: "fulcio only", + fulcio: true, + wantCap: trustedRootCapabilities{HasFulcio: true}, + }, + { + name: "tsa only", + tsa: true, + wantCap: trustedRootCapabilities{HasTSA: true}, + }, + { + name: "fulcio and rekor (typical keyless)", + fulcio: true, rekor: true, + wantCap: trustedRootCapabilities{HasFulcio: true, HasRekor: true}, + }, + { + name: "fulcio and ctlog without rekor", + fulcio: true, ctlog: true, + wantCap: trustedRootCapabilities{HasFulcio: true, HasCTLog: true}, + }, + { + name: "rekor and tsa", + rekor: true, tsa: true, + wantCap: trustedRootCapabilities{HasRekor: true, HasTSA: true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + tr := makeTrustedRoot(t, tt.fulcio, tt.rekor, tt.ctlog, tt.tsa) + got := detectTrustedRootCapabilities(tr) + g.Expect(got).To(Equal(tt.wantCap)) + }) + } +} + +func TestApplyTrustedRootAutoDetection_Keyless(t *testing.T) { + tests := []struct { + name string + fulcio bool + rekor bool + ctlog bool + tsa bool + wantIgnoreTlog bool + wantUseSignedTimestamps bool + wantIgnoreSCT bool + }{ + { + name: "fulcio + rekor + ctlog (typical public Sigstore)", + fulcio: true, rekor: true, ctlog: true, + wantIgnoreTlog: false, + wantUseSignedTimestamps: false, + wantIgnoreSCT: false, + }, + { + name: "fulcio + tsa (no tlog)", + fulcio: true, tsa: true, + wantIgnoreTlog: true, + wantUseSignedTimestamps: true, + wantIgnoreSCT: true, + }, + { + name: "rekor only (tlog-only policy)", + rekor: true, + wantIgnoreTlog: false, + wantUseSignedTimestamps: false, + wantIgnoreSCT: true, + }, + { + name: "all four components", + fulcio: true, rekor: true, ctlog: true, tsa: true, + wantIgnoreTlog: false, + wantUseSignedTimestamps: true, + wantIgnoreSCT: false, + }, + { + // GitHub-style immutable releases: keyless verification anchored + // in TSA timestamps and Fulcio identity rather than a Rekor tlog. + name: "fulcio + ctlog + tsa (GitHub-style, no Rekor)", + fulcio: true, ctlog: true, tsa: true, + wantIgnoreTlog: true, + wantUseSignedTimestamps: true, + wantIgnoreSCT: false, + }, + { + name: "tsa only", + tsa: true, + wantIgnoreTlog: true, + wantUseSignedTimestamps: true, + wantIgnoreSCT: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + tr := makeTrustedRoot(t, tt.fulcio, tt.rekor, tt.ctlog, tt.tsa) + caps := detectTrustedRootCapabilities(tr) + co := &cosign.CheckOpts{} + applyTrustedRootAutoDetection(co, tr, caps) + g.Expect(co.TrustedMaterial).NotTo(BeNil()) + g.Expect(co.IgnoreTlog).To(Equal(tt.wantIgnoreTlog)) + g.Expect(co.UseSignedTimestamps).To(Equal(tt.wantUseSignedTimestamps)) + g.Expect(co.IgnoreSCT).To(Equal(tt.wantIgnoreSCT)) + g.Expect(co.RekorClient).To(BeNil()) + }) + } +} + +func TestNewCosignVerifier_KeylessAutoDetect(t *testing.T) { + ctx := context.Background() + vf := NewCosignVerifierFactory() + + t.Run("rejects empty bundle", func(t *testing.T) { + g := NewWithT(t) + tr := makeTrustedRoot(t, false, false, false, false) + marshaled, err := tr.MarshalJSON() + g.Expect(err).NotTo(HaveOccurred()) + _, err = vf.NewCosignVerifier(ctx, WithTrustedRoot(marshaled)) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("must contain Fulcio and at least one of Rekor or TSA")) + }) + + for _, tt := range []struct { + name string + fulcio bool + rekor bool + ctlog bool + tsa bool + }{ + {name: "rejects fulcio only", fulcio: true}, + {name: "rejects rekor only", rekor: true}, + {name: "rejects tsa only", tsa: true}, + {name: "rejects rekor + tsa without fulcio", rekor: true, tsa: true}, + {name: "rejects fulcio + ctlog without time source", fulcio: true, ctlog: true}, + } { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + tr := makeTrustedRoot(t, tt.fulcio, tt.rekor, tt.ctlog, tt.tsa) + marshaled, err := tr.MarshalJSON() + g.Expect(err).NotTo(HaveOccurred()) + _, err = vf.NewCosignVerifier(ctx, WithTrustedRoot(marshaled)) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("must contain Fulcio and at least one of Rekor or TSA")) + }) + } + + t.Run("fulcio + rekor matches typical keyless", func(t *testing.T) { + g := NewWithT(t) + tr := makeTrustedRoot(t, true, true, false, false) + marshaled, err := tr.MarshalJSON() + g.Expect(err).NotTo(HaveOccurred()) + v, err := vf.NewCosignVerifier(ctx, WithTrustedRoot(marshaled)) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(v.opts.TrustedMaterial).NotTo(BeNil()) + g.Expect(v.opts.RekorClient).To(BeNil()) + g.Expect(v.rekorURLs).To(Equal([]string{"https://rekor.example.com"})) + g.Expect(v.opts.IgnoreTlog).To(BeFalse()) + g.Expect(v.opts.UseSignedTimestamps).To(BeFalse()) + g.Expect(v.opts.IgnoreSCT).To(BeTrue()) + }) + + t.Run("fulcio + tsa skips tlog and requires signed timestamps", func(t *testing.T) { + g := NewWithT(t) + tr := makeTrustedRoot(t, true, false, false, true) + marshaled, err := tr.MarshalJSON() + g.Expect(err).NotTo(HaveOccurred()) + v, err := vf.NewCosignVerifier(ctx, WithTrustedRoot(marshaled)) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(v.opts.TrustedMaterial).NotTo(BeNil()) + g.Expect(v.opts.RekorClient).To(BeNil()) + g.Expect(v.rekorURLs).To(BeEmpty()) + g.Expect(v.opts.IgnoreTlog).To(BeTrue()) + g.Expect(v.opts.UseSignedTimestamps).To(BeTrue()) + }) +} + +func TestNewCosignVerifier_KeyedAutoDetect(t *testing.T) { + ctx := context.Background() + vf := NewCosignVerifierFactory() + + // A throwaway ECDSA P-256 public key in PEM form, generated for tests. + pubKey := []byte(`-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwr +kBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw== +-----END PUBLIC KEY----- +`) + + t.Run("public key without trusted root keeps legacy offline tlog skip", func(t *testing.T) { + g := NewWithT(t) + v, err := vf.NewCosignVerifier(ctx, WithPublicKey(pubKey)) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(v.opts.SigVerifier).NotTo(BeNil()) + g.Expect(v.opts.TrustedMaterial).To(BeNil()) + g.Expect(v.opts.IgnoreTlog).To(BeTrue()) + g.Expect(v.opts.Offline).To(BeTrue()) + g.Expect(v.opts.UseSignedTimestamps).To(BeFalse()) + }) + + t.Run("public key + rekor-only trusted root enables tlog verification", func(t *testing.T) { + g := NewWithT(t) + tr := makeTrustedRoot(t, false, true, false, false) + marshaled, err := tr.MarshalJSON() + g.Expect(err).NotTo(HaveOccurred()) + v, err := vf.NewCosignVerifier(ctx, + WithPublicKey(pubKey), + WithTrustedRoot(marshaled), + ) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(v.opts.SigVerifier).NotTo(BeNil()) + g.Expect(v.opts.TrustedMaterial).NotTo(BeNil()) + g.Expect(v.opts.RekorClient).To(BeNil()) + g.Expect(v.rekorURLs).To(Equal([]string{"https://rekor.example.com"})) + g.Expect(v.opts.IgnoreTlog).To(BeFalse()) + g.Expect(v.opts.UseSignedTimestamps).To(BeFalse()) + }) + + t.Run("public key + tsa-only trusted root enables signed timestamps and skips tlog", func(t *testing.T) { + g := NewWithT(t) + tr := makeTrustedRoot(t, false, false, false, true) + marshaled, err := tr.MarshalJSON() + g.Expect(err).NotTo(HaveOccurred()) + v, err := vf.NewCosignVerifier(ctx, + WithPublicKey(pubKey), + WithTrustedRoot(marshaled), + ) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(v.opts.SigVerifier).NotTo(BeNil()) + g.Expect(v.opts.TrustedMaterial).NotTo(BeNil()) + g.Expect(v.opts.RekorClient).To(BeNil()) + g.Expect(v.rekorURLs).To(BeEmpty()) + g.Expect(v.opts.IgnoreTlog).To(BeTrue()) + g.Expect(v.opts.UseSignedTimestamps).To(BeTrue()) + }) + + t.Run("public key + rekor + tsa requires both", func(t *testing.T) { + g := NewWithT(t) + tr := makeTrustedRoot(t, false, true, false, true) + marshaled, err := tr.MarshalJSON() + g.Expect(err).NotTo(HaveOccurred()) + v, err := vf.NewCosignVerifier(ctx, + WithPublicKey(pubKey), + WithTrustedRoot(marshaled), + ) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(v.opts.RekorClient).To(BeNil()) + g.Expect(v.rekorURLs).To(Equal([]string{"https://rekor.example.com"})) + g.Expect(v.opts.IgnoreTlog).To(BeFalse()) + g.Expect(v.opts.UseSignedTimestamps).To(BeTrue()) + }) + + t.Run("public key + ctlog-only trusted root is allowed for keyed verification", func(t *testing.T) { + g := NewWithT(t) + tr := makeTrustedRoot(t, false, false, true, false) + marshaled, err := tr.MarshalJSON() + g.Expect(err).NotTo(HaveOccurred()) + v, err := vf.NewCosignVerifier(ctx, + WithPublicKey(pubKey), + WithTrustedRoot(marshaled), + ) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(v.opts.SigVerifier).NotTo(BeNil()) + g.Expect(v.opts.TrustedMaterial).NotTo(BeNil()) + g.Expect(v.opts.IgnoreTlog).To(BeTrue()) + g.Expect(v.opts.IgnoreSCT).To(BeTrue()) + }) +} diff --git a/internal/oci/cosign/cosign_test.go b/internal/oci/cosign/cosign_test.go index 29d49c800..815fe133a 100644 --- a/internal/oci/cosign/cosign_test.go +++ b/internal/oci/cosign/cosign_test.go @@ -18,6 +18,7 @@ package cosign import ( "context" + "errors" "fmt" "net/http" "net/url" @@ -30,6 +31,7 @@ import ( "github.com/google/go-containerregistry/pkg/v1/remote" . "github.com/onsi/gomega" "github.com/sigstore/cosign/v3/pkg/cosign" + "github.com/sigstore/cosign/v3/pkg/oci" "github.com/sigstore/sigstore-go/pkg/root" testproxy "github.com/fluxcd/source-controller/tests/proxy" @@ -235,6 +237,88 @@ func TestNewCosignVerifierWithTrustedRoot(t *testing.T) { }) } +func TestVerifyWithRekorURLs(t *testing.T) { + tests := []struct { + name string + opts cosign.CheckOpts + rekorURLs []string + verify func(attempt int, opts cosign.CheckOpts) ([]oci.Signature, error) + wantAttempts []bool + wantErr string + }{ + { + name: "retries each Rekor URL for legacy online lookup", + rekorURLs: []string{"https://rekor-a.example.com", "https://rekor-b.example.com"}, + verify: func(_ int, _ cosign.CheckOpts) ([]oci.Signature, error) { + return nil, errors.New("verify failed") + }, + wantAttempts: []bool{false, true, true}, + wantErr: "rekor \"https://rekor-b.example.com\"", + }, + { + name: "returns successful Rekor retry", + rekorURLs: []string{"https://rekor-a.example.com", "https://rekor-b.example.com"}, + verify: func(attempt int, _ cosign.CheckOpts) ([]oci.Signature, error) { + if attempt == 1 { + return []oci.Signature{nil}, nil + } + return nil, errors.New("verify failed") + }, + wantAttempts: []bool{false, true}, + }, + { + name: "does not retry bundle verification", + opts: cosign.CheckOpts{NewBundleFormat: true}, + rekorURLs: []string{"https://rekor-a.example.com"}, + verify: func(_ int, _ cosign.CheckOpts) ([]oci.Signature, error) { + return nil, errors.New("bundle failed") + }, + wantAttempts: []bool{false}, + wantErr: "bundle failed", + }, + { + name: "does not retry when tlog verification is ignored", + opts: cosign.CheckOpts{IgnoreTlog: true}, + rekorURLs: []string{"https://rekor-a.example.com"}, + verify: func(_ int, _ cosign.CheckOpts) ([]oci.Signature, error) { + return nil, errors.New("verify failed") + }, + wantAttempts: []bool{false}, + wantErr: "verify failed", + }, + { + name: "does not retry without Rekor URLs", + rekorURLs: nil, + verify: func(_ int, _ cosign.CheckOpts) ([]oci.Signature, error) { + return nil, errors.New("verify failed") + }, + wantAttempts: []bool{false}, + wantErr: "verify failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + verifier := &CosignVerifier{rekorURLs: tt.rekorURLs} + var attempts []bool + signatures, err := verifier.verifyWithRekorURLs(tt.opts, func(opts cosign.CheckOpts) ([]oci.Signature, error) { + attempts = append(attempts, opts.RekorClient != nil) + return tt.verify(len(attempts)-1, opts) + }) + + g.Expect(attempts).To(Equal(tt.wantAttempts)) + if tt.wantErr != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.wantErr)) + return + } + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(signatures).To(HaveLen(1)) + }) + } +} + // trustedRootJSON returns a minimal valid trusted_root.json with the given // Rekor base URL. The ECDSA P-256 public key is a test key. func trustedRootJSON(rekorURL string) string {