diff --git a/api/v1beta2/helmchart_types.go b/api/v1beta2/helmchart_types.go index 5b12f1f56..0ab4f3ca6 100644 --- a/api/v1beta2/helmchart_types.go +++ b/api/v1beta2/helmchart_types.go @@ -86,6 +86,14 @@ type HelmChartSpec struct { // NOTE: Not implemented, provisional as of https://github.com/fluxcd/flux2/pull/2092 // +optional AccessFrom *acl.AccessFrom `json:"accessFrom,omitempty"` + + // Verify contains the secret name containing the trusted public keys + // used to verify the signature and specifies which provider to use to check + // whether OCI image is authentic. + // This field is only supported for OCI sources. + // Optional dependencies e.g. umbrella chart are not verified. + // +optional + Verify *OCIRepositoryVerification `json:"verify,omitempty"` } const ( diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index b759c3791..a05e1f33d 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -449,6 +449,11 @@ func (in *HelmChartSpec) DeepCopyInto(out *HelmChartSpec) { *out = new(acl.AccessFrom) (*in).DeepCopyInto(*out) } + if in.Verify != nil { + in, out := &in.Verify, &out.Verify + *out = new(OCIRepositoryVerification) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmChartSpec. diff --git a/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml b/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml index 7ef36829d..7e231efbd 100644 --- a/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml +++ b/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml @@ -403,6 +403,31 @@ spec: items: type: string type: array + verify: + description: Verify contains the secret name containing the trusted + public keys used to verify the signature and specifies which provider + to use to check whether OCI image is authentic. + properties: + provider: + default: cosign + description: Provider specifies the technology used to sign the + OCI Artifact. + enum: + - cosign + type: string + secretRef: + description: SecretRef specifies the Kubernetes Secret containing + the trusted public keys. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + required: + - provider + type: object version: default: '*' description: Version is the chart version semver expression, ignored diff --git a/controllers/helmchart_controller.go b/controllers/helmchart_controller.go index 3cc7cc66a..544262347 100644 --- a/controllers/helmchart_controller.go +++ b/controllers/helmchart_controller.go @@ -28,6 +28,7 @@ import ( "strings" "time" + soci "github.com/fluxcd/source-controller/internal/oci" helmgetter "helm.sh/helm/v3/pkg/getter" helmreg "helm.sh/helm/v3/pkg/registry" corev1 "k8s.io/api/core/v1" @@ -57,6 +58,7 @@ import ( "github.com/fluxcd/pkg/runtime/predicates" "github.com/fluxcd/pkg/untar" "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/v1/remote" sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" "github.com/fluxcd/source-controller/internal/cache" @@ -70,6 +72,8 @@ import ( "github.com/fluxcd/source-controller/internal/util" ) +const cosignSignatureKey = "cosign.pub" + // helmChartReadyCondition contains all the conditions information // needed for HelmChart Ready status conditions summary calculation. var helmChartReadyCondition = summarize.Conditions{ @@ -80,6 +84,7 @@ var helmChartReadyCondition = summarize.Conditions{ sourcev1.BuildFailedCondition, sourcev1.ArtifactOutdatedCondition, sourcev1.ArtifactInStorageCondition, + sourcev1.SourceVerifiedCondition, meta.ReadyCondition, meta.ReconcilingCondition, meta.StalledCondition, @@ -90,6 +95,7 @@ var helmChartReadyCondition = summarize.Conditions{ sourcev1.BuildFailedCondition, sourcev1.ArtifactOutdatedCondition, sourcev1.ArtifactInStorageCondition, + sourcev1.SourceVerifiedCondition, meta.StalledCondition, meta.ReconcilingCondition, }, @@ -563,9 +569,30 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj * }() } + var verifier soci.Verifier + if obj.Spec.Verify != nil { + provider := obj.Spec.Verify.Provider + verifier, err = r.makeVerifier(ctx, obj, authenticator, keychain) + if err != nil { + if obj.Spec.Verify.SecretRef == nil { + provider = fmt.Sprintf("%s keyless", provider) + } + e := serror.NewGeneric( + fmt.Errorf("failed to verify the signature using provider '%s': %w", provider, err), + sourcev1.VerificationError, + ) + conditions.MarkFalse(obj, sourcev1.SourceVerifiedCondition, e.Reason, e.Err.Error()) + return sreconcile.ResultEmpty, e + } + } + // Tell the chart repository to use the OCI client with the configured getter clientOpts = append(clientOpts, helmgetter.WithRegistryClient(registryClient)) - ociChartRepo, err := repository.NewOCIChartRepository(normalizedURL, repository.WithOCIGetter(r.Getters), repository.WithOCIGetterOptions(clientOpts), repository.WithOCIRegistryClient(registryClient)) + ociChartRepo, err := repository.NewOCIChartRepository(normalizedURL, + repository.WithOCIGetter(r.Getters), + repository.WithOCIGetterOptions(clientOpts), + repository.WithOCIRegistryClient(registryClient), + repository.WithVerifier(verifier)) if err != nil { return chartRepoConfigErrorReturn(err, obj) } @@ -573,7 +600,7 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj * // If login options are configured, use them to login to the registry // The OCIGetter will later retrieve the stored credentials to pull the chart - if keychain != nil { + if loginOpt != nil { err = ociChartRepo.Login(loginOpt) if err != nil { e := &serror.Event{ @@ -621,6 +648,17 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj * opts := chart.BuildOptions{ ValuesFiles: obj.GetValuesFiles(), Force: obj.Generation != obj.Status.ObservedGeneration, + // The remote builder will not attempt to download the chart if + // an artifact exist with the same name and version and the force is false. + // It will try to verify the chart if: + // - we are on the first reconciliation + // - the HelmChart spec has changed (generation drift) + // - the previous reconciliation resulted in a failed artifact verification + // - there is no artifact in storage + Verify: obj.Spec.Verify != nil && (obj.Generation <= 0 || + conditions.GetObservedGeneration(obj, sourcev1.SourceVerifiedCondition) != obj.Generation || + conditions.IsFalse(obj, sourcev1.SourceVerifiedCondition) || + obj.GetArtifact() == nil), } if artifact := obj.GetArtifact(); artifact != nil { opts.CachedChart = r.Storage.LocalPath(*artifact) @@ -1029,7 +1067,7 @@ func (r *HelmChartReconciler) namespacedChartRepositoryCallback(ctx context.Cont // If login options are configured, use them to login to the registry // The OCIGetter will later retrieve the stored credentials to pull the chart - if keychain != nil { + if loginOpt != nil { err = ociChartRepo.Login(loginOpt) if err != nil { errs = append(errs, fmt.Errorf("failed to login to OCI chart repository for HelmRepository '%s': %w", repo.Name, err)) @@ -1238,6 +1276,11 @@ func observeChartBuild(obj *sourcev1.HelmChart, build *chart.Build, err error) { if build.Complete() { conditions.Delete(obj, sourcev1.FetchFailedCondition) conditions.Delete(obj, sourcev1.BuildFailedCondition) + conditions.MarkTrue(obj, sourcev1.SourceVerifiedCondition, meta.SucceededReason, fmt.Sprintf("verified signature of version %s", build.Version)) + } + + if obj.Spec.Verify == nil { + conditions.Delete(obj, sourcev1.SourceVerifiedCondition) } if err != nil { @@ -1253,6 +1296,10 @@ func observeChartBuild(obj *sourcev1.HelmChart, build *chart.Build, err error) { case chart.ErrChartMetadataPatch, chart.ErrValuesFilesMerge, chart.ErrDependencyBuild, chart.ErrChartPackage: conditions.Delete(obj, sourcev1.FetchFailedCondition) conditions.MarkTrue(obj, sourcev1.BuildFailedCondition, buildErr.Reason.Reason, buildErr.Error()) + case chart.ErrChartVerification: + conditions.Delete(obj, sourcev1.FetchFailedCondition) + conditions.MarkTrue(obj, sourcev1.BuildFailedCondition, buildErr.Reason.Reason, buildErr.Error()) + conditions.MarkFalse(obj, sourcev1.SourceVerifiedCondition, buildErr.Reason.Reason, buildErr.Error()) default: conditions.Delete(obj, sourcev1.BuildFailedCondition) conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, buildErr.Reason.Reason, buildErr.Error()) @@ -1289,3 +1336,51 @@ func chartRepoConfigErrorReturn(err error, obj *sourcev1.HelmChart) (sreconcile. return sreconcile.ResultEmpty, e } } + +// getVerifyOptions returns the verify options for the given chart. +func (r *HelmChartReconciler) makeVerifier(ctx context.Context, obj *sourcev1.HelmChart, auth authn.Authenticator, keychain authn.Keychain) (soci.Verifier, error) { + var publicKey []byte + verifyOpts := []remote.Option{} + if auth != nil { + verifyOpts = append(verifyOpts, remote.WithAuth(auth)) + } else { + verifyOpts = append(verifyOpts, remote.WithAuthFromKeychain(keychain)) + } + + // get the public keys from the given secret + if secretRef := obj.Spec.Verify.SecretRef; secretRef != nil { + certSecretName := types.NamespacedName{ + Namespace: obj.Namespace, + Name: secretRef.Name, + } + + var pubSecret corev1.Secret + if err := r.Get(ctx, certSecretName, &pubSecret); err != nil { + return nil, err + } + + switch obj.Spec.Verify.Provider { + case "cosign": + // we expect to find this key in the secret for cosign verification. We want to avoid + // having to look for a random public key. + if key, ok := pubSecret.Data[cosignSignatureKey]; ok { + publicKey = key + } + default: + } + } + + switch obj.Spec.Verify.Provider { + case "cosign": + defaultCosignOciOpts := []soci.Options{ + soci.WithRemoteOptions(verifyOpts...), + } + verifier, err := soci.NewCosignVerifier(ctx, append(defaultCosignOciOpts, soci.WithPublicKey(publicKey))...) + if err != nil { + return nil, err + } + return verifier, nil + default: + return nil, fmt.Errorf("unsupported verification provider: %s", obj.Spec.Verify.Provider) + } +} diff --git a/controllers/helmchart_controller_test.go b/controllers/helmchart_controller_test.go index 631286bc1..d2f73f057 100644 --- a/controllers/helmchart_controller_test.go +++ b/controllers/helmchart_controller_test.go @@ -26,6 +26,7 @@ import ( "io/ioutil" "net/http" "os" + "path" "path/filepath" "reflect" "strings" @@ -34,6 +35,9 @@ import ( "github.com/darkowlzz/controller-check/status" . "github.com/onsi/gomega" + coptions "github.com/sigstore/cosign/cmd/cosign/cli/options" + "github.com/sigstore/cosign/cmd/cosign/cli/sign" + "github.com/sigstore/cosign/pkg/cosign" hchart "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/chart/loader" helmreg "helm.sh/helm/v3/pkg/registry" @@ -56,6 +60,7 @@ import ( serror "github.com/fluxcd/source-controller/internal/error" "github.com/fluxcd/source-controller/internal/helm/chart" "github.com/fluxcd/source-controller/internal/helm/registry" + "github.com/fluxcd/source-controller/internal/oci" sreconcile "github.com/fluxcd/source-controller/internal/reconcile" "github.com/fluxcd/source-controller/internal/reconcile/summarize" ) @@ -2217,6 +2222,230 @@ func TestHelmChartReconciler_reconcileSourceFromOCI_authStrategy(t *testing.T) { } } +func TestHelmChartReconciler_reconcileSourceFromOCI_verifySignature(t *testing.T) { + g := NewWithT(t) + + tmpDir := t.TempDir() + server, err := setupRegistryServer(ctx, tmpDir, registryOptions{}) + g.Expect(err).ToNot(HaveOccurred()) + + const ( + chartPath = "testdata/charts/helmchart-0.1.0.tgz" + ) + + // Load a test chart + chartData, err := ioutil.ReadFile(chartPath) + + // Upload the test chart + metadata, err := loadTestChartToOCI(chartData, chartPath, server) + g.Expect(err).NotTo(HaveOccurred()) + + storage, err := NewStorage(tmpDir, "example.com", retentionTTL, retentionRecords) + g.Expect(err).ToNot(HaveOccurred()) + + cachedArtifact := &sourcev1.Artifact{ + Revision: "0.1.0", + Path: metadata.Name + "-" + metadata.Version + ".tgz", + } + g.Expect(storage.CopyFromPath(cachedArtifact, "testdata/charts/helmchart-0.1.0.tgz")).To(Succeed()) + + pf := func(b bool) ([]byte, error) { + return []byte("cosign-password"), nil + } + + keys, err := cosign.GenerateKeyPair(pf) + g.Expect(err).ToNot(HaveOccurred()) + + err = os.WriteFile(path.Join(tmpDir, "cosign.key"), keys.PrivateBytes, 0600) + g.Expect(err).ToNot(HaveOccurred()) + + defer func() { + err := os.Remove(path.Join(tmpDir, "cosign.key")) + g.Expect(err).ToNot(HaveOccurred()) + }() + + tests := []struct { + name string + want sreconcile.Result + wantErr bool + wantErrMsg string + shouldSign bool + beforeFunc func(obj *sourcev1.HelmChart) + assertConditions []metav1.Condition + cleanFunc func(g *WithT, build *chart.Build) + }{ + { + name: "unsigned charts should not pass verification", + beforeFunc: func(obj *sourcev1.HelmChart) { + obj.Spec.Chart = metadata.Name + obj.Spec.Version = metadata.Version + obj.Spec.Verify = &sourcev1.OCIRepositoryVerification{ + Provider: "cosign", + SecretRef: &meta.LocalObjectReference{Name: "cosign-key"}, + } + }, + wantErr: true, + wantErrMsg: "chart verification error: failed to verify : no matching signatures:", + want: sreconcile.ResultEmpty, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(sourcev1.BuildFailedCondition, "ChartVerificationError", "chart verification error: failed to verify : no matching signatures:"), + *conditions.FalseCondition(sourcev1.SourceVerifiedCondition, "ChartVerificationError", "chart verification error: failed to verify : no matching signatures:"), + }, + }, + { + name: "unsigned charts should not pass keyless verification", + beforeFunc: func(obj *sourcev1.HelmChart) { + obj.Spec.Chart = metadata.Name + obj.Spec.Version = metadata.Version + obj.Spec.Verify = &sourcev1.OCIRepositoryVerification{ + Provider: "cosign", + } + }, + 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, "ChartVerificationError", "chart verification error: failed to verify : no matching signatures:"), + }, + }, + { + name: "signed charts should pass verification", + beforeFunc: func(obj *sourcev1.HelmChart) { + obj.Spec.Chart = metadata.Name + obj.Spec.Version = metadata.Version + obj.Spec.Verify = &sourcev1.OCIRepositoryVerification{ + Provider: "cosign", + SecretRef: &meta.LocalObjectReference{Name: "cosign-key"}, + } + }, + shouldSign: true, + want: sreconcile.ResultSuccess, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(sourcev1.ArtifactOutdatedCondition, "NewChart", "pulled '' chart with version ''"), + *conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of version "), + }, + cleanFunc: func(g *WithT, build *chart.Build) { + g.Expect(os.Remove(build.Path)).To(Succeed()) + }, + }, + { + name: "verify failed before, removed from spec, remove condition", + beforeFunc: func(obj *sourcev1.HelmChart) { + obj.Spec.Chart = metadata.Name + obj.Spec.Version = metadata.Version + obj.Spec.Verify = nil + conditions.MarkFalse(obj, sourcev1.SourceVerifiedCondition, "VerifyFailed", "fail msg") + obj.Status.Artifact = &sourcev1.Artifact{Path: metadata.Name + "-" + metadata.Version + ".tgz"} + }, + want: sreconcile.ResultSuccess, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(sourcev1.ArtifactOutdatedCondition, "NewChart", "pulled '' chart with version ''"), + }, + cleanFunc: func(g *WithT, build *chart.Build) { + g.Expect(os.Remove(build.Path)).To(Succeed()) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + clientBuilder := fake.NewClientBuilder() + + repository := &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "helmrepository-", + }, + Spec: sourcev1.HelmRepositorySpec{ + URL: fmt.Sprintf("oci://%s/testrepo", server.registryHost), + Timeout: &metav1.Duration{Duration: timeout}, + Provider: sourcev1.GenericOCIProvider, + Type: sourcev1.HelmRepositoryTypeOCI, + }, + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cosign-key", + }, + Data: map[string][]byte{ + "cosign.pub": keys.PublicBytes, + }} + + clientBuilder.WithObjects(repository, secret) + + r := &HelmChartReconciler{ + Client: clientBuilder.Build(), + EventRecorder: record.NewFakeRecorder(32), + Getters: testGetters, + Storage: storage, + RegistryClientGenerator: registry.ClientGenerator, + } + + obj := &sourcev1.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "helmchart-", + }, + Spec: sourcev1.HelmChartSpec{ + SourceRef: sourcev1.LocalHelmChartSourceReference{ + Kind: sourcev1.HelmRepositoryKind, + Name: repository.Name, + }, + }, + } + + chartUrl := fmt.Sprintf("oci://%s/testrepo/%s:%s", server.registryHost, metadata.Name, metadata.Version) + + if tt.beforeFunc != nil { + tt.beforeFunc(obj) + } + + if tt.shouldSign { + ko := coptions.KeyOpts{ + KeyRef: path.Join(tmpDir, "cosign.key"), + PassFunc: pf, + } + + ro := &coptions.RootOptions{ + Timeout: timeout, + } + + err = sign.SignCmd(ro, ko, coptions.RegistryOptions{Keychain: oci.Anonymous{}}, + nil, []string{fmt.Sprintf("%s/testrepo/%s:%s", server.registryHost, metadata.Name, metadata.Version)}, "", + "", true, "", + "", "", false, + false, "", false) + g.Expect(err).ToNot(HaveOccurred()) + } + + assertConditions := tt.assertConditions + for k := range assertConditions { + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", metadata.Name) + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", metadata.Version) + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", chartUrl) + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", "cosign") + } + + var b chart.Build + if tt.cleanFunc != nil { + defer tt.cleanFunc(g, &b) + } + + got, err := r.reconcileSource(ctx, obj, &b) + if tt.wantErr { + tt.wantErrMsg = strings.ReplaceAll(tt.wantErrMsg, "", 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)) + }) + } +} + // extractChartMeta is used to extract a chart metadata from a byte array func extractChartMeta(chartData []byte) (*hchart.Metadata, error) { ch, err := loader.LoadArchive(bytes.NewReader(chartData)) diff --git a/controllers/ocirepository_controller.go b/controllers/ocirepository_controller.go index 2a6d44429..edbb6e1dc 100644 --- a/controllers/ocirepository_controller.go +++ b/controllers/ocirepository_controller.go @@ -628,7 +628,7 @@ func (r *OCIRepositoryReconciler) verifySignature(ctx context.Context, obj *sour for k, data := range pubSecret.Data { // search for public keys in the secret if strings.HasSuffix(k, ".pub") { - verifier, err := soci.NewVerifier(ctxTimeout, append(defaultCosignOciOpts, soci.WithPublicKey(data))...) + verifier, err := soci.NewCosignVerifier(ctxTimeout, append(defaultCosignOciOpts, soci.WithPublicKey(data))...) if err != nil { return err } @@ -654,7 +654,7 @@ func (r *OCIRepositoryReconciler) verifySignature(ctx context.Context, obj *sour // 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") - verifier, err := soci.NewVerifier(ctxTimeout, defaultCosignOciOpts...) + verifier, err := soci.NewCosignVerifier(ctxTimeout, defaultCosignOciOpts...) if err != nil { return err } diff --git a/docs/api/source.md b/docs/api/source.md index 8c4eda2ee..ff4ed4af7 100644 --- a/docs/api/source.md +++ b/docs/api/source.md @@ -670,6 +670,22 @@ references to this object. NOTE: Not implemented, provisional as of https://github.com/fluxcd/flux2/pull/2092

+ + +verify
+ + +OCIRepositoryVerification + + + + +(Optional) +

Verify contains the secret name containing the trusted public keys +used to verify the signature and specifies which provider to use to check +whether OCI image is authentic.

+ + @@ -2180,6 +2196,22 @@ references to this object. NOTE: Not implemented, provisional as of https://github.com/fluxcd/flux2/pull/2092

+ + +verify
+ + +OCIRepositoryVerification + + + + +(Optional) +

Verify contains the secret name containing the trusted public keys +used to verify the signature and specifies which provider to use to check +whether OCI image is authentic.

+ + @@ -3035,6 +3067,7 @@ github.com/fluxcd/pkg/apis/meta.ReconcileRequestStatus

(Appears on: +HelmChartSpec, OCIRepositorySpec)

OCIRepositoryVerification verifies the authenticity of an OCI Artifact

diff --git a/internal/helm/chart/builder.go b/internal/helm/chart/builder.go index 76dc517c7..5be208d8c 100644 --- a/internal/helm/chart/builder.go +++ b/internal/helm/chart/builder.go @@ -113,6 +113,8 @@ type BuildOptions struct { // Force can be set to force the build of the chart, for example // because the list of ValuesFiles has changed. Force bool + // Verifier can be set to the verification of the chart. + Verify bool } // GetValuesFiles returns BuildOptions.ValuesFiles, except if it equals diff --git a/internal/helm/chart/builder_remote.go b/internal/helm/chart/builder_remote.go index d15e24299..20589472b 100644 --- a/internal/helm/chart/builder_remote.go +++ b/internal/helm/chart/builder_remote.go @@ -63,7 +63,7 @@ func NewRemoteBuilder(repository repository.Downloader) Builder { // After downloading the chart, it is only packaged if required due to BuildOptions // modifying the chart, otherwise the exact data as retrieved from the repository // is written to p, after validating it to be a chart. -func (b *remoteChartBuilder) Build(_ context.Context, ref Reference, p string, opts BuildOptions) (*Build, error) { +func (b *remoteChartBuilder) Build(ctx context.Context, ref Reference, p string, opts BuildOptions) (*Build, error) { remoteRef, ok := ref.(RemoteReference) if !ok { err := fmt.Errorf("expected remote chart reference") @@ -74,9 +74,9 @@ func (b *remoteChartBuilder) Build(_ context.Context, ref Reference, p string, o return nil, &BuildError{Reason: ErrChartReference, Err: err} } - res, result, err := b.downloadFromRepository(b.remote, remoteRef, opts) + res, result, err := b.downloadFromRepository(ctx, b.remote, remoteRef, opts) if err != nil { - return nil, &BuildError{Reason: ErrChartPull, Err: err} + return nil, err } if res == nil { return result, nil @@ -124,7 +124,7 @@ func (b *remoteChartBuilder) Build(_ context.Context, ref Reference, p string, o return result, nil } -func (b *remoteChartBuilder) downloadFromRepository(remote repository.Downloader, remoteRef RemoteReference, opts BuildOptions) (*bytes.Buffer, *Build, error) { +func (b *remoteChartBuilder) downloadFromRepository(ctx context.Context, remote repository.Downloader, remoteRef RemoteReference, opts BuildOptions) (*bytes.Buffer, *Build, error) { // Get the current version for the RemoteReference cv, err := remote.GetChartVersion(remoteRef.Name, remoteRef.Version) if err != nil { @@ -132,6 +132,13 @@ func (b *remoteChartBuilder) downloadFromRepository(remote repository.Downloader return nil, nil, &BuildError{Reason: ErrChartReference, Err: err} } + // Verify the chart if necessary + if opts.Verify { + if err := remote.VerifyChart(ctx, cv); err != nil { + return nil, nil, &BuildError{Reason: ErrChartVerification, Err: err} + } + } + result, shouldReturn, err := generateBuildResult(cv, opts) if err != nil { return nil, nil, err diff --git a/internal/helm/chart/errors.go b/internal/helm/chart/errors.go index dedff9e37..7b1b7f3b0 100644 --- a/internal/helm/chart/errors.go +++ b/internal/helm/chart/errors.go @@ -84,5 +84,6 @@ var ( ErrValuesFilesMerge = BuildErrorReason{Reason: "ValuesFilesError", Summary: "values files merge error"} ErrDependencyBuild = BuildErrorReason{Reason: "DependencyBuildError", Summary: "dependency build error"} ErrChartPackage = BuildErrorReason{Reason: "ChartPackageError", Summary: "chart package error"} + ErrChartVerification = BuildErrorReason{Reason: "ChartVerificationError", Summary: "chart verification error"} ErrUnknown = BuildErrorReason{Reason: "Unknown", Summary: "unknown build error"} ) diff --git a/internal/helm/repository/chart_repository.go b/internal/helm/repository/chart_repository.go index 282d49a5d..3997d5f3f 100644 --- a/internal/helm/repository/chart_repository.go +++ b/internal/helm/repository/chart_repository.go @@ -18,6 +18,7 @@ package repository import ( "bytes" + "context" "crypto/sha256" "crypto/tls" "encoding/hex" @@ -520,3 +521,12 @@ func (r *ChartRepository) RemoveCache() error { } return nil } + +// VerifyChart verifies the chart against a signature. +// If no signature is provided, a keyless verification is performed. +// It returns an error on failure. +func (r *ChartRepository) VerifyChart(_ context.Context, _ *repo.ChartVersion) error { + // no-op + // this is a no-op because this is not implemented yet. + return nil +} diff --git a/internal/helm/repository/oci_chart_repository.go b/internal/helm/repository/oci_chart_repository.go index a037e6b40..72af1bf88 100644 --- a/internal/helm/repository/oci_chart_repository.go +++ b/internal/helm/repository/oci_chart_repository.go @@ -18,6 +18,7 @@ package repository import ( "bytes" + "context" "crypto/tls" "fmt" "net/url" @@ -32,7 +33,10 @@ import ( "helm.sh/helm/v3/pkg/repo" "github.com/Masterminds/semver/v3" + "github.com/google/go-containerregistry/pkg/name" + "github.com/fluxcd/pkg/version" + "github.com/fluxcd/source-controller/internal/oci" "github.com/fluxcd/source-controller/internal/transport" ) @@ -63,12 +67,24 @@ type OCIChartRepository struct { RegistryClient RegistryClient // credentialsFile is a temporary credentials file to use while downloading tags or charts from a registry. credentialsFile string + + // verifier is a chart verifier + // If attempts to verify a chart, and if the verifier is nil, an error is returned. + verifier oci.Verifier } // OCIChartRepositoryOption is a function that can be passed to NewOCIChartRepository // to configure an OCIChartRepository. type OCIChartRepositoryOption func(*OCIChartRepository) error +// WithVerifier returns a ChartRepositoryOption that will set the chart verifier +func WithVerifier(verifier oci.Verifier) OCIChartRepositoryOption { + return func(r *OCIChartRepository) error { + r.verifier = verifier + return nil + } +} + // WithOCIRegistryClient returns a ChartRepositoryOption that will set the registry client func WithOCIRegistryClient(client RegistryClient) OCIChartRepositoryOption { return func(r *OCIChartRepository) error { @@ -215,7 +231,6 @@ func (r *OCIChartRepository) DownloadChart(chart *repo.ChartVersion) (*bytes.Buf // Login attempts to login to the OCI registry. // It returns an error on failure. func (r *OCIChartRepository) Login(opts ...registry.LoginOption) error { - // Get login credentials from keychain err := r.RegistryClient.Login(r.URL.Host, opts...) if err != nil { return err @@ -297,3 +312,28 @@ func getLastMatchingVersionOrConstraint(cvs []string, ver string) (string, error return matchingVersions[0].Original(), nil } + +// VerifyChart verifies the chart against a signature. +// If no signature is provided, a keyless verification is performed. +// It returns an error on failure. +func (r *OCIChartRepository) VerifyChart(ctx context.Context, chart *repo.ChartVersion) error { + if r.verifier == nil { + return fmt.Errorf("no verifier available") + } + + if len(chart.URLs) == 0 { + return fmt.Errorf("chart '%s' has no downloadable URLs", chart.Name) + } + + ref, err := name.ParseReference(strings.TrimPrefix(chart.URLs[0], fmt.Sprintf("%s://", registry.OCIScheme))) + if err != nil { + return fmt.Errorf("invalid chart reference: %s", err) + } + + // verify the chart + if err := r.verifier.Verify(ctx, ref); err != nil { + return fmt.Errorf("failed to verify %s: %w", chart.URLs[0], err) + } + + return nil +} diff --git a/internal/helm/repository/repository.go b/internal/helm/repository/repository.go index 4c8cb7ff8..5fdf62bfa 100644 --- a/internal/helm/repository/repository.go +++ b/internal/helm/repository/repository.go @@ -18,6 +18,7 @@ package repository import ( "bytes" + "context" "helm.sh/helm/v3/pkg/repo" ) @@ -29,6 +30,8 @@ type Downloader interface { GetChartVersion(name, version string) (*repo.ChartVersion, error) // DownloadChart downloads a chart from the remote Helm repository or OCI Helm repository. DownloadChart(chart *repo.ChartVersion) (*bytes.Buffer, error) + // VerifyChart verifies the chart against a signature. + VerifyChart(ctx context.Context, chart *repo.ChartVersion) error // Clear removes all temporary files created by the downloader, caching the files if the cache is configured, // and calling garbage collector to remove unused files. Clear() error diff --git a/internal/oci/verifier.go b/internal/oci/verifier.go index b8d9c5d49..3d7a0df9b 100644 --- a/internal/oci/verifier.go +++ b/internal/oci/verifier.go @@ -34,13 +34,18 @@ import ( "github.com/sigstore/sigstore/pkg/signature" ) +// Verifier is an interface for verifying the authenticity of an OCI image. +type Verifier interface { + Verify(ctx context.Context, ref name.Reference) error +} + // options is a struct that holds options for verifier. type options struct { PublicKey []byte ROpt []remote.Option } -// Options is a function that configures the options applied to a Verifier. +// Options is a function that configures the options applied to a CosignVerifier. type Options func(opts *options) // WithPublicKey sets the public key. @@ -58,13 +63,13 @@ func WithRemoteOptions(opts ...remote.Option) Options { } } -// Verifier is a struct which is responsible for executing verification logic. -type Verifier struct { +// CosignVerifier is a struct which is responsible for executing verification logic. +type CosignVerifier struct { opts *cosign.CheckOpts } -// NewVerifier initializes a new Verifier. -func NewVerifier(ctx context.Context, opts ...Options) (*Verifier, error) { +// NewCosignVerifier initializes a new CosignVerifier. +func NewCosignVerifier(ctx context.Context, opts ...Options) (*CosignVerifier, error) { o := options{} for _, opt := range opts { opt(&o) @@ -117,12 +122,26 @@ func NewVerifier(ctx context.Context, opts ...Options) (*Verifier, error) { checkOpts.RekorClient = rc } - return &Verifier{ + return &CosignVerifier{ opts: checkOpts, }, nil } // VerifyImageSignatures verify the authenticity of the given ref OCI image. -func (v *Verifier) VerifyImageSignatures(ctx context.Context, ref name.Reference) ([]oci.Signature, bool, error) { +func (v *CosignVerifier) VerifyImageSignatures(ctx context.Context, ref name.Reference) ([]oci.Signature, bool, error) { return cosign.VerifyImageSignatures(ctx, ref, v.opts) } + +// Verify verifies the authenticity of the given ref OCI image. +func (v *CosignVerifier) Verify(ctx context.Context, ref name.Reference) error { + signatures, _, err := v.VerifyImageSignatures(ctx, ref) + if err != nil { + return err + } + + if len(signatures) == 0 { + return fmt.Errorf("no matching signatures were found for '%s'", ref.Name()) + } + + return nil +}