Skip to content

Commit

Permalink
cosign: allow identity matching for keyless verification
Browse files Browse the repository at this point in the history
Add `CosignIdentityMatch` to OCIRepository and HelmChart. It allows
specifying a regexp to match against the subject and issuer of the
certificate related to the artifact signature, if the artifact was
signed using Cosign keyless signing.

Signed-off-by: Sanskar Jaiswal <[email protected]>

okok
  • Loading branch information
aryan9600 committed Oct 6, 2023
1 parent ff39d21 commit dca67bb
Show file tree
Hide file tree
Showing 10 changed files with 478 additions and 13 deletions.
20 changes: 20 additions & 0 deletions api/v1beta2/ocirepository_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,26 @@ type OCIRepositoryVerification struct {
// trusted public keys.
// +optional
SecretRef *meta.LocalObjectReference `json:"secretRef,omitempty"`

// CosignIdentityMatch specifies the identity matching criteria to use
// while verifying an OCI artifact which was signed using Cosign keyless
// signing.
CosignIdentityMatch *CosignIdentityMatch `json:"cosignIdentityMatch,omitempty"`
}

// CosignIdentityMatch specifies options for verifying the certificate identity,
// i.e. the issuer and the subject of the certificate.
type CosignIdentityMatch struct {
// IssuerRegExp 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.
// +optional
IssuerRegExp string `json:"issuerRegExp,omitempty"`
// SubjectRegExp specifies the regex pattern to match against to verify
// the identity in the Fulcio certificate. The pattern must be a
// valid Go regular expression.
// +optional
SubjectRegExp string `json:"subjectRegExp,omitempty"`
}

// OCIRepositoryStatus defines the observed state of OCIRepository
Expand Down
20 changes: 20 additions & 0 deletions api/v1beta2/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,22 @@ spec:
Chart dependencies, which are not bundled in the umbrella chart
artifact, are not verified.
properties:
cosignIdentityMatch:
description: CosignIdentityMatch specifies the identity matching
criteria to use while verifying an OCI artifact which was signed
using Cosign keyless signing.
properties:
issuerRegExp:
description: IssuerRegExp 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
subjectRegExp:
description: SubjectRegExp specifies the regex pattern to
match against to verify the identity in the Fulcio certificate.
The pattern must be a valid Go regular expression.
type: string
type: object
provider:
default: cosign
description: Provider specifies the technology used to sign the
Expand Down
16 changes: 16 additions & 0 deletions config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,22 @@ spec:
public keys used to verify the signature and specifies which provider
to use to check whether OCI image is authentic.
properties:
cosignIdentityMatch:
description: CosignIdentityMatch specifies the identity matching
criteria to use while verifying an OCI artifact which was signed
using Cosign keyless signing.
properties:
issuerRegExp:
description: IssuerRegExp 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
subjectRegExp:
description: SubjectRegExp specifies the regex pattern to
match against to verify the identity in the Fulcio certificate.
The pattern must be a valid Go regular expression.
type: string
type: object
provider:
default: cosign
description: Provider specifies the technology used to sign the
Expand Down
65 changes: 65 additions & 0 deletions docs/api/v1beta2/source.md
Original file line number Diff line number Diff line change
Expand Up @@ -1614,6 +1614,56 @@ github.com/fluxcd/pkg/apis/meta.ReconcileRequestStatus
</table>
</div>
</div>
<h3 id="source.toolkit.fluxcd.io/v1beta2.CosignIdentityMatch">CosignIdentityMatch
</h3>
<p>
(<em>Appears on:</em>
<a href="#source.toolkit.fluxcd.io/v1beta2.OCIRepositoryVerification">OCIRepositoryVerification</a>)
</p>
<p>CosignIdentityMatch specifies options for verifying the certificate identity,
i.e. the issuer and the subject of the certificate.</p>
<div class="md-typeset__scrollwrap">
<div class="md-typeset__table">
<table>
<thead>
<tr>
<th>Field</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>issuerRegExp</code><br>
<em>
string
</em>
</td>
<td>
<em>(Optional)</em>
<p>IssuerRegExp 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.</p>
</td>
</tr>
<tr>
<td>
<code>subjectRegExp</code><br>
<em>
string
</em>
</td>
<td>
<em>(Optional)</em>
<p>SubjectRegExp specifies the regex pattern to match against to verify
the identity in the Fulcio certificate. The pattern must be a
valid Go regular expression.</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<h3 id="source.toolkit.fluxcd.io/v1beta2.GitRepositoryInclude">GitRepositoryInclude
</h3>
<p>
Expand Down Expand Up @@ -3295,6 +3345,21 @@ github.com/fluxcd/pkg/apis/meta.LocalObjectReference
trusted public keys.</p>
</td>
</tr>
<tr>
<td>
<code>cosignIdentityMatch</code><br>
<em>
<a href="#source.toolkit.fluxcd.io/v1beta2.CosignIdentityMatch">
CosignIdentityMatch
</a>
</em>
</td>
<td>
<p>CosignIdentityMatch specifies the identity matching criteria to use
while verifying an OCI artifact which was signed using Cosign keyless
signing.</p>
</td>
</tr>
</tbody>
</table>
</div>
Expand Down
9 changes: 9 additions & 0 deletions internal/controller/helmchart_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,15 @@ func (r *HelmChartReconciler) makeVerifiers(ctx context.Context, obj *helmv1.Hel
}

