diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml
index 756f18d68..b798459ce 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@v4.1.2
+ - 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/api/v1/ociverification_types.go b/api/v1/ociverification_types.go
index 43c876b62..8e52b19c2 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_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 b01da91a1..ea94c5fe7 100644
--- a/docs/api/v1/source.md
+++ b/docs/api/v1/source.md
@@ -3737,9 +3737,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/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/.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..7f132a3db
--- /dev/null
+++ b/hack/sigstore-test/build-and-load.sh
@@ -0,0 +1,32 @@
+#!/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}"
+ARCH=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/amd64/')
+BUILD_PLATFORM="${BUILD_PLATFORM:-linux/${ARCH}}"
+
+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/test-signing.sh b/hack/sigstore-test/test-signing.sh
new file mode 100755
index 000000000..463276264
--- /dev/null
+++ b/hack/sigstore-test/test-signing.sh
@@ -0,0 +1,370 @@
+#!/usr/bin/env bash
+# test-signing.sh: Validate cosign v2/v3 x key-pair/keyless verification flows
+# 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
+
+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}"
+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 ---
+
+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"
+
+# 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 \
+ --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 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 \
+ --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"
+
+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" \
+ --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}/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() {
+ 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-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:
+# 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..31c8daa73
--- /dev/null
+++ b/hack/sigstore-test/testdata/combined-secretref-trustedroot.yaml
@@ -0,0 +1,22 @@
+---
+# 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:
+ 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-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/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-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-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-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$"
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$"
diff --git a/hack/sigstore-test/trillian.mysql.values.yaml b/hack/sigstore-test/trillian.mysql.values.yaml
new file mode 100644
index 000000000..1fab44bac
--- /dev/null
+++ b/hack/sigstore-test/trillian.mysql.values.yaml
@@ -0,0 +1,47 @@
+# 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` over TCP instead. The
+# plain `mysqladmin ping` connects to the default unix socket
+# (/var/run/mysqld/mysqld.sock), but the 8.4 image's mysqld creates its
+# socket at /var/lib/mysql/mysql.sock, so a socket ping fails with
+# "connect to server at 'localhost' failed" even though the server is up.
+# A failing readiness probe drops the pod from the trillian-mysql Service
+# endpoints, so createdb and the log server get "connection refused" and
+# the whole stack stalls. Forcing --protocol=tcp -h 127.0.0.1 pings the
+# same TCP 3306 the clients use. The chart renders only liveness/readiness
+# (no startupProbe), so the liveness probe uses a generous
+# initialDelaySeconds + failureThreshold to tolerate a slow first-run init.
+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", "--protocol=tcp", "-h", "127.0.0.1", "ping"]
+ readinessProbe:
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ timeoutSeconds: 5
+ failureThreshold: 6
+ exec:
+ command: ["mysqladmin", "--protocol=tcp", "-h", "127.0.0.1", "ping"]
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.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..815fe133a 100644
--- a/internal/oci/cosign/cosign_test.go
+++ b/internal/oci/cosign/cosign_test.go
@@ -18,10 +18,12 @@ package cosign
import (
"context"
+ "errors"
"fmt"
"net/http"
"net/url"
"reflect"
+ "strings"
"testing"
"github.com/google/go-containerregistry/pkg/authn"
@@ -29,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"
@@ -148,22 +151,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: "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 no tlogs",
+ json: `{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1","tlogs":[]}`,
+ wantURLs: nil,
+ },
+ {
+ name: "returns empty slice when tlogs have no base URLs",
+ json: trustedRootWithTLogsJSON(
+ rekorTLogJSON("", "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="),
+ ),
+ wantURLs: nil,
},
}
@@ -172,20 +191,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 +224,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) {
@@ -223,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 {
@@ -283,6 +379,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)