diff --git a/api/v1/ociverification_types.go b/api/v1/ociverification_types.go
index de74be343..a2cf4c4ed 100644
--- a/api/v1/ociverification_types.go
+++ b/api/v1/ociverification_types.go
@@ -38,6 +38,13 @@ type OCIRepositoryVerification struct {
// specified matchers match against the identity.
// +optional
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".
+ // +optional
+ TrustedRootSecretRef *meta.LocalObjectReference `json:"trustedRootSecretRef,omitempty"`
}
// OIDCIdentityMatch specifies options for verifying the certificate identity,
diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go
index 14f1ba3c2..dc13584ae 100644
--- a/api/v1/zz_generated.deepcopy.go
+++ b/api/v1/zz_generated.deepcopy.go
@@ -970,6 +970,11 @@ func (in *OCIRepositoryVerification) DeepCopyInto(out *OCIRepositoryVerification
*out = make([]OIDCIdentityMatch, len(*in))
copy(*out, *in)
}
+ if in.TrustedRootSecretRef != nil {
+ in, out := &in.TrustedRootSecretRef, &out.TrustedRootSecretRef
+ *out = new(meta.LocalObjectReference)
+ **out = **in
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OCIRepositoryVerification.
diff --git a/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml b/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml
index 1ae58d5da..b2cef8c3c 100644
--- a/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml
+++ b/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml
@@ -184,6 +184,19 @@ spec:
required:
- name
type: object
+ 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".
+ properties:
+ name:
+ description: Name of the referent.
+ type: string
+ required:
+ - name
+ type: object
required:
- provider
type: object
diff --git a/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml b/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml
index f3a57d1b4..61cb36468 100644
--- a/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml
+++ b/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml
@@ -246,6 +246,19 @@ spec:
required:
- name
type: object
+ 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".
+ properties:
+ name:
+ description: Name of the referent.
+ type: string
+ required:
+ - name
+ type: object
required:
- provider
type: object
diff --git a/docs/api/v1/source.md b/docs/api/v1/source.md
index 935d74275..c2464aa28 100644
--- a/docs/api/v1/source.md
+++ b/docs/api/v1/source.md
@@ -3661,6 +3661,23 @@ signing. The artifact’s identity is deemed to be verified if any of the
specified matchers match against the identity.
+
+
+trustedRootSecretRef
+
+
+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”.
+ |
+
diff --git a/docs/spec/v1/ocirepositories.md b/docs/spec/v1/ocirepositories.md
index d2bfa399e..fa3c5a2d7 100644
--- a/docs/spec/v1/ocirepositories.md
+++ b/docs/spec/v1/ocirepositories.md
@@ -641,11 +641,64 @@ spec:
subject: "^https://github.com/stefanprodan/podinfo.*$"
```
-The controller verifies the signatures using the Fulcio root CA and the Rekor
-instance hosted at [rekor.sigstore.dev](https://rekor.sigstore.dev/).
+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/).
-Note that keyless verification is an **experimental feature**, using
-custom root CAs or self-hosted Rekor instances are not currently supported.
+##### Custom Sigstore infrastructure (self-hosted Rekor / Fulcio)
+
+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.
+
+The `trusted_root.json` file follows the
+[Sigstore trusted root format](https://github.com/sigstore/protobuf-specs).
+
+Generate the file using `cosign trusted-root create`:
+
+```sh
+cosign trusted-root create \
+ --fulcio="url=https://fulcio.example.com,certificate-chain=/path/to/fulcio-chain.pem" \
+ --rekor="url=https://rekor.example.com,public-key=/path/to/rekor.pub,start-time=2024-01-01T00:00:00Z" \
+ --ctfe="url=https://ctfe.example.com,public-key=/path/to/ctfe.pub,start-time=2024-01-01T00:00:00Z" \
+ --out trusted_root.json
+```
+
+The `--tsa` flag can also be used if a custom timestamp authority is deployed:
+
+```sh
+cosign trusted-root create \
+ --tsa="url=https://tsa.example.com/api/v1/timestamp,certificate-chain=/path/to/tsa-chain.pem" \
+ ...
+```
+
+Create the Kubernetes Secret from the generated file:
+
+```sh
+kubectl create secret generic sigstore-trusted-root \
+ --from-file=trusted_root.json=./trusted_root.json \
+ -n
+```
+
+Reference it in the OCIRepository:
+
+```yaml
+apiVersion: source.toolkit.fluxcd.io/v1
+kind: OCIRepository
+metadata:
+ name: podinfo
+spec:
+ interval: 5m
+ url: oci://registry.example.com/manifests/podinfo
+ verify:
+ provider: cosign
+ trustedRootSecretRef:
+ name: sigstore-trusted-root
+ matchOIDCIdentity:
+ - issuer: "^https://oidc-issuer.example.com$"
+ subject: "^https://ci.example.com/.*$"
+```
#### Notation
diff --git a/go.mod b/go.mod
index 212306821..8a85b934c 100644
--- a/go.mod
+++ b/go.mod
@@ -58,6 +58,7 @@ require (
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5
github.com/prometheus/client_golang v1.23.2
github.com/sigstore/cosign/v3 v3.0.4
+ github.com/sigstore/rekor v1.5.0
github.com/sigstore/sigstore v1.10.4
github.com/sigstore/sigstore-go v1.1.4
github.com/sirupsen/logrus v1.9.4
@@ -332,7 +333,6 @@ require (
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sigstore/fulcio v1.8.5 // indirect
github.com/sigstore/protobuf-specs v0.5.0 // indirect
- github.com/sigstore/rekor v1.5.0 // indirect
github.com/sigstore/rekor-tiles/v2 v2.0.1 // indirect
github.com/sigstore/timestamp-authority/v2 v2.0.4 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
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 ebde8aa2d..433ee0b9e 100644
--- a/internal/controller/ocirepository_controller.go
+++ b/internal/controller/ocirepository_controller.go
@@ -678,6 +678,18 @@ 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.
+ if trustedRootRef := obj.Spec.Verify.TrustedRootSecretRef; trustedRootRef != nil {
+ data, err := readTrustedRootFromSecret(ctxTimeout, r.Client, obj.Namespace, trustedRootRef)
+ if err != nil {
+ return soci.VerificationResultFailed, fmt.Errorf("failed to read trusted root from secret '%s/%s': %w",
+ obj.Namespace, trustedRootRef.Name, err)
+ }
+ defaultCosignOciOpts = append(defaultCosignOciOpts, scosign.WithTrustedRoot(data))
}
// get the public keys from the given secret
@@ -1357,6 +1369,29 @@ func layerSelectorEqual(a, b *sourcev1.OCILayerSelector) bool {
return *a == *b
}
+const trustedRootKey = "trusted_root.json"
+
+// readTrustedRootFromSecret reads and returns the trusted_root.json data from
+// the Kubernetes Secret referenced by the given LocalObjectReference.
+func readTrustedRootFromSecret(ctx context.Context, c client.Reader, namespace string, ref *meta.LocalObjectReference) ([]byte, error) {
+ secretName := types.NamespacedName{
+ Namespace: namespace,
+ Name: ref.Name,
+ }
+
+ var secret corev1.Secret
+ if err := c.Get(ctx, secretName, &secret); err != nil {
+ return nil, err
+ }
+
+ data, ok := secret.Data[trustedRootKey]
+ if !ok {
+ return nil, fmt.Errorf("'%s' not found in secret '%s'", trustedRootKey, secretName.String())
+ }
+
+ return data, nil
+}
+
func filterTags(filter string) filterFunc {
return func(tags []string) ([]string, error) {
if filter == "" {
diff --git a/internal/controller/ocirepository_verify_test.go b/internal/controller/ocirepository_verify_test.go
new file mode 100644
index 000000000..1c347027e
--- /dev/null
+++ b/internal/controller/ocirepository_verify_test.go
@@ -0,0 +1,103 @@
+/*
+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 controller
+
+import (
+ "context"
+ "testing"
+
+ "github.com/fluxcd/pkg/apis/meta"
+ . "github.com/onsi/gomega"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+)
+
+func TestReadTrustedRootFromSecret(t *testing.T) {
+ scheme := runtime.NewScheme()
+ _ = corev1.AddToScheme(scheme)
+
+ tests := []struct {
+ name string
+ namespace string
+ ref *meta.LocalObjectReference
+ secret *corev1.Secret
+ wantData []byte
+ wantErr string
+ }{
+ {
+ name: "reads trusted_root.json from secret",
+ namespace: "default",
+ ref: &meta.LocalObjectReference{Name: "sigstore-root"},
+ secret: &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "sigstore-root",
+ Namespace: "default",
+ },
+ Data: map[string][]byte{
+ "trusted_root.json": []byte(`{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1"}`),
+ },
+ },
+ wantData: []byte(`{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1"}`),
+ },
+ {
+ name: "error when secret does not exist",
+ namespace: "default",
+ ref: &meta.LocalObjectReference{Name: "missing-secret"},
+ wantErr: `"missing-secret" not found`,
+ },
+ {
+ name: "error when key is missing from secret",
+ namespace: "default",
+ ref: &meta.LocalObjectReference{Name: "no-key-secret"},
+ secret: &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "no-key-secret",
+ Namespace: "default",
+ },
+ Data: map[string][]byte{
+ "other-key": []byte("data"),
+ },
+ },
+ wantErr: "'trusted_root.json' not found in secret",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ g := NewWithT(t)
+
+ builder := fake.NewClientBuilder().WithScheme(scheme)
+ if tt.secret != nil {
+ builder = builder.WithObjects(tt.secret)
+ }
+ c := builder.Build()
+
+ data, err := readTrustedRootFromSecret(context.Background(), c, tt.namespace, tt.ref)
+
+ if tt.wantErr != "" {
+ g.Expect(err).To(HaveOccurred())
+ g.Expect(err.Error()).To(ContainSubstring(tt.wantErr))
+ return
+ }
+
+ g.Expect(err).NotTo(HaveOccurred())
+ g.Expect(data).To(Equal(tt.wantData))
+ })
+ }
+}
diff --git a/internal/oci/cosign/cosign.go b/internal/oci/cosign/cosign.go
index f68f27129..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"
@@ -41,9 +43,12 @@ import (
// options is a struct that holds options for verifier.
type options struct {
- publicKey []byte
- rOpt []remote.Option
- identities []cosign.Identity
+ publicKey []byte
+ 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.
@@ -72,9 +77,37 @@ 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.
+func WithTrustedRoot(trustedRoot []byte) Options {
+ return func(opts *options) {
+ opts.trustedRoot = trustedRoot
+ }
+}
+
+// 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.
@@ -123,8 +156,7 @@ func (f *CosignVerifierFactory) NewCosignVerifier(ctx context.Context, opts ...O
checkOpts.RegistryClientOpts = co
- // If a public key is provided, it will use it to verify the signature.
- // If there is no public key provided, it will try keyless verification.
+ // 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
@@ -141,64 +173,117 @@ func (f *CosignVerifierFactory) NewCosignVerifier(ctx context.Context, opts ...O
if err != nil {
return nil, err
}
- } else {
- checkOpts.RekorClient, err = rekor.NewClient(coptions.DefaultRekorURL)
+
+ 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 create Rekor client: %w", err)
+ return nil, fmt.Errorf("unable to parse trusted root: %w", err)
+ }
+
+ checkOpts.TrustedMaterial = customRoot
+
+ rekorURL, err := rekorURLFromTrustedRoot(customRoot)
+ if err != nil {
+ return nil, fmt.Errorf("unable to extract Rekor URL from trusted root: %w", err)
}
- // Initialize TrustedMaterial for v3/Bundle verification
- f.mu.Lock()
- if f.trustedMaterial != nil {
- checkOpts.TrustedMaterial = f.trustedMaterial
- f.mu.Unlock()
- } else {
- // Check if we should init or retry
- if f.initErr == nil || time.Since(f.lastAttempt) >= f.retryInterval {
- f.lastAttempt = time.Now()
- // TODO(stealthybox): it would be nice to control the http client here for the TrustedRoot fetcher
- // with the current state of this part of the cosign SDK, that would involve duplicating a lot of
- // their ENV, options, and defaulting code.
- f.trustedMaterial, f.initErr = cosign.TrustedRoot()
- }
-
- err := f.initErr
- tm := f.trustedMaterial
- f.mu.Unlock()
-
- if err != nil {
- return nil, fmt.Errorf("unable to initialize trusted root: %w", err)
- }
- checkOpts.TrustedMaterial = tm
+ checkOpts.RekorClient, err = newRekorClient(rekorURL, o.tlsConfig)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create Rekor client: %w", err)
}
- // Initialize legacy setup for v2 compatibility
+ return &CosignVerifier{opts: checkOpts, insecure: o.insecure}, nil
+ }
+
+ // Keyless verification using the public Sigstore infrastructure.
+ checkOpts.RekorClient, err = newRekorClient(coptions.DefaultRekorURL, o.tlsConfig)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create Rekor client: %w", err)
+ }
- // This performs an online fetch of the Rekor public keys, but this is needed
- // for verifying tlog entries (both online and offline).
- // TODO(hidde): above note is important to keep in mind when we implement
- // "offline" tlog above.
- if checkOpts.RekorPubKeys, err = cosign.GetRekorPubs(ctx); err != nil {
- return nil, fmt.Errorf("unable to get Rekor public keys: %w", err)
+ // Initialize TrustedMaterial for v3/Bundle verification.
+ f.mu.Lock()
+ if f.trustedMaterial != nil {
+ checkOpts.TrustedMaterial = f.trustedMaterial
+ f.mu.Unlock()
+ } else {
+ // Check if we should init or retry.
+ if f.initErr == nil || time.Since(f.lastAttempt) >= f.retryInterval {
+ f.lastAttempt = time.Now()
+ // TODO(stealthybox): it would be nice to control the http client here for the TrustedRoot fetcher
+ // with the current state of this part of the cosign SDK, that would involve duplicating a lot of
+ // their ENV, options, and defaulting code.
+ f.trustedMaterial, f.initErr = cosign.TrustedRoot()
}
- checkOpts.CTLogPubKeys, err = cosign.GetCTLogPubs(ctx)
+ err := f.initErr
+ tm := f.trustedMaterial
+ f.mu.Unlock()
+
if err != nil {
- return nil, fmt.Errorf("unable to get CTLog public keys: %w", err)
+ return nil, fmt.Errorf("unable to initialize trusted root: %w", err)
}
+ checkOpts.TrustedMaterial = tm
+ }
- if checkOpts.RootCerts, err = fulcio.GetRoots(); err != nil {
- return nil, fmt.Errorf("unable to get Fulcio root certs: %w", err)
- }
+ // Initialize legacy setup for v2 compatibility.
+
+ // This performs an online fetch of the Rekor public keys, but this is needed
+ // for verifying tlog entries (both online and offline).
+ // TODO(hidde): above note is important to keep in mind when we implement
+ // "offline" tlog above.
+ if checkOpts.RekorPubKeys, err = cosign.GetRekorPubs(ctx); err != nil {
+ return nil, fmt.Errorf("unable to get Rekor public keys: %w", err)
+ }
+
+ checkOpts.CTLogPubKeys, err = cosign.GetCTLogPubs(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("unable to get CTLog public keys: %w", err)
+ }
+
+ if checkOpts.RootCerts, err = fulcio.GetRoots(); err != nil {
+ return nil, fmt.Errorf("unable to get Fulcio root certs: %w", err)
+ }
- if checkOpts.IntermediateCerts, err = fulcio.GetIntermediates(); err != nil {
- return nil, fmt.Errorf("unable to get Fulcio intermediate certs: %w", err)
+ if checkOpts.IntermediateCerts, err = fulcio.GetIntermediates(); err != nil {
+ return nil, fmt.Errorf("unable to get Fulcio intermediate certs: %w", err)
+ }
+
+ 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
+// transparency log entries. It returns the BaseURL of the first entry that
+// has one set.
+func rekorURLFromTrustedRoot(tr *root.TrustedRoot) (string, error) {
+ logs := tr.RekorLogs()
+ if len(logs) == 0 {
+ return "", fmt.Errorf("no transparency log entries found in trusted root")
+ }
+
+ for _, log := range logs {
+ if log.BaseURL != "" {
+ return log.BaseURL, nil
}
}
- return &CosignVerifier{
- opts: checkOpts,
- }, nil
+ return "", fmt.Errorf("no transparency log entry with a BaseURL found in trusted root")
}
// Verify verifies the authenticity of the given ref OCI image.
@@ -212,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/cosign_test.go b/internal/oci/cosign/cosign_test.go
index 21113ed91..36df501b5 100644
--- a/internal/oci/cosign/cosign_test.go
+++ b/internal/oci/cosign/cosign_test.go
@@ -29,6 +29,7 @@ import (
"github.com/google/go-containerregistry/pkg/v1/remote"
. "github.com/onsi/gomega"
"github.com/sigstore/cosign/v3/pkg/cosign"
+ "github.com/sigstore/sigstore-go/pkg/root"
testproxy "github.com/fluxcd/source-controller/tests/proxy"
testregistry "github.com/fluxcd/source-controller/tests/registry"
@@ -42,6 +43,12 @@ func TestOptions(t *testing.T) {
}{{
name: "no options",
want: &options{},
+ }, {
+ name: "trusted root option",
+ opts: []Options{WithTrustedRoot([]byte(`{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1"}`))},
+ want: &options{
+ trustedRoot: []byte(`{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1"}`),
+ },
}, {
name: "signature option",
opts: []Options{WithPublicKey([]byte("foo"))},
@@ -121,6 +128,10 @@ func TestOptions(t *testing.T) {
t.Errorf("got %#v, want %#v", &o.publicKey, test.want.publicKey)
}
+ if !reflect.DeepEqual(o.trustedRoot, test.want.trustedRoot) {
+ t.Errorf("got trustedRoot %#v, want %#v", o.trustedRoot, test.want.trustedRoot)
+ }
+
if test.want.rOpt != nil {
if len(o.rOpt) != len(test.want.rOpt) {
t.Errorf("got %d remote options, want %d", len(o.rOpt), len(test.want.rOpt))
@@ -137,6 +148,141 @@ func TestOptions(t *testing.T) {
}
}
+func TestRekorURLFromTrustedRoot(t *testing.T) {
+ tests := []struct {
+ name string
+ json string
+ wantURL string
+ wantErr string
+ }{
+ {
+ name: "extracts base URL from tlog entry",
+ json: trustedRootJSON("https://rekor.example.com"),
+ wantURL: "https://rekor.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",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(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))
+ })
+ }
+}
+
+func TestNewCosignVerifierWithTrustedRoot(t *testing.T) {
+ g := NewWithT(t)
+
+ ctx := context.Background()
+ vf := NewCosignVerifierFactory()
+
+ t.Run("keyless with custom trusted root", func(t *testing.T) {
+ trJSON := trustedRootJSON("https://rekor.custom.example.com")
+
+ verifier, err := vf.NewCosignVerifier(ctx,
+ WithTrustedRoot([]byte(trJSON)),
+ WithIdentities([]cosign.Identity{
+ {
+ SubjectRegExp: ".*",
+ IssuerRegExp: ".*",
+ },
+ }),
+ )
+ g.Expect(err).NotTo(HaveOccurred())
+ g.Expect(verifier).NotTo(BeNil())
+ g.Expect(verifier.opts.TrustedMaterial).NotTo(BeNil())
+ g.Expect(verifier.opts.RekorClient).NotTo(BeNil())
+ })
+
+ t.Run("invalid trusted root JSON", func(t *testing.T) {
+ _, err := vf.NewCosignVerifier(ctx,
+ WithTrustedRoot([]byte("not-valid-json")),
+ )
+ g.Expect(err).To(HaveOccurred())
+ g.Expect(err.Error()).To(ContainSubstring("unable to parse trusted root"))
+ })
+}
+
+// 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 {
+ return fmt.Sprintf(`{
+ "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1",
+ "tlogs": [
+ {
+ "baseUrl": "%s",
+ "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="
+ }
+ }
+ ],
+ "certificateAuthorities": [
+ {
+ "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"
+ }
+ }
+ ],
+ "ctlogs": [
+ {
+ "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="
+ }
+ }
+ ]
+}`, rekorURL)
+}
+
func TestPrivateKeyVerificationWithProxy(t *testing.T) {
g := NewWithT(t)
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))
+}