// if no secret is provided, add a keyless verifier
if obj.Spec.Verify.CosignIdentityMatch != nil {
if obj.Spec.Verify.CosignIdentityMatch.IssuerRegExp != "" {
defaultCosignOciOpts = append(defaultCosignOciOpts, soci.WithIssuerRegexp(obj.Spec.Verify.CosignIdentityMatch.IssuerRegExp))
}

if obj.Spec.Verify.CosignIdentityMatch.SubjectRegExp != "" {
defaultCosignOciOpts = append(defaultCosignOciOpts, soci.WithSubjectRegexp(obj.Spec.Verify.CosignIdentityMatch.SubjectRegExp))
}
}
verifier, err := soci.NewCosignVerifier(ctx, defaultCosignOciOpts...)
if err != nil {
return nil, err
Expand Down
149 changes: 149 additions & 0 deletions internal/controller/helmchart_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2533,6 +2533,155 @@ 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
wantErrMsg string
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 <version>"),
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled '<name>' chart with version '<version>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled '<name>' chart with version '<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.MatchOIDCIdentity{
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 <version>"),
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled '<name>' chart with version '<version>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled '<name>' chart with version '<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.MatchOIDCIdentity{
Subject: "intruder",
Issuer: "^https://honeypot.com$",
}
},
assertConditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.BuildFailedCondition, "ChartVerificationError", "chart verification error: failed to verify <url>: 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 <url>: 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 <url>: no matching signatures"),
*conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "chart verification error: failed to verify <url>: 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, "<name>", obj.Spec.Chart)
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<version>", obj.Spec.Version)
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<url>", chartUrl)
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<provider>", "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 {
tt.wantErrMsg = strings.ReplaceAll(tt.wantErrMsg, "<url>", chartUrl)
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 TestHelmChartReconciler_reconcileSourceFromOCI_verifySignature(t *testing.T) {
g := NewWithT(t)

Expand Down
10 changes: 10 additions & 0 deletions internal/controller/ocirepository_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -664,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")

if obj.Spec.Verify.CosignIdentityMatch != nil {
if obj.Spec.Verify.CosignIdentityMatch.IssuerRegExp != "" {
defaultCosignOciOpts = append(defaultCosignOciOpts, soci.WithIssuerRegexp(obj.Spec.Verify.CosignIdentityMatch.IssuerRegExp))
}

if obj.Spec.Verify.CosignIdentityMatch.SubjectRegExp != "" {
defaultCosignOciOpts = append(defaultCosignOciOpts, soci.WithSubjectRegexp(obj.Spec.Verify.CosignIdentityMatch.SubjectRegExp))
}
}
verifier, err := soci.NewCosignVerifier(ctxTimeout, defaultCosignOciOpts...)
if err != nil {
return err
Expand Down
Loading

0 comments on commit dca67bb

Please sign in to comment.