From 0b00ba2e07ca30a4047f38f558884613e2da36b4 Mon Sep 17 00:00:00 2001 From: Sanskar Jaiswal Date: Thu, 5 Oct 2023 17:25:03 +0530 Subject: [PATCH] cosign: allow identity matching for keyless verification Add `.spec.verify.matchOIDCIdentity` to OCIRepository and HelmChart. It allows specifying regular expressions to match against the subject and issuer of the certificate related to the artifact signature. Its used only if the artifact was signed using Cosign keyless signing. Signed-off-by: Sanskar Jaiswal --- api/v1beta2/ocirepository_types.go | 22 +++ api/v1beta2/zz_generated.deepcopy.go | 20 ++ .../source.toolkit.fluxcd.io_helmcharts.yaml | 26 +++ ...rce.toolkit.fluxcd.io_ocirepositories.yaml | 26 +++ docs/api/v1beta2/source.md | 65 +++++++ internal/controller/helmchart_controller.go | 10 + .../controller/helmchart_controller_test.go | 176 ++++++++++++++++++ .../controller/ocirepository_controller.go | 11 ++ .../ocirepository_controller_test.go | 175 +++++++++++++++++ internal/oci/verifier.go | 26 +-- internal/oci/verifier_test.go | 25 +++ 11 files changed, 569 insertions(+), 13 deletions(-) diff --git a/api/v1beta2/ocirepository_types.go b/api/v1beta2/ocirepository_types.go index 299f20a52..861003a53 100644 --- a/api/v1beta2/ocirepository_types.go +++ b/api/v1beta2/ocirepository_types.go @@ -190,6 +190,28 @@ type OCIRepositoryVerification struct { // trusted public keys. // +optional SecretRef *meta.LocalObjectReference `json:"secretRef,omitempty"` + + // MatchOIDCIdentity specifies the identity matching criteria to use + // while verifying an OCI artifact which was signed using Cosign keyless + // signing. The artifact's identity is deemed to be verified if any of the + // specified matchers match against the identity. + // +optional + MatchOIDCIdentity []OIDCIdentityMatch `json:"matchOIDCIdentity,omitempty"` +} + +// OIDCIdentityMatch specifies options for verifying the certificate identity, +// i.e. the issuer and the subject of the certificate. +type OIDCIdentityMatch struct { + // Issuer specifies the regex pattern to match against to verify + // the OIDC issuer in the Fulcio certificate. The pattern must be a + // valid Go regular expression. + // +required + Issuer string `json:"issuer"` + // Subject specifies the regex pattern to match against to verify + // the identity subject in the Fulcio certificate. The pattern must + // be a valid Go regular expression. + // +required + Subject string `json:"subject"` } // OCIRepositoryStatus defines the observed state of OCIRepository diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index 5c2169a33..e522081f2 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -834,6 +834,11 @@ func (in *OCIRepositoryVerification) DeepCopyInto(out *OCIRepositoryVerification *out = new(meta.LocalObjectReference) **out = **in } + if in.MatchOIDCIdentity != nil { + in, out := &in.MatchOIDCIdentity, &out.MatchOIDCIdentity + *out = make([]OIDCIdentityMatch, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OCIRepositoryVerification. @@ -845,3 +850,18 @@ func (in *OCIRepositoryVerification) DeepCopy() *OCIRepositoryVerification { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OIDCIdentityMatch) DeepCopyInto(out *OIDCIdentityMatch) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OIDCIdentityMatch. +func (in *OIDCIdentityMatch) DeepCopy() *OIDCIdentityMatch { + if in == nil { + return nil + } + out := new(OIDCIdentityMatch) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml b/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml index 9448f29f3..49bdcdd93 100644 --- a/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml +++ b/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml @@ -411,6 +411,32 @@ spec: Chart dependencies, which are not bundled in the umbrella chart artifact, are not verified. properties: + matchOIDCIdentity: + description: MatchOIDCIdentity specifies the identity matching + criteria to use while verifying an OCI artifact which was signed + using Cosign keyless signing. The artifact's identity is deemed + to be verified if any of the specified matchers match against + the identity. + items: + description: OIDCIdentityMatch specifies options for verifying + the certificate identity, i.e. the issuer and the subject + of the certificate. + properties: + issuer: + description: Issuer specifies the regex pattern to match + against to verify the OIDC issuer in the Fulcio certificate. + The pattern must be a valid Go regular expression. + type: string + subject: + description: Subject specifies the regex pattern to match + against to verify the identity subject in the Fulcio certificate. + The pattern must be a valid Go regular expression. + type: string + required: + - issuer + - subject + type: object + type: array provider: default: cosign description: Provider specifies the technology used to sign the diff --git a/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml b/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml index df40334a4..b795c8fda 100644 --- a/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml +++ b/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml @@ -164,6 +164,32 @@ spec: public keys used to verify the signature and specifies which provider to use to check whether OCI image is authentic. properties: + matchOIDCIdentity: + description: MatchOIDCIdentity specifies the identity matching + criteria to use while verifying an OCI artifact which was signed + using Cosign keyless signing. The artifact's identity is deemed + to be verified if any of the specified matchers match against + the identity. + items: + description: OIDCIdentityMatch specifies options for verifying + the certificate identity, i.e. the issuer and the subject + of the certificate. + properties: + issuer: + description: Issuer specifies the regex pattern to match + against to verify the OIDC issuer in the Fulcio certificate. + The pattern must be a valid Go regular expression. + type: string + subject: + description: Subject specifies the regex pattern to match + against to verify the identity subject in the Fulcio certificate. + The pattern must be a valid Go regular expression. + type: string + required: + - issuer + - subject + type: object + type: array provider: default: cosign description: Provider specifies the technology used to sign the diff --git a/docs/api/v1beta2/source.md b/docs/api/v1beta2/source.md index 3d58db692..31fa0f06b 100644 --- a/docs/api/v1beta2/source.md +++ b/docs/api/v1beta2/source.md @@ -3295,6 +3295,71 @@ github.com/fluxcd/pkg/apis/meta.LocalObjectReference trusted public keys.

+ + +matchOIDCIdentity
+ + +[]OIDCIdentityMatch + + + + +(Optional) +

MatchOIDCIdentity specifies the identity matching criteria to use +while verifying an OCI artifact which was signed using Cosign keyless +signing. The artifact’s identity is deemed to be verified if any of the +specified matchers match against the identity.

+ + + + + + +

OIDCIdentityMatch +

+

+(Appears on: +OCIRepositoryVerification) +

+

OIDCIdentityMatch specifies options for verifying the certificate identity, +i.e. the issuer and the subject of the certificate.

+
+
+ + + + + + + + + + + + + + + +
FieldDescription
+issuer
+ +string + +
+

Issuer specifies the regex pattern to match against to verify +the OIDC issuer in the Fulcio certificate. The pattern must be a +valid Go regular expression.

+
+subject
+ +string + +
+

Subject specifies the regex pattern to match against to verify +the identity subject in the Fulcio certificate. The pattern must +be a valid Go regular expression.

+
diff --git a/internal/controller/helmchart_controller.go b/internal/controller/helmchart_controller.go index 1f952847f..f840a85bc 100644 --- a/internal/controller/helmchart_controller.go +++ b/internal/controller/helmchart_controller.go @@ -29,6 +29,7 @@ import ( "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/opencontainers/go-digest" + "github.com/sigstore/cosign/v2/pkg/cosign" helmgetter "helm.sh/helm/v3/pkg/getter" helmreg "helm.sh/helm/v3/pkg/registry" helmrepo "helm.sh/helm/v3/pkg/repo" @@ -1338,6 +1339,15 @@ func (r *HelmChartReconciler) makeVerifiers(ctx context.Context, obj *helmv1.Hel } // if no secret is provided, add a keyless verifier + var identities []cosign.Identity + for _, match := range obj.Spec.Verify.MatchOIDCIdentity { + identities = append(identities, cosign.Identity{ + IssuerRegExp: match.Issuer, + SubjectRegExp: match.Subject, + }) + } + defaultCosignOciOpts = append(defaultCosignOciOpts, soci.WithIdentities(identities)) + verifier, err := soci.NewCosignVerifier(ctx, defaultCosignOciOpts...) if err != nil { return nil, err diff --git a/internal/controller/helmchart_controller_test.go b/internal/controller/helmchart_controller_test.go index 1b22bc01c..6a6222a9e 100644 --- a/internal/controller/helmchart_controller_test.go +++ b/internal/controller/helmchart_controller_test.go @@ -59,6 +59,7 @@ import ( sourcev1 "github.com/fluxcd/source-controller/api/v1" helmv1 "github.com/fluxcd/source-controller/api/v1beta2" + ociv1 "github.com/fluxcd/source-controller/api/v1beta2" serror "github.com/fluxcd/source-controller/internal/error" "github.com/fluxcd/source-controller/internal/helm/chart" "github.com/fluxcd/source-controller/internal/helm/chart/secureloader" @@ -2533,6 +2534,181 @@ func TestHelmChartReconciler_reconcileSourceFromOCI_authStrategy(t *testing.T) { } } +func TestHelmChartRepository_reconcileSource_verifyOCISourceSignature_keyless(t *testing.T) { + tests := []struct { + name string + version string + want sreconcile.Result + wantErr bool + beforeFunc func(obj *helmv1.HelmChart) + assertConditions []metav1.Condition + revision string + }{ + { + name: "signed image with no identity matching specified should pass verification", + version: "6.5.1", + want: sreconcile.ResultSuccess, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of version "), + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled '' chart with version ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled '' chart with version ''"), + }, + revision: "6.5.1@sha256:af589b918022cd8d85a4543312d28170c2e894ccab8484050ff4bdefdde30b4e", + }, + { + name: "signed image with correct subject and issuer should pass verification", + version: "6.5.1", + want: sreconcile.ResultSuccess, + beforeFunc: func(obj *helmv1.HelmChart) { + obj.Spec.Verify.MatchOIDCIdentity = []ociv1.OIDCIdentityMatch{ + { + + Subject: "^https://github.com/stefanprodan/podinfo.*$", + Issuer: "^https://token.actions.githubusercontent.com$", + }, + } + }, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of version "), + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled '' chart with version ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled '' chart with version ''"), + }, + revision: "6.5.1@sha256:af589b918022cd8d85a4543312d28170c2e894ccab8484050ff4bdefdde30b4e", + }, + { + name: "signed image with correct and incorrect identity matchers should pass verification", + version: "6.5.1", + want: sreconcile.ResultSuccess, + beforeFunc: func(obj *helmv1.HelmChart) { + obj.Spec.Verify.MatchOIDCIdentity = []ociv1.OIDCIdentityMatch{ + { + Subject: "intruder", + Issuer: "^https://honeypot.com$", + }, + { + + Subject: "^https://github.com/stefanprodan/podinfo.*$", + Issuer: "^https://token.actions.githubusercontent.com$", + }, + } + }, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of version "), + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled '' chart with version ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled '' chart with version ''"), + }, + revision: "6.5.1@sha256:af589b918022cd8d85a4543312d28170c2e894ccab8484050ff4bdefdde30b4e", + }, + { + name: "signed image with incorrect subject and issuer should not pass verification", + version: "6.5.1", + wantErr: true, + want: sreconcile.ResultEmpty, + beforeFunc: func(obj *helmv1.HelmChart) { + obj.Spec.Verify.MatchOIDCIdentity = []ociv1.OIDCIdentityMatch{ + { + Subject: "intruder", + Issuer: "^https://honeypot.com$", + }, + } + }, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(sourcev1.BuildFailedCondition, "ChartVerificationError", "chart verification error: failed to verify : no matching signatures: none of the expected identities matched what was in the certificate"), + *conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "chart verification error: failed to verify : no matching signatures"), + }, + revision: "6.5.1@sha256:af589b918022cd8d85a4543312d28170c2e894ccab8484050ff4bdefdde30b4e", + }, + { + name: "unsigned image should not pass verification", + version: "6.1.0", + wantErr: true, + want: sreconcile.ResultEmpty, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(sourcev1.BuildFailedCondition, "ChartVerificationError", "chart verification error: failed to verify : no matching signatures"), + *conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "chart verification error: failed to verify : no matching signatures"), + }, + revision: "6.1.0@sha256:642383f56ccb529e3f658d40312d01b58d9bc6caeef653da43e58d1afe88982a", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + clientBuilder := fakeclient.NewClientBuilder() + + repository := &helmv1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "helmrepository-", + }, + Spec: helmv1.HelmRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts", + Timeout: &metav1.Duration{Duration: timeout}, + Provider: helmv1.GenericOCIProvider, + Type: helmv1.HelmRepositoryTypeOCI, + }, + } + clientBuilder.WithObjects(repository) + + r := &HelmChartReconciler{ + Client: clientBuilder.Build(), + EventRecorder: record.NewFakeRecorder(32), + Getters: testGetters, + Storage: testStorage, + RegistryClientGenerator: registry.ClientGenerator, + patchOptions: getPatchOptions(helmChartReadyCondition.Owned, "sc"), + } + + obj := &helmv1.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "helmchart-", + }, + Spec: helmv1.HelmChartSpec{ + SourceRef: helmv1.LocalHelmChartSourceReference{ + Kind: helmv1.HelmRepositoryKind, + Name: repository.Name, + }, + Version: tt.version, + Chart: "podinfo", + Verify: &helmv1.OCIRepositoryVerification{ + Provider: "cosign", + }, + }, + } + chartUrl := fmt.Sprintf("%s/%s:%s", repository.Spec.URL, obj.Spec.Chart, obj.Spec.Version) + + assertConditions := tt.assertConditions + for k := range assertConditions { + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", obj.Spec.Chart) + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", obj.Spec.Version) + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", chartUrl) + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", "cosign") + } + + if tt.beforeFunc != nil { + tt.beforeFunc(obj) + } + + g.Expect(r.Client.Create(ctx, obj)).ToNot(HaveOccurred()) + defer func() { + g.Expect(r.Client.Delete(ctx, obj)).ToNot(HaveOccurred()) + }() + + sp := patch.NewSerialPatcher(obj, r.Client) + + var b chart.Build + got, err := r.reconcileSource(ctx, sp, obj, &b) + if tt.wantErr { + g.Expect(err).ToNot(BeNil()) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + g.Expect(got).To(Equal(tt.want)) + g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(tt.assertConditions)) + }) + } +} + func TestHelmChartReconciler_reconcileSourceFromOCI_verifySignature(t *testing.T) { g := NewWithT(t) diff --git a/internal/controller/ocirepository_controller.go b/internal/controller/ocirepository_controller.go index 0c43d5655..9e6e69145 100644 --- a/internal/controller/ocirepository_controller.go +++ b/internal/controller/ocirepository_controller.go @@ -35,6 +35,7 @@ import ( "github.com/google/go-containerregistry/pkg/name" gcrv1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/sigstore/cosign/v2/pkg/cosign" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -663,6 +664,16 @@ func (r *OCIRepositoryReconciler) verifySignature(ctx context.Context, obj *ociv // if no secret is provided, try keyless verification ctrl.LoggerFrom(ctx).Info("no secret reference is provided, trying to verify the image using keyless method") + + var identities []cosign.Identity + for _, match := range obj.Spec.Verify.MatchOIDCIdentity { + identities = append(identities, cosign.Identity{ + IssuerRegExp: match.Issuer, + SubjectRegExp: match.Subject, + }) + } + defaultCosignOciOpts = append(defaultCosignOciOpts, soci.WithIdentities(identities)) + verifier, err := soci.NewCosignVerifier(ctxTimeout, defaultCosignOciOpts...) if err != nil { return err diff --git a/internal/controller/ocirepository_controller_test.go b/internal/controller/ocirepository_controller_test.go index 2e4458f7f..9b4529791 100644 --- a/internal/controller/ocirepository_controller_test.go +++ b/internal/controller/ocirepository_controller_test.go @@ -1435,6 +1435,181 @@ func TestOCIRepository_reconcileSource_verifyOCISourceSignature(t *testing.T) { } } +func TestOCIRepository_reconcileSource_verifyOCISourceSignature_keyless(t *testing.T) { + tests := []struct { + name string + reference *ociv1.OCIRepositoryRef + want sreconcile.Result + wantErr bool + wantErrMsg string + beforeFunc func(obj *ociv1.OCIRepository) + assertConditions []metav1.Condition + revision string + }{ + { + name: "signed image with no identity matching specified should pass verification", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.5.1", + }, + want: sreconcile.ResultSuccess, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision "), + }, + revision: "6.5.1@sha256:049fff8f9c92abba8615c6c3dcf9d10d30082213f6fe86c9305257f806c31e31", + }, + { + name: "signed image with correct subject and issuer should pass verification", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.5.1", + }, + want: sreconcile.ResultSuccess, + beforeFunc: func(obj *ociv1.OCIRepository) { + obj.Spec.Verify.MatchOIDCIdentity = []ociv1.OIDCIdentityMatch{ + { + + Subject: "^https://github.com/stefanprodan/podinfo.*$", + Issuer: "^https://token.actions.githubusercontent.com$", + }, + } + }, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision "), + }, + revision: "6.5.1@sha256:049fff8f9c92abba8615c6c3dcf9d10d30082213f6fe86c9305257f806c31e31", + }, + { + name: "signed image with both correct and incorrect identity matchers should pass verification", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.5.1", + }, + want: sreconcile.ResultSuccess, + beforeFunc: func(obj *ociv1.OCIRepository) { + obj.Spec.Verify.MatchOIDCIdentity = []ociv1.OIDCIdentityMatch{ + { + + Subject: "^https://github.com/stefanprodan/podinfo.*$", + Issuer: "^https://token.actions.githubusercontent.com$", + }, + { + Subject: "intruder", + Issuer: "^https://honeypot.com$", + }, + } + }, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision "), + }, + revision: "6.5.1@sha256:049fff8f9c92abba8615c6c3dcf9d10d30082213f6fe86c9305257f806c31e31", + }, + { + name: "signed image with incorrect subject and issuer should not pass verification", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.5.1", + }, + wantErr: true, + want: sreconcile.ResultEmpty, + beforeFunc: func(obj *ociv1.OCIRepository) { + obj.Spec.Verify.MatchOIDCIdentity = []ociv1.OIDCIdentityMatch{ + { + Subject: "intruder", + Issuer: "^https://honeypot.com$", + }, + } + }, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "failed to verify the signature using provider ' keyless': no matching signatures: none of the expected identities matched what was in the certificate"), + }, + revision: "6.5.1@sha256:049fff8f9c92abba8615c6c3dcf9d10d30082213f6fe86c9305257f806c31e31", + }, + { + name: "unsigned image should not pass verification", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.1.0", + }, + wantErr: true, + want: sreconcile.ResultEmpty, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "failed to verify the signature using provider ' keyless': no matching signatures"), + }, + revision: "6.1.0@sha256:3816fe9636a297f0c934b1fa0f46fe4c068920375536ac2803604adfb4c55894", + }, + } + + clientBuilder := fakeclient.NewClientBuilder(). + WithScheme(testEnv.GetScheme()). + WithStatusSubresource(&ociv1.OCIRepository{}) + + r := &OCIRepositoryReconciler{ + Client: clientBuilder.Build(), + EventRecorder: record.NewFakeRecorder(32), + Storage: testStorage, + patchOptions: getPatchOptions(ociRepositoryReadyCondition.Owned, "sc"), + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + obj := &ociv1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "verify-oci-source-signature-", + Generation: 1, + }, + Spec: ociv1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/manifests/podinfo", + Verify: &ociv1.OCIRepositoryVerification{ + Provider: "cosign", + }, + Interval: metav1.Duration{Duration: interval}, + Timeout: &metav1.Duration{Duration: timeout}, + Reference: tt.reference, + }, + } + url := strings.TrimPrefix(obj.Spec.URL, "oci://") + ":" + tt.reference.Tag + + assertConditions := tt.assertConditions + for k := range assertConditions { + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", tt.revision) + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", url) + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", "cosign") + } + + if tt.beforeFunc != nil { + tt.beforeFunc(obj) + } + + g.Expect(r.Client.Create(ctx, obj)).ToNot(HaveOccurred()) + defer func() { + g.Expect(r.Client.Delete(ctx, obj)).ToNot(HaveOccurred()) + }() + + sp := patch.NewSerialPatcher(obj, r.Client) + + artifact := &sourcev1.Artifact{} + got, err := r.reconcileSource(ctx, sp, obj, artifact, t.TempDir()) + if tt.wantErr { + tt.wantErrMsg = strings.ReplaceAll(tt.wantErrMsg, "", url) + g.Expect(err).ToNot(BeNil()) + g.Expect(err.Error()).To(ContainSubstring(tt.wantErrMsg)) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + g.Expect(got).To(Equal(tt.want)) + g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(tt.assertConditions)) + }) + } +} + func TestOCIRepository_reconcileSource_noop(t *testing.T) { g := NewWithT(t) diff --git a/internal/oci/verifier.go b/internal/oci/verifier.go index 77306c7d7..2fb304e4e 100644 --- a/internal/oci/verifier.go +++ b/internal/oci/verifier.go @@ -40,8 +40,9 @@ type Verifier interface { // options is a struct that holds options for verifier. type options struct { - PublicKey []byte - ROpt []remote.Option + PublicKey []byte + ROpt []remote.Option + Identities []cosign.Identity } // Options is a function that configures the options applied to a Verifier. @@ -62,6 +63,14 @@ func WithRemoteOptions(opts ...remote.Option) Options { } } +// WithIdentities specifies the identity matchers that have to be met +// for the signature to be deemed valid. +func WithIdentities(identities []cosign.Identity) Options { + return func(opts *options) { + opts.Identities = identities + } +} + // CosignVerifier is a struct which is responsible for executing verification logic. type CosignVerifier struct { opts *cosign.CheckOpts @@ -82,6 +91,7 @@ func NewCosignVerifier(ctx context.Context, opts ...Options) (*CosignVerifier, e return nil, err } + checkOpts.Identities = o.Identities if o.ROpt != nil { co = append(co, ociremote.WithRemoteOptions(o.ROpt...)) } @@ -141,17 +151,7 @@ func NewCosignVerifier(ctx context.Context, opts ...Options) (*CosignVerifier, e // VerifyImageSignatures verify the authenticity of the given ref OCI image. func (v *CosignVerifier) VerifyImageSignatures(ctx context.Context, ref name.Reference) ([]oci.Signature, bool, error) { - opts := v.opts - - // TODO: expose the match conditions in the CRD - opts.Identities = []cosign.Identity{ - { - IssuerRegExp: ".*", - SubjectRegExp: ".*", - }, - } - - return cosign.VerifyImageSignatures(ctx, ref, opts) + return cosign.VerifyImageSignatures(ctx, ref, v.opts) } // Verify verifies the authenticity of the given ref OCI image. diff --git a/internal/oci/verifier_test.go b/internal/oci/verifier_test.go index 8b3ae3865..114601616 100644 --- a/internal/oci/verifier_test.go +++ b/internal/oci/verifier_test.go @@ -23,6 +23,7 @@ import ( "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/sigstore/cosign/v2/pkg/cosign" ) func TestOptions(t *testing.T) { @@ -75,6 +76,30 @@ func TestOptions(t *testing.T) { remote.WithTransport(http.DefaultTransport), }, }, + }, { + name: "identities option", + opts: []Options{WithIdentities([]cosign.Identity{ + { + SubjectRegExp: "test-user", + IssuerRegExp: "^https://token.actions.githubusercontent.com$", + }, + { + SubjectRegExp: "dev-user", + IssuerRegExp: "^https://accounts.google.com$", + }, + })}, + want: &options{ + Identities: []cosign.Identity{ + { + SubjectRegExp: "test-user", + IssuerRegExp: "^https://token.actions.githubusercontent.com$", + }, + { + SubjectRegExp: "dev-user", + IssuerRegExp: "^https://accounts.google.com$", + }, + }, + }, }, }