From 1ee79596603cdf7d07c749280c6ea6c84ac6fb8a Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Mialon Date: Tue, 10 Mar 2026 14:23:29 +0000 Subject: [PATCH 1/5] Add custom Sigstore trusted root support for OCIRepository Enable signature verification of OCI artifacts against self-hosted Sigstore infrastructure (custom Fulcio CA, self-hosted Rekor instance) by introducing a trustedRootSecretRef field on the verify spec. When set, the controller reads a trusted_root.json from the referenced Secret, extracts the Rekor URL from the transparency log entries, and creates a verifier using the custom trusted material instead of the public Sigstore TUF root. Signed-off-by: Pierre-Gilles Mialon --- api/v1/ociverification_types.go | 7 + .../source.toolkit.fluxcd.io_helmcharts.yaml | 13 ++ ...rce.toolkit.fluxcd.io_ocirepositories.yaml | 13 ++ docs/api/v1/source.md | 17 ++ docs/spec/v1/ocirepositories.md | 61 ++++++- .../controller/ocirepository_controller.go | 33 ++++ .../controller/ocirepository_verify_test.go | 103 ++++++++++++ internal/oci/cosign/cosign.go | 149 ++++++++++++------ internal/oci/cosign/cosign_test.go | 146 +++++++++++++++++ 9 files changed, 490 insertions(+), 52 deletions(-) create mode 100644 internal/controller/ocirepository_verify_test.go 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/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/internal/controller/ocirepository_controller.go b/internal/controller/ocirepository_controller.go index ebde8aa2d..7ab7ef12e 100644 --- a/internal/controller/ocirepository_controller.go +++ b/internal/controller/ocirepository_controller.go @@ -680,6 +680,16 @@ func (r *OCIRepositoryReconciler) verifySignature(ctx context.Context, obj *sour scosign.WithRemoteOptions(opt...), } + // 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 if secretRef := obj.Spec.Verify.SecretRef; secretRef != nil { @@ -1357,6 +1367,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..d87d91dae 100644 --- a/internal/oci/cosign/cosign.go +++ b/internal/oci/cosign/cosign.go @@ -41,9 +41,10 @@ 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 } // Options is a function that configures the options applied to a Verifier. @@ -72,6 +73,16 @@ 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 + } +} + // CosignVerifier is a struct which is responsible for executing verification logic. type CosignVerifier struct { opts *cosign.CheckOpts @@ -123,8 +134,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 +151,107 @@ 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}, 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) } - // 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.TrustedMaterial = customRoot + + rekorURL, err := rekorURLFromTrustedRoot(customRoot) + if err != nil { + return nil, fmt.Errorf("unable to extract Rekor URL from trusted root: %w", err) } - // Initialize legacy setup for v2 compatibility + checkOpts.RekorClient, err = rekor.NewClient(rekorURL) + if err != nil { + return nil, fmt.Errorf("unable to create Rekor client: %w", err) + } + + return &CosignVerifier{opts: checkOpts}, nil + } + + // Keyless verification using the public Sigstore infrastructure. + checkOpts.RekorClient, err = rekor.NewClient(coptions.DefaultRekorURL) + 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) + } + + return &CosignVerifier{opts: checkOpts}, nil +} + +// 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") + } - if checkOpts.IntermediateCerts, err = fulcio.GetIntermediates(); err != nil { - return nil, fmt.Errorf("unable to get Fulcio intermediate certs: %w", err) + 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. 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) From 58209fde6bbc7380d53f696b0ad6d182659123c0 Mon Sep 17 00:00:00 2001 From: leigh capili Date: Tue, 26 May 2026 07:26:10 +0100 Subject: [PATCH 2/5] api: regenerate deepcopy for TrustedRootSecretRef field Signed-off-by: leigh capili --- api/v1/zz_generated.deepcopy.go | 5 +++++ 1 file changed, 5 insertions(+) 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. From bfe55f96166e44d2f9cc00f5cadd18de1a11617d Mon Sep 17 00:00:00 2001 From: leigh capili Date: Tue, 26 May 2026 17:03:49 +0100 Subject: [PATCH 3/5] cosign: add WithInsecure and WithTLSConfig verifier options WithInsecure passes name.Insecure to GetBundles/VerifyImageAttestations for v3 bundle discovery on HTTP registries. Follows the same pattern as notation's WithInsecureRegistry. WithTLSConfig passes a *tls.Config to the Rekor client, supporting private CAs from certSecretRef. Replaces the cosign CLI rekor wrapper with a direct rekor.GetRekorClient call to thread the option through. Includes a test using a fake non-loopback hostname to verify the insecure option is required for bundle discovery on HTTP registries. Signed-off-by: leigh capili --- go.mod | 2 +- internal/oci/cosign/cosign.go | 57 +++++++-- internal/oci/cosign/verify_insecure_test.go | 127 ++++++++++++++++++++ 3 files changed, 176 insertions(+), 10 deletions(-) create mode 100644 internal/oci/cosign/verify_insecure_test.go 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/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)) +} From c583de33d5d3339b5fa123ce45372df735b85532 Mon Sep 17 00:00:00 2001 From: leigh capili Date: Tue, 26 May 2026 17:03:53 +0100 Subject: [PATCH 4/5] controller: wire insecure and TLS config to cosign verifier Pass obj.Spec.Insecure and transport.TLSClientConfig to the cosign verifier so v3 bundle discovery and Rekor connections use the same transport settings as the registry. Signed-off-by: leigh capili --- internal/controller/ocirepository_controller.go | 2 ++ 1 file changed, 2 insertions(+) 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. From ffa18cc70e3269795b3d9c2e2008c4e97fe3198a Mon Sep 17 00:00:00 2001 From: leigh capili Date: Tue, 26 May 2026 17:03:57 +0100 Subject: [PATCH 5/5] controller: pass TLS config and insecure to cosign verifier for HelmChart OCI Pass clientOpts.TLSConfig and clientOpts.Insecure to the cosign verifier in makeVerifiers so that HelmChart verification of OCI-sourced charts works against registries behind private CAs and on HTTP. Signed-off-by: leigh capili --- internal/controller/helmchart_controller.go | 1 + 1 file changed, 1 insertion(+) 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