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)) +}