diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 570d4edd5..ea4c7f9c4 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -33,3 +33,73 @@ 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: Checkout + uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: 1.26.x + - name: Setup Kubernetes + uses: helm/kind-action@v1 + with: + install_only: true + - name: Setup tools + run: | + curl -fsSL https://fluxcd.io/install.sh | bash + helm repo add sigstore https://sigstore.github.io/helm-charts + - name: Create cluster and registries + run: hack/sigstore-test/kind-up.sh + - name: Install sigstore stack + run: hack/sigstore-test/setup-sigstore.sh + - name: Patch Fulcio config + run: | + kubectl -n fulcio-system get cm fulcio-server-config -o json | \ + python3 -c " + import json, sys + cm = json.load(sys.stdin) + config = json.loads(cm['data']['config.json']) + config['OIDCIssuers']['https://kubernetes.default.svc.cluster.local'] = { + 'IssuerURL': 'https://kubernetes.default.svc.cluster.local', + 'ClientID': 'sigstore', + 'Type': 'kubernetes' + } + cm['data']['config.json'] = json.dumps(config, indent=2) + json.dump(cm, sys.stdout) + " | kubectl apply -f - + kubectl -n fulcio-system rollout restart deploy/fulcio-server + kubectl -n fulcio-system rollout status deploy/fulcio-server --timeout=2m + - name: Expose sigstore services + run: | + kubectl -n rekor-system expose deploy rekor-server --name=rekor-np --type=NodePort --port=80 --target-port=3000 + cat <>> 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}" + +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..f8fcc2440 --- /dev/null +++ b/hack/sigstore-test/fetch-cosign.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Fetch cosign v2 and v3 binaries for testing. +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) +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}" + +# cosign v2 (last v2 release) +COSIGN_V2_VERSION="${COSIGN_V2_VERSION:-v2.4.3}" +COSIGN_V2_URL="https://github.com/sigstore/cosign/releases/download/${COSIGN_V2_VERSION}/cosign-${OS}-${ARCH}" + +fetch_binary() { + local name="$1" url="$2" dest="$3" + 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 + "${dest}" version 2>&1 | head -3 + echo "" +} + +fetch_binary "cosign-v3" "${COSIGN_V3_URL}" "${BIN_DIR}/cosign-v3" +fetch_binary "cosign-v2" "${COSIGN_V2_URL}" "${BIN_DIR}/cosign-v2" + +echo "=== Cosign binaries ready ===" +echo " v2: ${BIN_DIR}/cosign-v2" +echo " v3: ${BIN_DIR}/cosign-v3" diff --git a/hack/sigstore-test/kind-down.sh b/hack/sigstore-test/kind-down.sh new file mode 100755 index 000000000..38710eb5b --- /dev/null +++ b/hack/sigstore-test/kind-down.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +CLUSTER_NAME="${CLUSTER_NAME:-sigstore-test}" +REG_NAME="${CLUSTER_NAME}-registry" + +echo ">>> tearing down sigstore test environment" + +echo ">>> killing port-forwards" +pkill -f "kubectl.*port-forward.*sigstore" 2>/dev/null || true + +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 ">>> done" diff --git a/hack/sigstore-test/kind-up.sh b/hack/sigstore-test/kind-up.sh new file mode 100755 index 000000000..f228e2ea9 --- /dev/null +++ b/hack/sigstore-test/kind-up.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Spin up a kind cluster with a local OCI registry on the same Docker network. +# Sigstore stack installation is a separate step (see setup-sigstore.sh). +set -euo pipefail + +CLUSTER_NAME="${CLUSTER_NAME:-sigstore-test}" +REG_NAME="${CLUSTER_NAME}-registry" +REG_LOCALHOST_PORT="${REG_LOCALHOST_PORT:-5555}" +REG_CLUSTER_PORT=5000 +NODE_IMAGE="${KIND_NODE_IMAGE:-kindest/node:v1.32.2}" + +echo "=== Phase 1: Local OCI Registries ===" +# 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 ${REG_NAME} on localhost:${REG_LOCALHOST_PORT}" + docker run -d --restart=always \ + -p "127.0.0.1:${REG_LOCALHOST_PORT}:5000" \ + --name "${REG_NAME}" \ + ghcr.io/project-zot/zot-linux-$(uname -m | sed 's/aarch64/arm64/;s/x86_64/amd64/'):latest +else + echo ">>> registry ${REG_NAME} already running" +fi + +# Fallback registry: registry:2 (tag-based referrers only, no referrers API) +REG2_NAME="${CLUSTER_NAME}-registry2" +REG2_LOCALHOST_PORT="${REG2_LOCALHOST_PORT:-5557}" +if [ "$(docker inspect -f '{{.State.Running}}' "${REG2_NAME}" 2>/dev/null || true)" != 'true' ]; then + echo ">>> starting registry:2 ${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:2 +else + echo ">>> registry:2 ${REG2_NAME} already running" +fi + +echo "=== Phase 2: Kind Cluster ===" +if ! kind get clusters 2>/dev/null | grep -q "^${CLUSTER_NAME}$"; then + echo ">>> creating kind cluster ${CLUSTER_NAME}" + cat <>> cluster ${CLUSTER_NAME} already exists" +fi + +# Connect registries to kind network +if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${REG_NAME}" 2>/dev/null)" = 'null' ]; then + echo ">>> connecting registry:3 to kind network" + docker network connect "kind" "${REG_NAME}" +fi +if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${REG2_NAME}" 2>/dev/null)" = 'null' ]; then + echo ">>> connecting registry:2 to kind network" + docker network connect "kind" "${REG2_NAME}" +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 + +# Allow unauthenticated OIDC discovery (needed for Fulcio to validate SA tokens) +kubectl create clusterrolebinding oidc-reviewer \ + --clusterrole=system:service-account-issuer-discovery \ + --group=system:unauthenticated 2>/dev/null || true + +echo "" +echo "=== Cluster Ready ===" +echo " cluster: ${CLUSTER_NAME}" +echo " registry3: localhost:${REG_LOCALHOST_PORT} (in-cluster: ${REG_NAME}:${REG_CLUSTER_PORT})" +echo " registry2: localhost:${REG2_LOCALHOST_PORT} (in-cluster: ${REG2_NAME}:${REG_CLUSTER_PORT})" +echo "" +echo "Next: run setup-sigstore.sh to install the sigstore stack" diff --git a/hack/sigstore-test/port-forward.sh b/hack/sigstore-test/port-forward.sh new file mode 100755 index 000000000..5542f97c9 --- /dev/null +++ b/hack/sigstore-test/port-forward.sh @@ -0,0 +1,29 @@ +#!/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 + +echo ">>> setting up port-forwarding to sigstore services" + +# Kill any existing port-forwards +pkill -f "kubectl.*port-forward.*sigstore" 2>/dev/null || true +sleep 1 + +# Rekor +kubectl -n rekor-system port-forward svc/rekor-server 3000:80 &>/dev/null & +# Fulcio +kubectl -n fulcio-system port-forward svc/fulcio-server 5555:80 &>/dev/null & +# TUF +kubectl -n tuf-system port-forward svc/tuf 8081:80 &>/dev/null & + +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. Use 'kill %1 %2 %3' to stop." diff --git a/hack/sigstore-test/setup-sigstore.sh b/hack/sigstore-test/setup-sigstore.sh new file mode 100755 index 000000000..3052eacfa --- /dev/null +++ b/hack/sigstore-test/setup-sigstore.sh @@ -0,0 +1,52 @@ +#!/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)" + +echo "=== Installing Sigstore Stack ===" +helm repo add sigstore https://sigstore.github.io/helm-charts 2>/dev/null || true +helm repo update sigstore + +echo ">>> installing sigstore/scaffold (this takes a few minutes)..." +helm upgrade --install scaffold sigstore/scaffold \ + --namespace sigstore --create-namespace \ + --timeout 10m \ + --wait + +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 + kubectl rollout status --timeout=5m -n "${ns}" "${deploy}" 2>/dev/null || true + done + 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 "=== Sigstore Stack Ready ===" +echo " pki: ${SCRIPT_DIR}/pki/" +ls -la "${SCRIPT_DIR}/pki/" 2>/dev/null diff --git a/hack/sigstore-test/test-signing.sh b/hack/sigstore-test/test-signing.sh new file mode 100755 index 000000000..367e5b265 --- /dev/null +++ b/hack/sigstore-test/test-signing.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# test-signing.sh: Validate cosign v2/v3 x key-pair/keyless verification flows. +# +# Prerequisites: +# - kind cluster running (hack/sigstore-test/kind-up.sh) +# - sigstore stack installed (hack/sigstore-test/setup-sigstore.sh) +# - source-controller deployed (hack/sigstore-test/build-and-load.sh) +# - fulcio config patched for cluster.local issuer +# - rekor-np and fulcio-np NodePort services created +set -eoux 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:5555" +REG2="localhost:5557" +NS="source-system" + +NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}') +REKOR_NP=$(kubectl -n rekor-system get svc rekor-np -o jsonpath='{.spec.ports[0].nodePort}') +FULCIO_NP=$(kubectl -n fulcio-system get svc fulcio-np -o jsonpath='{.spec.ports[0].nodePort}') +REKOR_URL="http://${NODE_IP}:${REKOR_NP}" +FULCIO_URL="http://${NODE_IP}:${FULCIO_NP}" + +# --- Setup keys and secrets --- + +mkdir -p "$KEYS_DIR" "$PKI_DIR" + +if [ ! -f "$KEYS_DIR/test.key" ]; then + COSIGN_PASSWORD="" cosign generate-key-pair --output-key-prefix="$KEYS_DIR/test" +fi +if [ ! -f "$KEYS_DIR/wrong.key" ]; then + COSIGN_PASSWORD="" cosign generate-key-pair --output-key-prefix="$KEYS_DIR/wrong" +fi +if [ ! -f "$KEYS_DIR/signing-config-notlog.json" ]; then + cosign signing-config create --out "$KEYS_DIR/signing-config-notlog.json" +fi + +if [ ! -s "$PKI_DIR/trusted_root.json" ]; then + 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" +fi + +# Wrong trusted root (uses wrong.pub as rekor 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=$KEYS_DIR/wrong.pub,start-time=2024-01-01T00:00:00Z" \ + --ctfe="url=http://ctlog.ctlog-system.svc,public-key=$PKI_DIR/ctfe.pub,start-time=2024-01-01T00:00:00Z" \ + --out "$PKI_DIR/wrong_trusted_root.json" + +kubectl -n "$NS" create secret generic cosign-test-key \ + --from-file=cosign.pub="$KEYS_DIR/test.pub" --dry-run=client -o yaml | kubectl apply -f - +kubectl -n "$NS" create secret generic cosign-wrong-key \ + --from-file=cosign.pub="$KEYS_DIR/wrong.pub" --dry-run=client -o yaml | kubectl apply -f - +kubectl -n "$NS" create secret generic sigstore-trusted-root \ + --from-file=trusted_root.json="$PKI_DIR/trusted_root.json" --dry-run=client -o yaml | kubectl apply -f - +kubectl -n "$NS" create secret generic sigstore-wrong-root \ + --from-file=trusted_root.json="$PKI_DIR/wrong_trusted_root.json" --dry-run=client -o yaml | kubectl apply -f - +kubectl -n "$NS" create secret docker-registry registry-creds \ + --docker-server="sigstore-test-registry:5000" \ + --docker-username=user --docker-password=pass \ + --dry-run=client -o yaml | kubectl apply -f - + +# --- Helper --- + +push_artifact() { + local ref="$1" + local tmp + tmp=$(mktemp -d) + echo "{\"test\":\"$(basename "$ref")\"}" > "$tmp/data.yaml" + flux push artifact "oci://$ref" --path="$tmp" --source=test --revision=v1 + rm -rf "$tmp" +} + +# --- Sign artifacts --- + +echo "Run cosign v2-style key-pair tests" +push_artifact "$REG/test/v2-key:v1" +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" +cosign sign \ + --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 \ + --identity-token="$(kubectl create token default -n default --audience=sigstore)" \ + --yes "$REG/test/v2-keyless:v1" + +echo "Run cosign v3 bundle keyless tests" +push_artifact "$REG/test/v3-keyless:v1" +cosign sign \ + --trusted-root="$PKI_DIR/trusted_root.json" \ + --fulcio-url="$FULCIO_URL" --rekor-url="$REKOR_URL" \ + --allow-insecure-registry --use-signing-config=false \ + --identity-token="$(kubectl create token default -n default --audience=sigstore)" \ + --yes "$REG/test/v3-keyless:v1" + +echo "Run cosign v3 key-pair with tlog tests" +push_artifact "$REG/test/v3-key-tlog:v1" +COSIGN_PASSWORD="" cosign sign --key="$KEYS_DIR/test.key" \ + --trusted-root="$PKI_DIR/trusted_root.json" \ + --rekor-url="$REKOR_URL" \ + --allow-insecure-registry --use-signing-config=false \ + --yes "$REG/test/v3-key-tlog:v1" + +echo "Run registry auth test" +push_artifact "$REG/test/authed:v1" +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 \ + --trusted-root="$PKI_DIR/trusted_root.json" \ + --fulcio-url="$FULCIO_URL" --rekor-url="$REKOR_URL" \ + --allow-insecure-registry --use-signing-config=false \ + --identity-token="$(kubectl create token default -n default --audience=sigstore)" \ + --yes "$REG2/test/v3-keyless-fallback:v1" + +push_artifact "$REG2/test/v3-key-tlog-fallback:v1" +COSIGN_PASSWORD="" cosign sign --key="$KEYS_DIR/test.key" \ + --trusted-root="$PKI_DIR/trusted_root.json" \ + --rekor-url="$REKOR_URL" \ + --allow-insecure-registry --use-signing-config=false \ + --yes "$REG2/test/v3-key-tlog-fallback:v1" + +# --- Apply and verify --- + +echo "Run OCIRepository verify tests" +kubectl -n "$NS" apply -f "${TESTDATA}/v2-key.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/v3-key.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/v2-keyless-trustedroot.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/v3-keyless-trustedroot.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/v3-key-tlog.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/combined-secretref-trustedroot.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/registry-auth.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/registry2-fallback.yaml" + +kubectl -n "$NS" wait ocirepository/test-v2-key --for=condition=ready --timeout=1m +kubectl -n "$NS" wait ocirepository/test-v3-key --for=condition=ready --timeout=1m +kubectl -n "$NS" wait ocirepository/test-v2-keyless --for=condition=ready --timeout=1m +kubectl -n "$NS" wait ocirepository/test-v3-keyless --for=condition=ready --timeout=1m +kubectl -n "$NS" wait ocirepository/test-v3-key-tlog --for=condition=ready --timeout=1m +kubectl -n "$NS" wait ocirepository/test-combined --for=condition=ready --timeout=1m +kubectl -n "$NS" wait ocirepository/test-authed --for=condition=ready --timeout=1m +kubectl -n "$NS" wait ocirepository/test-v3-key-fallback --for=condition=ready --timeout=1m +kubectl -n "$NS" wait ocirepository/test-v3-keyless-fallback --for=condition=ready --timeout=1m +kubectl -n "$NS" wait ocirepository/test-v3-key-tlog-fallback --for=condition=ready --timeout=1m + +echo "Run negative verification tests" +kubectl -n "$NS" apply -f "${TESTDATA}/wrong-key.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/wrong-identity.yaml" +kubectl -n "$NS" apply -f "${TESTDATA}/wrong-rekor-key.yaml" + +# Negative tests: wait for VerificationError condition +sleep 30 +kubectl -n "$NS" get ocirepository test-wrong-key -o jsonpath='{.status.conditions[?(@.type=="Ready")].reason}' | grep -q "VerificationError" +kubectl -n "$NS" get ocirepository test-wrong-identity -o jsonpath='{.status.conditions[?(@.type=="Ready")].reason}' | grep -q "VerificationError" +kubectl -n "$NS" get ocirepository test-wrong-rekor -o jsonpath='{.status.conditions[?(@.type=="Ready")].reason}' | grep -q "VerificationError" + +echo "All sigstore verification tests passed!" diff --git a/hack/sigstore-test/testdata/combined-secretref-trustedroot.yaml b/hack/sigstore-test/testdata/combined-secretref-trustedroot.yaml new file mode 100644 index 000000000..af45c4c0f --- /dev/null +++ b/hack/sigstore-test/testdata/combined-secretref-trustedroot.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-combined +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v2-key + ref: + tag: v1 + insecure: true + verify: + provider: cosign + secretRef: + name: cosign-test-key + trustedRootSecretRef: + name: sigstore-trusted-root diff --git a/hack/sigstore-test/testdata/registry-auth.yaml b/hack/sigstore-test/testdata/registry-auth.yaml new file mode 100644 index 000000000..dd27bce12 --- /dev/null +++ b/hack/sigstore-test/testdata/registry-auth.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-authed +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/authed + ref: + tag: v1 + insecure: true + secretRef: + name: registry-creds + verify: + provider: cosign + secretRef: + name: cosign-test-key diff --git a/hack/sigstore-test/testdata/registry2-fallback.yaml b/hack/sigstore-test/testdata/registry2-fallback.yaml new file mode 100644 index 000000000..aa0437ce8 --- /dev/null +++ b/hack/sigstore-test/testdata/registry2-fallback.yaml @@ -0,0 +1,48 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-v3-key-fallback +spec: + interval: 5m + url: oci://sigstore-test-registry2:5000/test/v3-key-fallback + ref: + tag: v1 + insecure: true + verify: + provider: cosign + secretRef: + name: cosign-test-key +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-v3-keyless-fallback +spec: + interval: 5m + url: oci://sigstore-test-registry2:5000/test/v3-keyless-fallback + ref: + tag: v1 + insecure: true + verify: + provider: cosign + trustedRootSecretRef: + name: sigstore-trusted-root + matchOIDCIdentity: + - issuer: "^https://kubernetes\\.default\\.svc" + subject: "^https://kubernetes\\.io/namespaces/default/serviceaccounts/default$" +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-v3-key-tlog-fallback +spec: + interval: 5m + url: oci://sigstore-test-registry2:5000/test/v3-key-tlog-fallback + ref: + tag: v1 + insecure: true + verify: + provider: cosign + secretRef: + name: cosign-test-key diff --git a/hack/sigstore-test/testdata/v2-key.yaml b/hack/sigstore-test/testdata/v2-key.yaml new file mode 100644 index 000000000..bc3bfaa49 --- /dev/null +++ b/hack/sigstore-test/testdata/v2-key.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-v2-key +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v2-key + ref: + tag: v1 + insecure: true + verify: + provider: cosign + secretRef: + name: cosign-test-key diff --git a/hack/sigstore-test/testdata/v2-keyless-trustedroot.yaml b/hack/sigstore-test/testdata/v2-keyless-trustedroot.yaml new file mode 100644 index 000000000..be437b5f9 --- /dev/null +++ b/hack/sigstore-test/testdata/v2-keyless-trustedroot.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-v2-keyless +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v2-keyless + ref: + tag: v1 + insecure: true + verify: + provider: cosign + trustedRootSecretRef: + name: sigstore-trusted-root + matchOIDCIdentity: + - issuer: "^https://kubernetes\\.default\\.svc" + subject: "^https://kubernetes\\.io/namespaces/default/serviceaccounts/default$" diff --git a/hack/sigstore-test/testdata/v3-key-tlog.yaml b/hack/sigstore-test/testdata/v3-key-tlog.yaml new file mode 100644 index 000000000..1e4b21915 --- /dev/null +++ b/hack/sigstore-test/testdata/v3-key-tlog.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-v3-key-tlog +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v3-key-tlog + ref: + tag: v1 + insecure: true + verify: + provider: cosign + secretRef: + name: cosign-test-key diff --git a/hack/sigstore-test/testdata/v3-key.yaml b/hack/sigstore-test/testdata/v3-key.yaml new file mode 100644 index 000000000..97b5e46e2 --- /dev/null +++ b/hack/sigstore-test/testdata/v3-key.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-v3-key +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v3-key + ref: + tag: v1 + insecure: true + verify: + provider: cosign + secretRef: + name: cosign-test-key diff --git a/hack/sigstore-test/testdata/v3-keyless-trustedroot.yaml b/hack/sigstore-test/testdata/v3-keyless-trustedroot.yaml new file mode 100644 index 000000000..e81324884 --- /dev/null +++ b/hack/sigstore-test/testdata/v3-keyless-trustedroot.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-v3-keyless +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v3-keyless + ref: + tag: v1 + insecure: true + verify: + provider: cosign + trustedRootSecretRef: + name: sigstore-trusted-root + matchOIDCIdentity: + - issuer: "^https://kubernetes\\.default\\.svc" + subject: "^https://kubernetes\\.io/namespaces/default/serviceaccounts/default$" diff --git a/hack/sigstore-test/testdata/wrong-identity.yaml b/hack/sigstore-test/testdata/wrong-identity.yaml new file mode 100644 index 000000000..3c6b67ec2 --- /dev/null +++ b/hack/sigstore-test/testdata/wrong-identity.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-wrong-identity +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v2-keyless + ref: + tag: v1 + insecure: true + verify: + provider: cosign + trustedRootSecretRef: + name: sigstore-trusted-root + matchOIDCIdentity: + - issuer: "^https://wrong-issuer\\.example\\.com$" + subject: "^wrong-subject@example\\.com$" diff --git a/hack/sigstore-test/testdata/wrong-key.yaml b/hack/sigstore-test/testdata/wrong-key.yaml new file mode 100644 index 000000000..42ba87c39 --- /dev/null +++ b/hack/sigstore-test/testdata/wrong-key.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-wrong-key +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v2-key + ref: + tag: v1 + insecure: true + verify: + provider: cosign + secretRef: + name: cosign-wrong-key diff --git a/hack/sigstore-test/testdata/wrong-rekor-key.yaml b/hack/sigstore-test/testdata/wrong-rekor-key.yaml new file mode 100644 index 000000000..bdcf5482a --- /dev/null +++ b/hack/sigstore-test/testdata/wrong-rekor-key.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: test-wrong-rekor +spec: + interval: 5m + url: oci://sigstore-test-registry:5000/test/v2-keyless + ref: + tag: v1 + insecure: true + verify: + provider: cosign + trustedRootSecretRef: + name: sigstore-wrong-root + matchOIDCIdentity: + - issuer: "^https://kubernetes\\.default\\.svc" + subject: "^https://kubernetes\\.io/namespaces/default/serviceaccounts/default$" diff --git a/internal/controller/helmchart_controller.go b/internal/controller/helmchart_controller.go index 963d75dde..d8f2eb679 100644 --- a/internal/controller/helmchart_controller.go +++ b/internal/controller/helmchart_controller.go @@ -1313,6 +1313,7 @@ func (r *HelmChartReconciler) makeVerifiers(ctx context.Context, obj *sourcev1.H case "cosign": defaultCosignOciOpts := []scosign.Options{ scosign.WithRemoteOptions(verifyOpts...), + scosign.WithTLSConfig(clientOpts.TLSConfig), } // get the public keys from the given secret diff --git a/internal/controller/ocirepository_controller.go b/internal/controller/ocirepository_controller.go index 7ab7ef12e..433ee0b9e 100644 --- a/internal/controller/ocirepository_controller.go +++ b/internal/controller/ocirepository_controller.go @@ -678,6 +678,8 @@ func (r *OCIRepositoryReconciler) verifySignature(ctx context.Context, obj *sour case "cosign": defaultCosignOciOpts := []scosign.Options{ scosign.WithRemoteOptions(opt...), + scosign.WithInsecure(obj.Spec.Insecure), + scosign.WithTLSConfig(transport.TLSClientConfig), } // If a trusted root secret is provided, read and pass it to the verifier. diff --git a/internal/oci/cosign/cosign.go b/internal/oci/cosign/cosign.go index d87d91dae..3be4ab03e 100644 --- a/internal/oci/cosign/cosign.go +++ b/internal/oci/cosign/cosign.go @@ -19,6 +19,7 @@ package cosign import ( "context" "crypto" + "crypto/tls" "fmt" "sync" "time" @@ -27,9 +28,10 @@ import ( "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/sigstore/cosign/v3/cmd/cosign/cli/fulcio" coptions "github.com/sigstore/cosign/v3/cmd/cosign/cli/options" - "github.com/sigstore/cosign/v3/cmd/cosign/cli/rekor" "github.com/sigstore/cosign/v3/pkg/cosign" "github.com/sigstore/cosign/v3/pkg/oci" + rekorclient "github.com/sigstore/rekor/pkg/client" + rekorgenclient "github.com/sigstore/rekor/pkg/generated/client" ociremote "github.com/sigstore/cosign/v3/pkg/oci/remote" "github.com/sigstore/sigstore-go/pkg/root" @@ -45,6 +47,8 @@ type options struct { rOpt []remote.Option identities []cosign.Identity trustedRoot []byte + insecure bool + tlsConfig *tls.Config } // Options is a function that configures the options applied to a Verifier. @@ -83,9 +87,27 @@ func WithTrustedRoot(trustedRoot []byte) Options { } } +// WithInsecure sets the verifier to use HTTP when discovering v3 bundle +// signatures from the container registry via OCI referrers tag fallback. +// Does not affect Rekor connections. +func WithInsecure(insecure bool) Options { + return func(opts *options) { + opts.insecure = insecure + } +} + +// WithTLSConfig sets the TLS configuration for Rekor client connections. +// When nil, the system trust store is used. +func WithTLSConfig(tlsConfig *tls.Config) Options { + return func(opts *options) { + opts.tlsConfig = tlsConfig + } +} + // CosignVerifier is a struct which is responsible for executing verification logic. type CosignVerifier struct { - opts *cosign.CheckOpts + opts *cosign.CheckOpts + insecure bool } // CosignVerifierFactory is a factory for creating Verifiers with shared state. @@ -152,7 +174,7 @@ func (f *CosignVerifierFactory) NewCosignVerifier(ctx context.Context, opts ...O return nil, err } - return &CosignVerifier{opts: checkOpts}, nil + return &CosignVerifier{opts: checkOpts, insecure: o.insecure}, nil } // Keyless verification: when a custom trusted root is provided, use it @@ -171,16 +193,16 @@ func (f *CosignVerifierFactory) NewCosignVerifier(ctx context.Context, opts ...O return nil, fmt.Errorf("unable to extract Rekor URL from trusted root: %w", err) } - checkOpts.RekorClient, err = rekor.NewClient(rekorURL) + checkOpts.RekorClient, err = newRekorClient(rekorURL, o.tlsConfig) if err != nil { return nil, fmt.Errorf("unable to create Rekor client: %w", err) } - return &CosignVerifier{opts: checkOpts}, nil + return &CosignVerifier{opts: checkOpts, insecure: o.insecure}, nil } // Keyless verification using the public Sigstore infrastructure. - checkOpts.RekorClient, err = rekor.NewClient(coptions.DefaultRekorURL) + checkOpts.RekorClient, err = newRekorClient(coptions.DefaultRekorURL, o.tlsConfig) if err != nil { return nil, fmt.Errorf("unable to create Rekor client: %w", err) } @@ -233,7 +255,17 @@ 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}, nil + return &CosignVerifier{opts: checkOpts, insecure: o.insecure}, nil +} + +// newRekorClient creates a Rekor client with optional TLS configuration. +// If tlsConfig is nil, the default system trust store is used. +func newRekorClient(rekorURL string, tlsConfig *tls.Config) (*rekorgenclient.Rekor, error) { + opts := []rekorclient.Option{rekorclient.WithUserAgent(coptions.UserAgent())} + if tlsConfig != nil { + opts = append(opts, rekorclient.WithTLSConfig(tlsConfig)) + } + return rekorclient.GetRekorClient(rekorURL, opts...) } // rekorURLFromTrustedRoot extracts the Rekor base URL from a trusted root's @@ -265,14 +297,21 @@ func (v *CosignVerifier) Verify(ctx context.Context, ref name.Reference) (soci.V var signatures []oci.Signature // copy options since we'll need to change them based on bundle discovery on the ref opts := *v.opts - newBundles, _, err := cosign.GetBundles(ctx, ref, opts.RegistryClientOpts) + + // Pass insecure to GetBundles for internal bundle digest references. + var nameOpts []name.Option + if v.insecure { + nameOpts = append(nameOpts, name.Insecure) + } + + newBundles, _, err := cosign.GetBundles(ctx, ref, opts.RegistryClientOpts, nameOpts...) // 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) } else { opts.NewBundleFormat = true - signatures, _, err = cosign.VerifyImageAttestations(ctx, ref, &opts) + signatures, _, err = cosign.VerifyImageAttestations(ctx, ref, &opts, nameOpts...) } if err != nil { return soci.VerificationResultFailed, err diff --git a/internal/oci/cosign/verify_insecure_test.go b/internal/oci/cosign/verify_insecure_test.go new file mode 100644 index 000000000..8247479b9 --- /dev/null +++ b/internal/oci/cosign/verify_insecure_test.go @@ -0,0 +1,127 @@ +/* +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" + "net" + "net/http" + "os" + "path" + "testing" + "time" + + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/types" + . "github.com/onsi/gomega" + coptions "github.com/sigstore/cosign/v3/cmd/cosign/cli/options" + "github.com/sigstore/cosign/v3/cmd/cosign/cli/sign" + "github.com/sigstore/cosign/v3/pkg/cosign" + + soci "github.com/fluxcd/source-controller/internal/oci" + testregistry "github.com/fluxcd/source-controller/tests/registry" +) + +// TestVerifyInsecureV3Bundle tests v3 bundle-format signature verification +// against an HTTP-only registry accessed via a non-loopback hostname. +// +// go-containerregistry uses HTTP implicitly for localhost/127.0.0.1/RFC1918. +// This test uses a fake external hostname to cover the case of in-cluster +// registries like "my-registry:5000" where name.Insecure must be explicit. +// +// GetBundles() creates new name.Reference objects for bundle digests via +// name.ParseReference without carrying over name.Insecure from the original +// ref, so WithInsecure(true) on the verifier is needed to make it work. +func TestVerifyInsecureV3Bundle(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + // Start an HTTP-only registry on a random port + registryAddr := testregistry.New(t) + _, port, _ := net.SplitHostPort(registryAddr) + + // Use a fake external hostname that requires name.Insecure + fakeHost := "fake-external-registry.example.com" + fakeAddr := fmt.Sprintf("%s:%s", fakeHost, port) + + // Custom transport that resolves the fake hostname to 127.0.0.1 + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + if host, p, _ := net.SplitHostPort(addr); host == fakeHost { + addr = net.JoinHostPort("127.0.0.1", p) + } + return (&net.Dialer{}).DialContext(ctx, network, addr) + }, + } + + // Generate cosign key pair + keys, err := cosign.GenerateKeyPair(func(b bool) ([]byte, error) { + return []byte(""), nil + }) + g.Expect(err).NotTo(HaveOccurred()) + + tmpDir := t.TempDir() + keyPath := path.Join(tmpDir, "cosign.key") + err = os.WriteFile(keyPath, keys.PrivateBytes, 0600) + g.Expect(err).NotTo(HaveOccurred()) + + // Push a test image using the real loopback address + realRef := fmt.Sprintf("%s/test/v3bundle:v1", registryAddr) + img := mutate.MediaType(empty.Image, types.OCIManifestSchema1) + err = crane.Push(img, realRef) + g.Expect(err).NotTo(HaveOccurred()) + + // Sign with v3 bundle format using the real loopback address + // (the bundle is stored by digest, so it's discoverable from any hostname) + pf := func(_ bool) ([]byte, error) { return []byte(""), nil } + ko := coptions.KeyOpts{ + KeyRef: keyPath, + PassFunc: pf, + NewBundleFormat: true, + } + ro := &coptions.RootOptions{Timeout: 30 * time.Second} + err = sign.SignCmd(ctx, ro, ko, coptions.SignOptions{ + Upload: true, + SkipConfirmation: true, + TlogUpload: false, + NewBundleFormat: true, + Registry: coptions.RegistryOptions{AllowInsecure: true, AllowHTTPRegistry: true}, + }, []string{realRef}) + g.Expect(err).NotTo(HaveOccurred()) + + // Parse reference with name.Insecure (as source-controller does for spec.insecure=true) + ref, err := name.ParseReference(fmt.Sprintf("%s/test/v3bundle:v1", fakeAddr), name.Insecure) + g.Expect(err).NotTo(HaveOccurred()) + + // Verify using the CosignVerifier with the custom transport + vf := NewCosignVerifierFactory() + verifier, err := vf.NewCosignVerifier(ctx, + WithPublicKey(keys.PublicBytes), + WithRemoteOptions(remote.WithTransport(transport)), + WithInsecure(true), + ) + g.Expect(err).NotTo(HaveOccurred()) + + result, err := verifier.Verify(ctx, ref) + g.Expect(err).NotTo(HaveOccurred(), "v3 bundle verification should succeed on insecure registry with non-loopback hostname") + g.Expect(result).To(Equal(soci.VerificationResultSuccess)) +}