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 60599e235..edfa29a5b 100644
--- a/docs/api/v1beta2/source.md
+++ b/docs/api/v1beta2/source.md
@@ -3319,6 +3319,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.
+ |
+
+
+
+
+
+
+
+(Appears on:
+OCIRepositoryVerification)
+
+OIDCIdentityMatch specifies options for verifying the certificate identity,
+i.e. the issuer and the subject of the certificate.
+
+
+
+
+
+Field |
+Description |
+
+
+
+
+
+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/docs/spec/v1beta2/helmcharts.md b/docs/spec/v1beta2/helmcharts.md
index 5e98c70bb..2c06b23ef 100644
--- a/docs/spec/v1beta2/helmcharts.md
+++ b/docs/spec/v1beta2/helmcharts.md
@@ -253,11 +253,13 @@ For practical information, see
**Note:** This feature is available only for Helm charts fetched from an OCI Registry.
`.spec.verify` is an optional field to enable the verification of [Cosign](https://github.com/sigstore/cosign)
-signatures. The field offers two subfields:
+signatures. The field offers three subfields:
- `.provider`, to specify the verification provider. Only supports `cosign` at present.
- `.secretRef.name`, to specify a reference to a Secret in the same namespace as
the HelmChart, containing the Cosign public keys of trusted authors.
+- `.matchOIDCIdentity`, to specify a list of OIDC identity matchers. Please see
+ [Keyless verification](#keyless-verification) for more details.
```yaml
---
@@ -307,6 +309,18 @@ For publicly available HelmCharts, which are signed using the
[Cosign Keyless](https://github.com/sigstore/cosign/blob/main/KEYLESS.md) procedure,
you can enable the verification by omitting the `.verify.secretRef` field.
+To verify the identity's subject and the OIDC issuer present in the Fulcio
+certificate, you can specify a list of OIDC identity matchers using
+`.spec.verify.matchOIDCIdentity`. The matcher provides two required fields:
+
+- `.issuer`, to specify a regexp that matches against the OIDC issuer.
+- `.subject`, to specify a regexp that matches against the subject identity in
+ the certificate.
+Both values should follow the [Go regular expression syntax](https://golang.org/s/re2syntax).
+
+The matchers are evaluated in an OR fashion, i.e. the identity is deemed to be
+verified if any one matcher successfully matches against the identity.
+
Example of verifying HelmCharts signed by the
[Cosign GitHub Action](https://github.com/sigstore/cosign-installer) with GitHub OIDC Token:
@@ -325,6 +339,9 @@ spec:
version: ">=6.1.6"
verify:
provider: cosign
+ matchOIDCIdentity:
+ - issuer: "^https://token.actions.githubusercontent.com$"
+ subject: "^https://github.com/stefanprodan/podinfo.*$"
```
```yaml
diff --git a/docs/spec/v1beta2/ocirepositories.md b/docs/spec/v1beta2/ocirepositories.md
index 2db354930..4ef84823c 100644
--- a/docs/spec/v1beta2/ocirepositories.md
+++ b/docs/spec/v1beta2/ocirepositories.md
@@ -501,11 +501,13 @@ for more information.
### Verification
`.spec.verify` is an optional field to enable the verification of [Cosign](https://github.com/sigstore/cosign)
-signatures. The field offers two subfields:
+signatures. The field offers three subfields:
- `.provider`, to specify the verification provider. Only supports `cosign` at present.
- `.secretRef.name`, to specify a reference to a Secret in the same namespace as
the OCIRepository, containing the Cosign public keys of trusted authors.
+- `.matchOIDCIdentity`, to specify a list of OIDC identity matchers. Please see
+ [Keyless verification](#keyless-verification) for more details.
```yaml
---
@@ -555,6 +557,18 @@ For publicly available OCI artifacts, which are signed using the
[Cosign Keyless](https://github.com/sigstore/cosign/blob/main/KEYLESS.md) procedure,
you can enable the verification by omitting the `.verify.secretRef` field.
+To verify the identity's subject and the OIDC issuer present in the Fulcio
+certificate, you can specify a list of OIDC identity matchers using
+`.spec.verify.matchOIDCIdentity`. The matcher provides two required fields:
+
+- `.issuer`, to specify a regexp that matches against the OIDC issuer.
+- `.subject`, to specify a regexp that matches against the subject identity in
+ the certificate.
+Both values should follow the [Go regular expression syntax](https://golang.org/s/re2syntax).
+
+The matchers are evaluated in an OR fashion, i.e. the identity is deemed to be
+verified if any one matcher successfully matches against the identity.
+
Example of verifying artifacts signed by the
[Cosign GitHub Action](https://github.com/sigstore/cosign-installer) with GitHub OIDC Token:
@@ -568,6 +582,9 @@ spec:
url: oci://ghcr.io/stefanprodan/manifests/podinfo
verify:
provider: cosign
+ matchOIDCIdentity:
+ - issuer: "^https://token.actions.githubusercontent.com$"
+ subject: "^https://github.com/stefanprodan/podinfo.*$"
```
The controller verifies the signatures using the Fulcio root CA and the Rekor
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..af1ec7d54 100644
--- a/internal/controller/helmchart_controller_test.go
+++ b/internal/controller/helmchart_controller_test.go
@@ -2533,6 +2533,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 = []helmv1.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 incorrect and correct identity matchers should pass verification",
+ version: "6.5.1",
+ want: sreconcile.ResultSuccess,
+ beforeFunc: func(obj *helmv1.HelmChart) {
+ obj.Spec.Verify.MatchOIDCIdentity = []helmv1.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 = []helmv1.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).To(HaveOccurred())
+ } 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..77d745b15 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 incorrect and correct 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: "intruder",
+ Issuer: "^https://honeypot.com$",
+ },
+ {
+
+ 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 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 {
+ g.Expect(err).To(HaveOccurred())
+ tt.wantErrMsg = strings.ReplaceAll(tt.wantErrMsg, "", url)
+ 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$",
+ },
+ },
+ },
},
}