diff --git a/api/go.mod b/api/go.mod index a5445cc68..7ceae5b8a 100644 --- a/api/go.mod +++ b/api/go.mod @@ -17,10 +17,13 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - golang.org/x/net v0.0.0-20211215060638-4ddde0e984e9 // indirect + golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d // indirect + golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect golang.org/x/text v0.3.7 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + k8s.io/api v0.23.4 // indirect k8s.io/klog/v2 v2.30.0 // indirect k8s.io/utils v0.0.0-20211208161948-7d6a63dca704 // indirect sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2 // indirect diff --git a/api/go.sum b/api/go.sum index 0526ae80d..1c0d21c48 100644 --- a/api/go.sum +++ b/api/go.sum @@ -297,6 +297,8 @@ github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -334,7 +336,6 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= @@ -569,8 +570,9 @@ golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211209124913-491a49abca63/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211215060638-4ddde0e984e9 h1:kmreh1vGI63l2FxOAYS3Yv6ATsi7lSTuwNSVbGfJV9I= golang.org/x/net v0.0.0-20211215060638-4ddde0e984e9/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d h1:62NvYBuaanGXR2ZOfwDFkhhl6X1DUgf8qg3GuQvxZsE= +golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -660,8 +662,9 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8 h1:M69LAlWZCshgp0QSzyDcSsSIejIEeuaCVpmwcKwyLMk= golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -857,8 +860,9 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= @@ -893,8 +897,9 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -k8s.io/api v0.23.0 h1:WrL1gb73VSC8obi8cuYETJGXEoFNEh3LU0Pt+Sokgro= k8s.io/api v0.23.0/go.mod h1:8wmDdLBHBNxtOIytwLstXt5E9PddnZb0GaMcqsvDBpg= +k8s.io/api v0.23.4 h1:85gnfXQOWbJa1SiWGpE9EEtHs0UVvDyIsSMpEtl2D4E= +k8s.io/api v0.23.4/go.mod h1:i77F4JfyNNrhOjZF7OwwNJS5Y1S9dpwvb9iYRYRczfI= k8s.io/apiextensions-apiserver v0.23.0/go.mod h1:xIFAEEDlAZgpVBl/1VSjGDmLoXAWRG40+GsWhKhAxY4= k8s.io/apimachinery v0.23.0/go.mod h1:fFCTTBKvKcwTPFzjlcxp91uPFZr+JA0FubU4fLzzFYc= k8s.io/apimachinery v0.23.4 h1:fhnuMd/xUL3Cjfl64j5ULKZ1/J9n8NuQEgNL+WXWfdM= diff --git a/api/v1beta2/condition_types.go b/api/v1beta2/condition_types.go index 647b8aa7f..3b131bfcd 100644 --- a/api/v1beta2/condition_types.go +++ b/api/v1beta2/condition_types.go @@ -85,4 +85,8 @@ const ( // SymlinkUpdateFailedReason signals a failure in updating a symlink. SymlinkUpdateFailedReason string = "SymlinkUpdateFailed" + + // VerificationFailedReason signals a failure in verifying the signature of + // an artifact instance, such as a git commit or a helm chart. + VerificationFailedReason string = "VerificationFailed" ) diff --git a/api/v1beta2/helmchart_types.go b/api/v1beta2/helmchart_types.go index 2ce5a942f..8c634b138 100644 --- a/api/v1beta2/helmchart_types.go +++ b/api/v1beta2/helmchart_types.go @@ -84,6 +84,23 @@ type HelmChartSpec struct { // NOTE: Not implemented, provisional as of https://github.com/fluxcd/flux2/pull/2092 // +optional AccessFrom *acl.AccessFrom `json:"accessFrom,omitempty"` + + // VerificationKeyring for verifying the packaged chart's signature using a provenance file. + // +optional + VerificationKeyring *VerificationKeyring `json:"verificationKeyring,omitempty"` +} + +// VerificationKeyring contains enough info to get the public GPG key to be used for verifying +// the chart signature using a provenance file. +type VerificationKeyring struct { + // SecretRef is a reference to the secret that contains the public GPG key. + // +required + SecretRef meta.LocalObjectReference `json:"secretRef,omitempty"` + + // Key in the SecretRef that contains the public keyring in legacy GPG format. + // +kubebuilder:default:=pubring.gpg + // +optional + Key string `json:"key,omitempty"` } const ( @@ -154,6 +171,10 @@ const ( // ChartPackageSucceededReason signals that the package of the Helm // chart succeeded. ChartPackageSucceededReason string = "ChartPackageSucceeded" + + // ChartVerificationSucceededReason signals that the Helm chart's signature + // has been verified using it's provenance file. + ChartVerificationSucceededReason string = "ChartVerificationSucceeded" ) // GetConditions returns the status conditions of the object. diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index b789d81da..6cfbc3e35 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -442,6 +442,11 @@ func (in *HelmChartSpec) DeepCopyInto(out *HelmChartSpec) { *out = new(acl.AccessFrom) (*in).DeepCopyInto(*out) } + if in.VerificationKeyring != nil { + in, out := &in.VerificationKeyring, &out.VerificationKeyring + *out = new(VerificationKeyring) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmChartSpec. @@ -614,3 +619,19 @@ func (in *LocalHelmChartSourceReference) DeepCopy() *LocalHelmChartSourceReferen in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VerificationKeyring) DeepCopyInto(out *VerificationKeyring) { + *out = *in + out.SecretRef = in.SecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VerificationKeyring. +func (in *VerificationKeyring) DeepCopy() *VerificationKeyring { + if in == nil { + return nil + } + out := new(VerificationKeyring) + 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 a45d0370b..2026e7efa 100644 --- a/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml +++ b/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml @@ -404,6 +404,26 @@ spec: items: type: string type: array + verificationKeyring: + description: VerificationKeyring for verifying the packaged chart's + signature using a provenance file. + properties: + key: + default: pubring.gpg + description: Key in the SecretRef that contains the public keyring + in legacy GPG format. + type: string + secretRef: + description: SecretRef is a reference to the secret that contains + the public GPG key. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + type: object version: default: '*' description: Version is the chart version semver expression, ignored diff --git a/controllers/gitrepository_controller.go b/controllers/gitrepository_controller.go index 9c9189ca8..d7df0a90a 100644 --- a/controllers/gitrepository_controller.go +++ b/controllers/gitrepository_controller.go @@ -638,7 +638,7 @@ func (r *GitRepositoryReconciler) verifyCommitSignature(ctx context.Context, obj if err := r.Client.Get(ctx, publicKeySecret, secret); err != nil { e := &serror.Event{ Err: fmt.Errorf("PGP public keys secret error: %w", err), - Reason: "VerificationError", + Reason: sourcev1.VerificationFailedReason, } conditions.MarkFalse(obj, sourcev1.SourceVerifiedCondition, e.Reason, e.Err.Error()) return sreconcile.ResultEmpty, e diff --git a/controllers/gitrepository_controller_test.go b/controllers/gitrepository_controller_test.go index 7b6aeba35..e7daac2b7 100644 --- a/controllers/gitrepository_controller_test.go +++ b/controllers/gitrepository_controller_test.go @@ -1209,7 +1209,7 @@ func TestGitRepositoryReconciler_verifyCommitSignature(t *testing.T) { }, wantErr: true, assertConditions: []metav1.Condition{ - *conditions.FalseCondition(sourcev1.SourceVerifiedCondition, "VerificationError", "PGP public keys secret error: secrets \"none-existing\" not found"), + *conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationFailedReason, "PGP public keys secret error: secrets \"none-existing\" not found"), }, }, { diff --git a/controllers/helmchart_controller.go b/controllers/helmchart_controller.go index 3fa0c0271..820ffe56f 100644 --- a/controllers/helmchart_controller.go +++ b/controllers/helmchart_controller.go @@ -73,6 +73,7 @@ var helmChartReadyCondition = summarize.Conditions{ sourcev1.FetchFailedCondition, sourcev1.StorageOperationFailedCondition, sourcev1.ArtifactOutdatedCondition, + sourcev1.SourceVerifiedCondition, meta.ReadyCondition, meta.ReconcilingCondition, meta.StalledCondition, @@ -82,6 +83,7 @@ var helmChartReadyCondition = summarize.Conditions{ sourcev1.FetchFailedCondition, sourcev1.StorageOperationFailedCondition, sourcev1.ArtifactOutdatedCondition, + sourcev1.SourceVerifiedCondition, meta.StalledCondition, meta.ReconcilingCondition, }, @@ -467,13 +469,23 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj * opts.VersionMetadata = strconv.FormatInt(obj.Generation, 10) } + keyring, err := r.getProvenanceKeyring(ctx, obj) + if err != nil { + e := &serror.Event{ + Err: fmt.Errorf("failed to get public key for chart signature verification: %w", err), + Reason: sourcev1.VerificationFailedReason, + } + conditions.MarkFalse(obj, sourcev1.SourceVerifiedCondition, e.Reason, e.Err.Error()) + return sreconcile.ResultEmpty, e + } + opts.Keyring = keyring + // Build the chart ref := chart.RemoteReference{Name: obj.Spec.Chart, Version: obj.Spec.Version} build, err := cb.Build(ctx, ref, util.TempPathForObj("", ".tgz", obj), opts) if err != nil { return sreconcile.ResultEmpty, err } - *b = *build return sreconcile.ResultSuccess, nil } @@ -590,6 +602,16 @@ func (r *HelmChartReconciler) buildFromTarballArtifact(ctx context.Context, obj } opts.VersionMetadata += strconv.FormatInt(obj.Generation, 10) } + keyring, err := r.getProvenanceKeyring(ctx, obj) + if err != nil { + e := &serror.Event{ + Err: fmt.Errorf("failed to get public key for chart signature verification: %w", err), + Reason: sourcev1.VerificationFailedReason, + } + conditions.MarkFalse(obj, sourcev1.SourceVerifiedCondition, e.Reason, e.Err.Error()) + return sreconcile.ResultEmpty, e + } + opts.Keyring = keyring // Build chart cb := chart.NewLocalBuilder(dm) @@ -670,6 +692,19 @@ func (r *HelmChartReconciler) reconcileArtifact(ctx context.Context, obj *source return sreconcile.ResultEmpty, e } + // the provenance file artifact is not recorded, but it shadows the HelmChart artifact + // under the assumption that the file is always available at "chart.tgz.prov" + if b.ProvFilePath != "" { + provArtifact := r.Storage.NewArtifactFor(obj.Kind, obj.GetObjectMeta(), b.Version, fmt.Sprintf("%s-%s.tgz.prov", b.Name, b.Version)) + if err = r.Storage.CopyFromPath(&provArtifact, b.ProvFilePath); err != nil { + e := &serror.Event{ + Err: fmt.Errorf("unable to copy Helm chart provenance file to storage: %w", err), + Reason: sourcev1.ArchiveOperationFailedReason, + } + conditions.MarkTrue(obj, sourcev1.StorageOperationFailedCondition, e.Reason, e.Err.Error()) + } + } + // Record it on the object obj.Status.Artifact = artifact.DeepCopy() obj.Status.ObservedChartName = b.Name @@ -763,8 +798,18 @@ func (r *HelmChartReconciler) garbageCollect(ctx context.Context, obj *sourcev1. obj.Status.Artifact = nil return nil } + if obj.GetArtifact() != nil { - if deleted, err := r.Storage.RemoveAllButCurrent(*obj.GetArtifact()); err != nil { + localPath := r.Storage.LocalPath(*obj.GetArtifact()) + provFilePath := localPath + ".prov" + dir := filepath.Dir(localPath) + callback := func(path string, info os.FileInfo) bool { + if path != localPath && path != provFilePath && info.Mode()&os.ModeSymlink != os.ModeSymlink { + return true + } + return false + } + if deleted, err := r.Storage.RemoveConditionally(dir, callback); err != nil { return &serror.Event{ Err: fmt.Errorf("garbage collection of old artifacts failed: %w", err), Reason: "GarbageCollectionFailed", @@ -991,6 +1036,15 @@ func observeChartBuild(obj *sourcev1.HelmChart, build *chart.Build, err error) { conditions.Delete(obj, sourcev1.BuildFailedCondition) } + if build.VerificationSignature != nil && build.ProvFilePath != "" { + var sigVerMsg strings.Builder + sigVerMsg.WriteString(fmt.Sprintf("verified chart hash: '%s'", build.VerificationSignature.FileHash)) + sigVerMsg.WriteString(fmt.Sprintf(" signed by: '%s'", build.VerificationSignature.Identity)) + sigVerMsg.WriteString(fmt.Sprintf(" with key: '%X'", build.VerificationSignature.KeyFingerprint)) + + conditions.MarkTrue(obj, sourcev1.SourceVerifiedCondition, sourcev1.ChartVerificationSucceededReason, sigVerMsg.String()) + } + if err != nil { var buildErr *chart.BuildError if ok := errors.As(err, &buildErr); !ok { @@ -1021,3 +1075,26 @@ func reasonForBuild(build *chart.Build) string { } return sourcev1.ChartPullSucceededReason } + +func (r *HelmChartReconciler) getProvenanceKeyring(ctx context.Context, chart *sourcev1.HelmChart) ([]byte, error) { + if chart.Spec.VerificationKeyring == nil { + conditions.Delete(chart, sourcev1.SourceVerifiedCondition) + return nil, nil + } + name := types.NamespacedName{ + Namespace: chart.GetNamespace(), + Name: chart.Spec.VerificationKeyring.SecretRef.Name, + } + var secret corev1.Secret + err := r.Client.Get(ctx, name, &secret) + if err != nil { + return nil, err + } + key := chart.Spec.VerificationKeyring.Key + val, ok := secret.Data[key] + if !ok { + err = fmt.Errorf("secret doesn't contain the advertised verification keyring name %s", key) + return nil, err + } + return val, nil +} diff --git a/controllers/helmchart_controller_test.go b/controllers/helmchart_controller_test.go index 43d568b85..832277949 100644 --- a/controllers/helmchart_controller_test.go +++ b/controllers/helmchart_controller_test.go @@ -52,6 +52,8 @@ import ( sreconcile "github.com/fluxcd/source-controller/internal/reconcile" ) +const publicKeyFileName = "pub.gpg" + func TestHelmChartReconciler_Reconcile(t *testing.T) { g := NewWithT(t) @@ -163,6 +165,140 @@ func TestHelmChartReconciler_Reconcile(t *testing.T) { }, timeout).Should(BeTrue()) } +func TestHelmChartReconciler_ReconcileWithSigVerification(t *testing.T) { + g := NewWithT(t) + + const ( + chartName = "helmchart" + chartVersion = "0.2.0" + chartPath = "testdata/charts/helmchart" + ) + + server, err := helmtestserver.NewTempHelmServer() + g.Expect(err).NotTo(HaveOccurred()) + defer os.RemoveAll(server.Root()) + + publicKeyPath := fmt.Sprintf("%s/%s", server.Root(), publicKeyFileName) + + g.Expect(server.PackageSignedChartWithVersion(chartPath, chartVersion, publicKeyPath)).To(Succeed()) + g.Expect(server.GenerateIndex()).To(Succeed()) + + server.Start() + defer server.Stop() + + ns, err := testEnv.CreateNamespace(ctx, "helmchart") + g.Expect(err).ToNot(HaveOccurred()) + defer func() { g.Expect(testEnv.Delete(ctx, ns)).To(Succeed()) }() + + repository := &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "helmrepository-", + Namespace: ns.Name, + }, + Spec: sourcev1.HelmRepositorySpec{ + URL: server.URL(), + }, + } + g.Expect(testEnv.CreateAndWait(ctx, repository)).To(Succeed()) + + keyring, err := os.ReadFile(publicKeyPath) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(keyring).ToNot(BeEmpty()) + + keyringSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "keyring-secret", + Namespace: ns.Name, + }, + Data: map[string][]byte{ + publicKeyFileName: keyring, + }, + } + g.Expect(testEnv.CreateAndWait(ctx, keyringSecret)).To(Succeed()) + + obj := &sourcev1.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "helmrepository-reconcile-", + Namespace: ns.Name, + }, + Spec: sourcev1.HelmChartSpec{ + Chart: chartName, + Version: chartVersion, + SourceRef: sourcev1.LocalHelmChartSourceReference{ + Kind: sourcev1.HelmRepositoryKind, + Name: repository.Name, + }, + VerificationKeyring: &sourcev1.VerificationKeyring{ + SecretRef: meta.LocalObjectReference{ + Name: keyringSecret.Name, + }, + Key: publicKeyFileName, + }, + }, + } + g.Expect(testEnv.Create(ctx, obj)).To(Succeed()) + + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + // Wait for finalizer to be set + g.Eventually(func() bool { + if err := testEnv.Get(ctx, key, obj); err != nil { + return false + } + return len(obj.Finalizers) > 0 + }, timeout).Should(BeTrue()) + + // Wait for HelmChart to be Ready + g.Eventually(func() bool { + if err := testEnv.Get(ctx, key, obj); err != nil { + return false + } + if !conditions.IsReady(obj) || obj.Status.Artifact == nil { + return false + } + readyCondition := conditions.Get(obj, meta.ReadyCondition) + return obj.Generation == readyCondition.ObservedGeneration && + obj.Generation == obj.Status.ObservedGeneration + }, timeout).Should(BeTrue()) + + // Check if the object status is valid. + condns := &status.Conditions{NegativePolarity: helmChartReadyCondition.NegativePolarity} + checker := status.NewChecker(testEnv.Client, testEnv.GetScheme(), condns) + checker.CheckErr(ctx, obj) + + // kstatus client conformance check. + u, err := patch.ToUnstructured(obj) + g.Expect(err).ToNot(HaveOccurred()) + res, err := kstatus.Compute(u) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(res.Status).To(Equal(kstatus.CurrentStatus)) + + // Patch the object with reconcile request annotation. + patchHelper, err := patch.NewHelper(obj, testEnv.Client) + g.Expect(err).ToNot(HaveOccurred()) + annotations := map[string]string{ + meta.ReconcileRequestAnnotation: "now", + } + obj.SetAnnotations(annotations) + g.Expect(patchHelper.Patch(ctx, obj)).ToNot(HaveOccurred()) + g.Eventually(func() bool { + if err := testEnv.Get(ctx, key, obj); err != nil { + return false + } + return obj.Status.LastHandledReconcileAt == "now" + }, timeout).Should(BeTrue()) + + g.Expect(testEnv.Delete(ctx, obj)).To(Succeed()) + + // Wait for HelmChart to be deleted + g.Eventually(func() bool { + if err := testEnv.Get(ctx, key, obj); err != nil { + return apierrors.IsNotFound(err) + } + return false + }, timeout).Should(BeTrue()) +} + func TestHelmChartReconciler_reconcileStorage(t *testing.T) { tests := []struct { name string @@ -183,10 +319,18 @@ func TestHelmChartReconciler_reconcileStorage(t *testing.T) { Path: fmt.Sprintf("/reconcile-storage/%s.txt", v), Revision: v, } + provArtifact := &sourcev1.Artifact{ + Path: fmt.Sprintf("/reconcile-storage/%s.txt.prov", v), + Revision: v, + } + if err := testStorage.MkdirAll(*obj.Status.Artifact); err != nil { return err } - if err := testStorage.AtomicWriteFile(obj.Status.Artifact, strings.NewReader(v), 0644); err != nil { + if err := testStorage.AtomicWriteFile(obj.Status.Artifact, strings.NewReader(v), 0o644); err != nil { + return err + } + if err := testStorage.AtomicWriteFile(provArtifact, strings.NewReader(v), 0o644); err != nil { return err } } @@ -204,6 +348,9 @@ func TestHelmChartReconciler_reconcileStorage(t *testing.T) { "/reconcile-storage/c.txt", "!/reconcile-storage/b.txt", "!/reconcile-storage/a.txt", + "/reconcile-storage/c.txt.prov", + "!/reconcile-storage/b.txt.prov", + "!/reconcile-storage/a.txt.prov", }, want: sreconcile.ResultSuccess, }, @@ -237,7 +384,7 @@ func TestHelmChartReconciler_reconcileStorage(t *testing.T) { if err := testStorage.MkdirAll(*obj.Status.Artifact); err != nil { return err } - if err := testStorage.AtomicWriteFile(obj.Status.Artifact, strings.NewReader("file"), 0644); err != nil { + if err := testStorage.AtomicWriteFile(obj.Status.Artifact, strings.NewReader("file"), 0o644); err != nil { return err } return nil @@ -311,14 +458,19 @@ func TestHelmChartReconciler_reconcileSource(t *testing.T) { } g.Expect(storage.Archive(gitArtifact, "testdata/charts", nil)).To(Succeed()) + keyring, err := os.ReadFile("testdata/charts/pub.gpg") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(keyring).ToNot(BeEmpty()) + tests := []struct { - name string - source sourcev1.Source - beforeFunc func(obj *sourcev1.HelmChart) - want sreconcile.Result - wantErr error - assertFunc func(g *WithT, build chart.Build, obj sourcev1.HelmChart) - cleanFunc func(g *WithT, build *chart.Build) + name string + source sourcev1.Source + keyringSecret *corev1.Secret + beforeFunc func(obj *sourcev1.HelmChart) + want sreconcile.Result + wantErr error + assertFunc func(g *WithT, build chart.Build, obj sourcev1.HelmChart) + cleanFunc func(g *WithT, build *chart.Build) }{ { name: "Observes Artifact revision and build result", @@ -354,6 +506,59 @@ func TestHelmChartReconciler_reconcileSource(t *testing.T) { g.Expect(os.Remove(build.Path)).To(Succeed()) }, }, + { + name: "Observes Artifact revision and build result with valid signature", + source: &sourcev1.GitRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gitrepository", + Namespace: "default", + }, + Status: sourcev1.GitRepositoryStatus{ + Artifact: gitArtifact, + }, + }, + keyringSecret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "keyring-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + publicKeyFileName: keyring, + }, + }, + beforeFunc: func(obj *sourcev1.HelmChart) { + obj.Spec.Chart = "testdata/charts/helmchart-0.1.0.tgz" + obj.Spec.SourceRef = sourcev1.LocalHelmChartSourceReference{ + Name: "gitrepository", + Kind: sourcev1.GitRepositoryKind, + } + obj.Spec.VerificationKeyring = &sourcev1.VerificationKeyring{ + SecretRef: meta.LocalObjectReference{ + Name: "keyring-secret", + }, + Key: publicKeyFileName, + } + }, + want: sreconcile.ResultSuccess, + assertFunc: func(g *WithT, build chart.Build, obj sourcev1.HelmChart) { + g.Expect(build.Complete()).To(BeTrue()) + g.Expect(build.Name).To(Equal("helmchart")) + g.Expect(build.Version).To(Equal("0.1.0")) + g.Expect(build.Path).To(BeARegularFile()) + g.Expect(build.VerificationSignature).ToNot(BeNil()) + g.Expect(build.ProvFilePath).To(BeARegularFile()) + + g.Expect(obj.Status.ObservedSourceArtifactRevision).To(Equal(gitArtifact.Revision)) + g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ + *conditions.TrueCondition(sourcev1.ArtifactOutdatedCondition, "NewChart", "pulled 'helmchart' chart with version '0.1.0'"), + *conditions.TrueCondition(sourcev1.SourceVerifiedCondition, sourcev1.ChartVerificationSucceededReason, "verified chart hash: 'sha256:007c7b7446eebcb18caeffe9898a3356ba1795f54df40ad39cfcc7382874a10a' signed by: 'TestUser' with key: '943CB5929ECDA2B5B5EC88BC7035BA97D32A87C1'"), + })) + }, + cleanFunc: func(g *WithT, build *chart.Build) { + g.Expect(os.Remove(build.Path)).To(Succeed()) + g.Expect(os.Remove(build.ProvFilePath)).To(Succeed()) + }, + }, { name: "Error on unavailable source", beforeFunc: func(obj *sourcev1.HelmChart) { @@ -458,6 +663,9 @@ func TestHelmChartReconciler_reconcileSource(t *testing.T) { if tt.source != nil { clientBuilder.WithRuntimeObjects(tt.source) } + if tt.keyringSecret != nil { + clientBuilder.WithRuntimeObjects(tt.keyringSecret) + } r := &HelmChartReconciler{ Client: clientBuilder.Build(), @@ -988,7 +1196,7 @@ func TestHelmChartReconciler_reconcileArtifact(t *testing.T) { }, afterFunc: func(t *WithT, obj *sourcev1.HelmChart) { t.Expect(obj.GetArtifact()).ToNot(BeNil()) - t.Expect(obj.GetArtifact().Checksum).To(Equal("bbdf96023c912c393b49d5238e227576ed0d20d1bb145d7476d817b80e20c11a")) + t.Expect(obj.GetArtifact().Checksum).To(Equal("007c7b7446eebcb18caeffe9898a3356ba1795f54df40ad39cfcc7382874a10a")) t.Expect(obj.GetArtifact().Revision).To(Equal("0.1.0")) t.Expect(obj.Status.URL).ToNot(BeEmpty()) t.Expect(obj.Status.ObservedChartName).To(Equal("helmchart")) @@ -1049,7 +1257,7 @@ func TestHelmChartReconciler_reconcileArtifact(t *testing.T) { }, afterFunc: func(t *WithT, obj *sourcev1.HelmChart) { t.Expect(obj.GetArtifact()).ToNot(BeNil()) - t.Expect(obj.GetArtifact().Checksum).To(Equal("bbdf96023c912c393b49d5238e227576ed0d20d1bb145d7476d817b80e20c11a")) + t.Expect(obj.GetArtifact().Checksum).To(Equal("007c7b7446eebcb18caeffe9898a3356ba1795f54df40ad39cfcc7382874a10a")) t.Expect(obj.GetArtifact().Revision).To(Equal("0.1.0")) t.Expect(obj.Status.URL).ToNot(BeEmpty()) t.Expect(obj.Status.ObservedChartName).To(Equal("helmchart")) @@ -1185,6 +1393,105 @@ func TestHelmChartReconciler_getHelmRepositorySecret(t *testing.T) { } } +func TestHelmChartReconciler_getVerificationKeyring(t *testing.T) { + secret := &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "keyring-secret", + Namespace: "foo", + }, + Data: map[string][]byte{ + publicKeyFileName: []byte("bar"), + }, + } + clientBuilder := fake.NewClientBuilder() + clientBuilder.WithObjects(secret) + + r := &HelmChartReconciler{ + Client: clientBuilder.Build(), + } + + tests := []struct { + name string + chart *sourcev1.HelmChart + want []byte + wantErr bool + }{ + { + name: "Existing secret reference", + chart: &sourcev1.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: secret.Namespace, + }, + Spec: sourcev1.HelmChartSpec{ + VerificationKeyring: &sourcev1.VerificationKeyring{ + SecretRef: meta.LocalObjectReference{ + Name: "keyring-secret", + }, + Key: publicKeyFileName, + }, + }, + }, + want: []byte("bar"), + }, + { + name: "Empty secret reference", + chart: &sourcev1.HelmChart{ + Spec: sourcev1.HelmChartSpec{ + VerificationKeyring: nil, + }, + }, + want: nil, + }, + { + name: "Error on client error", + chart: &sourcev1.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "different", + }, + Spec: sourcev1.HelmChartSpec{ + VerificationKeyring: &sourcev1.VerificationKeyring{ + SecretRef: meta.LocalObjectReference{ + Name: "keyring-secret", + }, + Key: publicKeyFileName, + }, + }, + }, + wantErr: true, + }, + { + name: "Error on invalid key", + chart: &sourcev1.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "foo", + }, + Spec: sourcev1.HelmChartSpec{ + VerificationKeyring: &sourcev1.VerificationKeyring{ + SecretRef: meta.LocalObjectReference{ + Name: "keyring-secret", + }, + Key: "invalid-key", + }, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got, err := r.getProvenanceKeyring(context.TODO(), tt.chart) + g.Expect(err != nil).To(Equal(tt.wantErr)) + g.Expect(got).To(Equal(tt.want)) + }) + } +} + func TestHelmChartReconciler_getSource(t *testing.T) { mocks := []client.Object{ &sourcev1.HelmRepository{ diff --git a/controllers/storage.go b/controllers/storage.go index 55f9a077c..54bf06409 100644 --- a/controllers/storage.go +++ b/controllers/storage.go @@ -53,6 +53,10 @@ type Storage struct { Timeout time.Duration `json:"timeout"` } +// removeFileCallback is a function which determines whether the +// provided file should be removed from the filesystem. +type removeFileCallback func(path string, info os.FileInfo) bool + // NewStorage creates the storage helper for a given path and hostname. func NewStorage(basePath string, hostname string, timeout time.Duration) (*Storage, error) { if f, err := os.Stat(basePath); os.IsNotExist(err) || !f.IsDir() { @@ -145,6 +149,36 @@ func (s *Storage) RemoveAllButCurrent(artifact sourcev1.Artifact) ([]string, err return deletedFiles, nil } +// RemoveConditionally walks through the provided dir and then deletes all files +// for which any of the callbacks return true. +func (s *Storage) RemoveConditionally(dir string, callbacks ...removeFileCallback) ([]string, error) { + deletedFiles := []string{} + var errors []string + _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + errors = append(errors, err.Error()) + return nil + } + for _, callback := range callbacks { + if callback(path, info) { + if err := os.Remove(path); err != nil { + errors = append(errors, info.Name()) + } else { + // Collect the successfully deleted file paths. + deletedFiles = append(deletedFiles, path) + } + break + } + } + return nil + }) + + if len(errors) > 0 { + return deletedFiles, fmt.Errorf("failed to remove files: %s", strings.Join(errors, " ")) + } + return deletedFiles, nil +} + // ArtifactExist returns a boolean indicating whether the v1beta1.Artifact exists in storage and is a regular file. func (s *Storage) ArtifactExist(artifact sourcev1.Artifact) bool { fi, err := os.Lstat(s.LocalPath(artifact)) diff --git a/controllers/testdata/charts/helmchart-0.1.0.tgz b/controllers/testdata/charts/helmchart-0.1.0.tgz index f64a32eee..186a1ddb7 100644 Binary files a/controllers/testdata/charts/helmchart-0.1.0.tgz and b/controllers/testdata/charts/helmchart-0.1.0.tgz differ diff --git a/controllers/testdata/charts/helmchart-0.1.0.tgz.prov b/controllers/testdata/charts/helmchart-0.1.0.tgz.prov new file mode 100644 index 000000000..7c44d8c25 --- /dev/null +++ b/controllers/testdata/charts/helmchart-0.1.0.tgz.prov @@ -0,0 +1,26 @@ +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +apiVersion: v2 +appVersion: 1.16.0 +description: A Helm chart for Kubernetes +name: helmchart +type: application +version: 0.1.0 + +... +files: + helmchart-0.1.0.tgz: sha256:007c7b7446eebcb18caeffe9898a3356ba1795f54df40ad39cfcc7382874a10a +-----BEGIN PGP SIGNATURE----- + +wsDcBAEBCgAQBQJiKwNBCRBwNbqX0yqHwQAACj8MABCY6mVrWaJdC64PbhTTonVE +97MZZpQBT+CZIRAecfkvcTeMTBeKh/yRwsSmjwo46eKOpNFJ1eQVHqVcKWLfBn3Z +AijuXTaISl8SnQyKPF2Z8n+YrYwh9OWPUX2CpUQstx+snSLDuv5ltWIgRlzfHAUN +hwzsgjs8bpHe8wZTgnASUVbcMMYQXCcovbXB6NATDLkZLHBWWEISicOl6VYLLl2D +kZg7LDcDKPcPmKJ6WtVurkyWXhK3jdYzlaOQWjs2nLIH/CdlmAygELuWexsOZAhY +MEauKEMoVzDQF5oaNA78AzlBLGogxao5fBYtAAHGb5tQdnVRUeSci+7IR0LHsS05 +YF/UnUF69GSESfoKIBvQuzex4BRCLBwayq6CSyrpZQ2+Vg4ARPo7LFg7Wy0zvC9Z +NxGnIeh1az9hltdzPgg6ZahPZB+eMF+t9ouAz9OZ3kxYUDmoE+Z+NqRWsPi27Cxk +CSw9EfJfDsputN/wj4NAxZKfqauMtS5sgaSgtrW+zA== +=mfBq +-----END PGP SIGNATURE----- \ No newline at end of file diff --git a/controllers/testdata/charts/pub.gpg b/controllers/testdata/charts/pub.gpg new file mode 100644 index 000000000..291f8abd8 Binary files /dev/null and b/controllers/testdata/charts/pub.gpg differ diff --git a/controllers/testdata/charts/sec.gpg b/controllers/testdata/charts/sec.gpg new file mode 100644 index 000000000..be43af3a0 Binary files /dev/null and b/controllers/testdata/charts/sec.gpg differ diff --git a/docs/api/source.md b/docs/api/source.md index 6f0d1621b..600aa4125 100644 --- a/docs/api/source.md +++ b/docs/api/source.md @@ -668,6 +668,20 @@ references to this object. NOTE: Not implemented, provisional as of https://github.com/fluxcd/flux2/pull/2092

+ + +verificationKeyring
+ + +VerificationKeyring + + + + +(Optional) +

VerificationKeyring for verifying the packaged chart’s signature using a provenance file.

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

+ + +verificationKeyring
+ + +VerificationKeyring + + + + +(Optional) +

VerificationKeyring for verifying the packaged chart’s signature using a provenance file.

+ + @@ -2251,6 +2279,53 @@ string Source is the interface that provides generic access to the Artifact and interval. It must be supported by all kinds of the source.toolkit.fluxcd.io API group.

+

VerificationKeyring +

+

+(Appears on: +HelmChartSpec) +

+

VerificationKeyring contains enough info to get the public GPG key to be used for verifying +the chart signature using a provenance file.

+
+
+ + + + + + + + + + + + + + + + + +
FieldDescription
+secretRef
+ + +github.com/fluxcd/pkg/apis/meta.LocalObjectReference + + +
+

SecretRef is a reference to the secret that contains the public GPG key.

+
+key
+ +string + +
+(Optional) +

Key in the SecretRef that contains the public keyring in legacy GPG format.

+
+
+

This page was automatically generated with gen-crd-api-reference-docs

diff --git a/internal/helm/chart/builder.go b/internal/helm/chart/builder.go index e3ce2207d..aaee045c6 100644 --- a/internal/helm/chart/builder.go +++ b/internal/helm/chart/builder.go @@ -104,6 +104,10 @@ 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 + + // Keyring can be set to the bytes of a public kering in legacy + // PGP format used for verifying a chart's signature using a provenance file. + Keyring []byte } // GetValuesFiles returns BuildOptions.ValuesFiles, except if it equals @@ -125,6 +129,13 @@ type Build struct { // Path is the absolute path to the packaged chart. // Can be empty, in which case a failure should be assumed. Path string + // ProvFilePath is the absolute path to a provenance file. + // It can be empty, in which case it should be assumed that the packaged + // chart is not verified. + ProvFilePath string + // VerificationSignature is populated when a chart's signature + // is successfully verified using its provenance file. + VerificationSignature *VerificationSignature // ValuesFiles is the list of files used to compose the chart's // default "values.yaml". ValuesFiles []string @@ -157,7 +168,6 @@ func (b *Build) Summary() string { if len(b.ValuesFiles) > 0 { s.WriteString(fmt.Sprintf(" and merged values files %v", b.ValuesFiles)) } - return s.String() } diff --git a/internal/helm/chart/builder_local.go b/internal/helm/chart/builder_local.go index 2710e41a9..c6060dec7 100644 --- a/internal/helm/chart/builder_local.go +++ b/internal/helm/chart/builder_local.go @@ -17,6 +17,7 @@ limitations under the License. package chart import ( + "bytes" "context" "fmt" "os" @@ -104,6 +105,8 @@ func (b *localChartBuilder) Build(ctx context.Context, ref Reference, p string, isChartDir := pathIsDir(localRef.Path) requiresPackaging := isChartDir || opts.VersionMetadata != "" || len(opts.GetValuesFiles()) != 0 + var provFilePath string + // If all the following is true, we do not need to package the chart: // - Chart name from cached chart matches resolved name // - Chart version from cached chart matches calculated version @@ -114,10 +117,20 @@ func (b *localChartBuilder) Build(ctx context.Context, ref Reference, p string, // and continue the build if err = curMeta.Validate(); err == nil { if result.Name == curMeta.Name && result.Version == curMeta.Version { + // We can only verify a cached chart with provenance file if we didn't + // package the chart ourselves, and instead stored it as is. + if !requiresPackaging && opts.Keyring != nil { + provFilePath = provenanceFilePath(opts.CachedChart) + ver, err := verifyChartWithProvFile(bytes.NewReader(opts.Keyring), opts.CachedChart, provFilePath) + if err != nil { + return nil, &BuildError{Reason: ErrProvenanceVerification, Err: err} + } + result.VerificationSignature = buildVerificationSig(ver) + result.ProvFilePath = provFilePath + } result.Path = opts.CachedChart result.ValuesFiles = opts.GetValuesFiles() result.Packaged = requiresPackaging - return result, nil } } @@ -130,6 +143,18 @@ func (b *localChartBuilder) Build(ctx context.Context, ref Reference, p string, if err = copyFileToPath(localRef.Path, p); err != nil { return result, &BuildError{Reason: ErrChartPull, Err: err} } + if opts.Keyring != nil { + provFilePath = provenanceFilePath(p) + if err = copyFileToPath(provenanceFilePath(localRef.Path), provFilePath); err != nil { + return result, &BuildError{Reason: ErrChartPull, Err: err} + } + ver, err := verifyChartWithProvFile(bytes.NewReader(opts.Keyring), localRef.Path, provFilePath) + if err != nil { + return nil, err + } + result.ProvFilePath = provFilePath + result.VerificationSignature = buildVerificationSig(ver) + } result.Path = p return result, nil } diff --git a/internal/helm/chart/builder_local_test.go b/internal/helm/chart/builder_local_test.go index cff5f180f..a3543a605 100644 --- a/internal/helm/chart/builder_local_test.go +++ b/internal/helm/chart/builder_local_test.go @@ -40,6 +40,11 @@ func TestLocalBuilder_Build(t *testing.T) { chartB, err := os.ReadFile("./../testdata/charts/helmchart-0.1.0.tgz") g.Expect(err).ToNot(HaveOccurred()) g.Expect(chartB).ToNot(BeEmpty()) + + keyring, err := os.ReadFile("./../testdata/charts/pub.gpg") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(keyring).ToNot(BeEmpty()) + mockRepo := func() *repository.ChartRepository { return &repository.ChartRepository{ Client: &mockGetter{ @@ -105,6 +110,7 @@ func TestLocalBuilder_Build(t *testing.T) { { name: "already packaged chart", reference: LocalReference{Path: "./../testdata/charts/helmchart-0.1.0.tgz"}, + buildOpts: BuildOptions{Keyring: keyring}, wantVersion: "0.1.0", wantPackaged: false, }, @@ -221,6 +227,10 @@ fullnameOverride: "full-foo-name-override"`), g.Expect(err).ToNot(HaveOccurred()) g.Expect(cb.Packaged).To(Equal(tt.wantPackaged), "unexpected Build.Packaged value") g.Expect(cb.Path).ToNot(BeEmpty(), "empty Build.Path") + if tt.buildOpts.Keyring != nil { + g.Expect(cb.ProvFilePath).ToNot(BeEmpty(), "empty Build.ProvFilePath") + g.Expect(cb.VerificationSignature).ToNot(BeNil(), "nil Build.VerificationSignature") + } // Load the resulting chart and verify the values. resultChart, err := loader.Load(cb.Path) @@ -243,6 +253,10 @@ func TestLocalBuilder_Build_CachedChart(t *testing.T) { reference := LocalReference{Path: "./../testdata/charts/helmchart"} + keyring, err := os.ReadFile("./../testdata/charts/pub.gpg") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(keyring).ToNot(BeEmpty()) + dm := NewDependencyManager() b := NewLocalBuilder(dm) @@ -272,6 +286,35 @@ func TestLocalBuilder_Build_CachedChart(t *testing.T) { g.Expect(cb.Path).To(Equal(targetPath2)) } +func TestLocalBuilder_VerifyCachedChartSig(t *testing.T) { + g := NewWithT(t) + + reference := LocalReference{Path: "./../testdata/charts/helmchart-0.1.0.tgz"} + + keyring, err := os.ReadFile("./../testdata/charts/pub.gpg") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(keyring).ToNot(BeEmpty()) + + dm := NewDependencyManager() + b := NewLocalBuilder(dm) + + tmpDir, err := os.MkdirTemp("", "local-chart-") + g.Expect(err).ToNot(HaveOccurred()) + defer os.RemoveAll(tmpDir) + + buildOpts := BuildOptions{} + buildOpts.Keyring = keyring + + buildOpts.CachedChart = "./../testdata/charts/helmchart-0.1.0.tgz" + targetPath2 := filepath.Join(tmpDir, "chart2.tgz") + defer os.RemoveAll(targetPath2) + + cb, err := b.Build(context.TODO(), reference, targetPath2, buildOpts) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cb.ProvFilePath).ToNot(BeEmpty(), "empty Build.ProvFilePath") + g.Expect(cb.VerificationSignature).ToNot(BeNil(), "nil Build.VerificationSignature") +} + func Test_mergeFileValues(t *testing.T) { tests := []struct { name string diff --git a/internal/helm/chart/builder_remote.go b/internal/helm/chart/builder_remote.go index 778efd253..c16e56e02 100644 --- a/internal/helm/chart/builder_remote.go +++ b/internal/helm/chart/builder_remote.go @@ -17,9 +17,9 @@ limitations under the License. package chart import ( + "bytes" "context" "fmt" - "io" "os" "path/filepath" @@ -33,6 +33,7 @@ import ( "github.com/fluxcd/source-controller/internal/fs" "github.com/fluxcd/source-controller/internal/helm/repository" + "github.com/fluxcd/source-controller/internal/util" ) type remoteChartBuilder struct { @@ -105,6 +106,8 @@ func (b *remoteChartBuilder) Build(_ context.Context, ref Reference, p string, o requiresPackaging := len(opts.GetValuesFiles()) != 0 || opts.VersionMetadata != "" + var provFilePath string + // If all the following is true, we do not need to download and/or build the chart: // - Chart name from cached chart matches resolved name // - Chart version from cached chart matches calculated version @@ -115,6 +118,17 @@ func (b *remoteChartBuilder) Build(_ context.Context, ref Reference, p string, o // and continue the build if err = curMeta.Validate(); err == nil { if result.Name == curMeta.Name && result.Version == curMeta.Version { + // We can only verify a cached chart with provenance file if we didn't + // package the chart ourselves, and instead stored it as is. + if !requiresPackaging && opts.Keyring != nil { + provFilePath = provenanceFilePath(opts.CachedChart) + ver, err := verifyChartWithProvFile(bytes.NewReader(opts.Keyring), opts.CachedChart, provFilePath) + if err != nil { + return nil, err + } + result.ProvFilePath = provFilePath + result.VerificationSignature = buildVerificationSig(ver) + } result.Path = opts.CachedChart result.ValuesFiles = opts.GetValuesFiles() result.Packaged = requiresPackaging @@ -130,11 +144,37 @@ func (b *remoteChartBuilder) Build(_ context.Context, ref Reference, p string, o err = fmt.Errorf("failed to download chart for remote reference: %w", err) return result, &BuildError{Reason: ErrChartPull, Err: err} } + // Deal with the underlying byte slice to avoid having to read the buffer multiple times. + chartBuf := res.Bytes() + + if opts.Keyring != nil { + provFilePath = provenanceFilePath(p) + err := b.remote.DownloadProvenanceFile(cv, provFilePath) + if err != nil { + err = fmt.Errorf("failed to download provenance file for remote reference: %w", err) + return nil, &BuildError{Reason: ErrChartPull, Err: err} + } + // Write the remote chart temporarily to verify it with provenance file. + // This is needed, since the verification will work only if the .tgz file is untampered. + // But we write the packaged chart to disk under a different name, so the provenance file + // will not be valid for this _new_ packaged chart. + chart, err := util.WriteToTempFile(chartBuf, fmt.Sprintf("%s-%s.tgz", cv.Name, cv.Version), true) + if err != nil { + return nil, err + } + defer os.Remove(chart.Name()) + ver, err := verifyChartWithProvFile(bytes.NewReader(opts.Keyring), chart.Name(), provFilePath) + if err != nil { + return nil, err + } + result.ProvFilePath = provFilePath + result.VerificationSignature = buildVerificationSig(ver) + } // Use literal chart copy from remote if no custom values files options are // set or version metadata isn't set. if !requiresPackaging { - if err = validatePackageAndWriteToPath(res, p); err != nil { + if err = validatePackageAndWriteToPath(chartBuf, p); err != nil { return nil, &BuildError{Reason: ErrChartPull, Err: err} } result.Path = p @@ -143,7 +183,7 @@ func (b *remoteChartBuilder) Build(_ context.Context, ref Reference, p string, o // Load the chart and merge chart values var chart *helmchart.Chart - if chart, err = loader.LoadArchive(res); err != nil { + if chart, err = loader.LoadArchive(bytes.NewBuffer(chartBuf)); err != nil { err = fmt.Errorf("failed to load downloaded chart: %w", err) return result, &BuildError{Reason: ErrChartPackage, Err: err} } @@ -166,6 +206,7 @@ func (b *remoteChartBuilder) Build(_ context.Context, ref Reference, p string, o if err = packageToPath(chart, p); err != nil { return nil, &BuildError{Reason: ErrChartPackage, Err: err} } + result.Path = p result.Packaged = true return result, nil @@ -202,18 +243,12 @@ func mergeChartValues(chart *helmchart.Chart, paths []string) (map[string]interf // validatePackageAndWriteToPath atomically writes the packaged chart from reader // to out while validating it by loading the chart metadata from the archive. -func validatePackageAndWriteToPath(reader io.Reader, out string) error { - tmpFile, err := os.CreateTemp("", filepath.Base(out)) - if err != nil { - return fmt.Errorf("failed to create temporary file for chart: %w", err) - } +func validatePackageAndWriteToPath(b []byte, out string) error { + tmpFile, err := util.WriteToTempFile(b, out, false) defer os.Remove(tmpFile.Name()) - if _, err = tmpFile.ReadFrom(reader); err != nil { - _ = tmpFile.Close() - return fmt.Errorf("failed to write chart to file: %w", err) - } - if err = tmpFile.Close(); err != nil { - return err + + if err != nil { + return fmt.Errorf("failed to write packaged chart to temp file: %w", err) } meta, err := LoadChartMetadataFromArchive(tmpFile.Name()) if err != nil { diff --git a/internal/helm/chart/builder_remote_test.go b/internal/helm/chart/builder_remote_test.go index 015b1bdac..9afd521b9 100644 --- a/internal/helm/chart/builder_remote_test.go +++ b/internal/helm/chart/builder_remote_test.go @@ -36,9 +36,10 @@ import ( // mockIndexChartGetter returns specific response for index and chart queries. type mockIndexChartGetter struct { - IndexResponse []byte - ChartResponse []byte - requestedURL string + IndexResponse []byte + ChartResponse []byte + ProvenanceFileResponse []byte + requestedURL string } func (g *mockIndexChartGetter) Get(u string, _ ...helmgetter.Option) (*bytes.Buffer, error) { @@ -47,6 +48,9 @@ func (g *mockIndexChartGetter) Get(u string, _ ...helmgetter.Option) (*bytes.Buf if strings.HasSuffix(u, "index.yaml") { r = g.IndexResponse } + if strings.HasSuffix(u, ".prov") { + r = g.ProvenanceFileResponse + } return bytes.NewBuffer(r), nil } @@ -68,12 +72,18 @@ entries: - urls: - https://example.com/grafana.tgz description: string - version: 6.17.4 + version: 0.1.0 + name: helmchart `) + provFile, err := os.ReadFile("./../testdata/charts/helmchart-0.1.0.tgz.prov") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(provFile).ToNot(BeEmpty()) + mockGetter := &mockIndexChartGetter{ - IndexResponse: index, - ChartResponse: chartGrafana, + IndexResponse: index, + ChartResponse: chartGrafana, + ProvenanceFileResponse: provFile, } mockRepo := func() *repository.ChartRepository { @@ -84,6 +94,10 @@ entries: } } + keyring, err := os.ReadFile("./../testdata/charts/pub.gpg") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(keyring).ToNot(BeEmpty()) + tests := []struct { name string reference Reference @@ -124,16 +138,22 @@ entries: wantErr: "Invalid Metadata string", }, { - name: "with version metadata", - reference: RemoteReference{Name: "grafana"}, - repository: mockRepo(), - buildOpts: BuildOptions{VersionMetadata: "foo"}, - wantVersion: "6.17.4+foo", + name: "with version metadata", + reference: RemoteReference{Name: "grafana"}, + repository: mockRepo(), + buildOpts: BuildOptions{ + VersionMetadata: "foo", + Keyring: keyring, + }, + wantVersion: "0.1.0+foo", wantPackaged: true, }, { - name: "default values", - reference: RemoteReference{Name: "grafana"}, + name: "default values", + reference: RemoteReference{Name: "grafana"}, + buildOpts: BuildOptions{ + Keyring: keyring, + }, repository: mockRepo(), wantVersion: "0.1.0", wantValues: chartutil.Values{ @@ -145,9 +165,10 @@ entries: reference: RemoteReference{Name: "grafana"}, buildOpts: BuildOptions{ ValuesFiles: []string{"a.yaml", "b.yaml", "c.yaml"}, + Keyring: keyring, }, repository: mockRepo(), - wantVersion: "6.17.4", + wantVersion: "0.1.0", wantValues: chartutil.Values{ "a": "b", "b": "d", @@ -184,6 +205,8 @@ entries: g.Expect(err).ToNot(HaveOccurred()) g.Expect(cb.Packaged).To(Equal(tt.wantPackaged), "unexpected Build.Packaged value") g.Expect(cb.Path).ToNot(BeEmpty(), "empty Build.Path") + g.Expect(cb.ProvFilePath).ToNot(BeEmpty(), "empty Build.ProvFilePath") + g.Expect(cb.VerificationSignature).ToNot(BeNil(), "nil Build.VerificationSignature") // Load the resulting chart and verify the values. resultChart, err := loader.Load(cb.Path) @@ -204,6 +227,14 @@ func TestRemoteBuilder_Build_CachedChart(t *testing.T) { g.Expect(err).ToNot(HaveOccurred()) g.Expect(chartGrafana).ToNot(BeEmpty()) + provFile, err := os.ReadFile("./../testdata/charts/helmchart-0.1.0.tgz.prov") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(provFile).ToNot(BeEmpty()) + + keyring, err := os.ReadFile("./../testdata/charts/pub.gpg") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(keyring).ToNot(BeEmpty()) + index := []byte(` apiVersion: v1 entries: @@ -216,8 +247,9 @@ entries: `) mockGetter := &mockIndexChartGetter{ - IndexResponse: index, - ChartResponse: chartGrafana, + IndexResponse: index, + ChartResponse: chartGrafana, + ProvenanceFileResponse: provFile, } mockRepo := func() *repository.ChartRepository { return &repository.ChartRepository{ @@ -242,11 +274,16 @@ entries: defer os.RemoveAll(tmpDir) // Build first time. - targetPath := filepath.Join(tmpDir, "chart1.tgz") + // The file name should be the same as the actual chart in testdata, so that + // we can verify it's signature using the provenance file. + targetPath := filepath.Join(tmpDir, "helmchart-0.1.0.tgz") defer os.RemoveAll(targetPath) buildOpts := BuildOptions{} + buildOpts.Keyring = keyring cb, err := b.Build(context.TODO(), reference, targetPath, buildOpts) g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cb.ProvFilePath).ToNot(BeEmpty(), "empty Build.ProvFilePath") + g.Expect(cb.VerificationSignature).ToNot(BeNil(), "nil Build.VerificationSignature") // Set the result as the CachedChart for second build. buildOpts.CachedChart = cb.Path @@ -257,12 +294,16 @@ entries: cb, err = b.Build(context.TODO(), reference, targetPath2, buildOpts) g.Expect(err).ToNot(HaveOccurred()) g.Expect(cb.Path).To(Equal(targetPath)) + g.Expect(cb.ProvFilePath).ToNot(BeEmpty(), "empty Build.ProvFilePath") + g.Expect(cb.VerificationSignature).ToNot(BeNil(), "nil Build.VerificationSignature") // Rebuild with build option Force. buildOpts.Force = true cb, err = b.Build(context.TODO(), reference, targetPath2, buildOpts) g.Expect(err).ToNot(HaveOccurred()) g.Expect(cb.Path).To(Equal(targetPath2)) + g.Expect(cb.ProvFilePath).ToNot(BeEmpty(), "empty Build.ProvFilePath") + g.Expect(cb.VerificationSignature).ToNot(BeNil(), "nil Build.VerificationSignature") } func Test_mergeChartValues(t *testing.T) { @@ -346,9 +387,8 @@ func Test_validatePackageAndWriteToPath(t *testing.T) { g.Expect(err).ToNot(HaveOccurred()) defer os.RemoveAll(tmpDir) - validF, err := os.Open("./../testdata/charts/helmchart-0.1.0.tgz") + validF, err := os.ReadFile("./../testdata/charts/helmchart-0.1.0.tgz") g.Expect(err).ToNot(HaveOccurred()) - defer validF.Close() chartPath := filepath.Join(tmpDir, "chart.tgz") defer os.Remove(chartPath) @@ -356,9 +396,8 @@ func Test_validatePackageAndWriteToPath(t *testing.T) { g.Expect(err).ToNot(HaveOccurred()) g.Expect(chartPath).To(BeARegularFile()) - emptyF, err := os.Open("./../testdata/charts/empty.tgz") + emptyF, err := os.ReadFile("./../testdata/charts/empty.tgz") g.Expect(err).ToNot(HaveOccurred()) - defer emptyF.Close() err = validatePackageAndWriteToPath(emptyF, filepath.Join(tmpDir, "out.tgz")) g.Expect(err).To(HaveOccurred()) } diff --git a/internal/helm/chart/errors.go b/internal/helm/chart/errors.go index 5b3a5bec0..c8c29ff0e 100644 --- a/internal/helm/chart/errors.go +++ b/internal/helm/chart/errors.go @@ -77,11 +77,12 @@ func IsPersistentBuildErrorReason(err error) bool { } var ( - ErrChartReference = BuildErrorReason{Reason: "InvalidChartReference", Summary: "invalid chart reference"} - ErrChartPull = BuildErrorReason{Reason: "ChartPullError", Summary: "chart pull error"} - ErrChartMetadataPatch = BuildErrorReason{Reason: "MetadataPatchError", Summary: "chart metadata patch error"} - 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"} - ErrUnknown = BuildErrorReason{Reason: "Unknown", Summary: "unknown build error"} + ErrChartReference = BuildErrorReason{Reason: "InvalidChartReference", Summary: "invalid chart reference"} + ErrChartPull = BuildErrorReason{Reason: "ChartPullError", Summary: "chart pull error"} + ErrProvenanceVerification = BuildErrorReason{Reason: "ProvenanceVerificationError", Summary: "provenance file verification error"} + ErrChartMetadataPatch = BuildErrorReason{Reason: "MetadataPatchError", Summary: "chart metadata patch error"} + 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"} + ErrUnknown = BuildErrorReason{Reason: "Unknown", Summary: "unknown build error"} ) diff --git a/internal/helm/chart/verify.go b/internal/helm/chart/verify.go new file mode 100644 index 000000000..30cf02822 --- /dev/null +++ b/internal/helm/chart/verify.go @@ -0,0 +1,95 @@ +/* +Copyright 2021 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package chart + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "golang.org/x/crypto/openpgp" + "helm.sh/helm/v3/pkg/provenance" +) + +// Ref: https://github.com/helm/helm/blob/v3.8.0/pkg/downloader/chart_downloader.go#L328 +// modified to accept a custom provenance file path and an actual keyring instead of a +// path to the file containing the keyring. +func verifyChartWithProvFile(keyring io.Reader, chartPath, provFilePath string) (*provenance.Verification, error) { + switch fi, err := os.Stat(chartPath); { + case err != nil: + return nil, err + case fi.IsDir(): + return nil, fmt.Errorf("unpacked charts cannot be verified") + case !isTar(chartPath): + return nil, fmt.Errorf("chart must be a tgz file") + } + + if provFilePath == "" { + provFilePath = chartPath + ".prov" + } + + if _, err := os.Stat(provFilePath); err != nil { + return nil, fmt.Errorf("could not load provenance file %s: %w", provFilePath, err) + } + + ring, err := openpgp.ReadKeyRing(keyring) + if err != nil { + return nil, err + } + + sig := &provenance.Signatory{KeyRing: ring} + verification, err := sig.Verify(chartPath, provFilePath) + if err != nil { + err = fmt.Errorf("failed to verify helm chart using provenance file: %w", err) + } + return verification, err +} + +// isTar tests whether the given file is a tar file. +func isTar(filename string) bool { + return strings.EqualFold(filepath.Ext(filename), ".tgz") +} + +// Returns the path of a provenance file related to a packaged chart by +// adding ".prov" at the end, as per the Helm convention. +func provenanceFilePath(path string) string { + return path + ".prov" +} + +// ref: https://github.com/helm/helm/blob/v3.8.0/pkg/action/verify.go#L47-L51 +type VerificationSignature struct { + Identity string + KeyFingerprint [20]byte + FileHash string +} + +func buildVerificationSig(ver *provenance.Verification) *VerificationSignature { + var verSig VerificationSignature + if ver != nil { + if ver.SignedBy != nil { + for name := range ver.SignedBy.Identities { + verSig.Identity = name + break + } + } + verSig.FileHash = ver.FileHash + verSig.KeyFingerprint = ver.SignedBy.PrimaryKey.Fingerprint + } + return &verSig +} diff --git a/internal/helm/chart/verify_test.go b/internal/helm/chart/verify_test.go new file mode 100644 index 000000000..86309714f --- /dev/null +++ b/internal/helm/chart/verify_test.go @@ -0,0 +1,18 @@ +package chart + +import ( + "os" + "testing" + + . "github.com/onsi/gomega" +) + +func Test_verifyChartWithProvFile(t *testing.T) { + g := NewWithT(t) + + keyring, err := os.Open("../testdata/charts/pub.gpg") + g.Expect(err).ToNot(HaveOccurred()) + ver, err := verifyChartWithProvFile(keyring, "../testdata/charts/helmchart-0.1.0.tgz", "../testdata/charts/helmchart-0.1.0.tgz.prov") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(ver).ToNot(BeNil()) +} diff --git a/internal/helm/repository/chart_repository.go b/internal/helm/repository/chart_repository.go index e8154dca0..40c9d362c 100644 --- a/internal/helm/repository/chart_repository.go +++ b/internal/helm/repository/chart_repository.go @@ -38,8 +38,10 @@ import ( "github.com/fluxcd/pkg/version" + "github.com/fluxcd/source-controller/internal/fs" "github.com/fluxcd/source-controller/internal/helm" "github.com/fluxcd/source-controller/internal/transport" + "github.com/fluxcd/source-controller/internal/util" ) var ErrNoChartIndex = errors.New("no chart index") @@ -189,6 +191,45 @@ func (r *ChartRepository) Get(name, ver string) (*repo.ChartVersion, error) { // and then attempts to download the chart using the Client and Options of the // ChartRepository. It returns a bytes.Buffer containing the chart data. func (r *ChartRepository) DownloadChart(chart *repo.ChartVersion) (*bytes.Buffer, error) { + u, err := r.resolveChartURL(chart) + if err != nil { + return nil, err + } + + t := transport.NewOrIdle(r.tlsConfig) + clientOpts := append(r.Options, getter.WithTransport(t)) + defer transport.Release(t) + + return r.Client.Get(u.String(), clientOpts...) +} + +func (r *ChartRepository) DownloadProvenanceFile(chart *repo.ChartVersion, path string) error { + u, err := r.resolveChartURL(chart) + if err != nil { + return err + } + t := transport.NewOrIdle(r.tlsConfig) + clientOpts := append(r.Options, getter.WithTransport(t)) + defer transport.Release(t) + + res, err := r.Client.Get(fmt.Sprintf("%s.prov", u.String()), clientOpts...) + if err != nil { + return err + } + tmpFile, err := util.WriteToTempFile(res.Bytes(), path, false) + defer os.Remove(tmpFile.Name()) + + if err != nil { + return fmt.Errorf("failed to write provenance file to temp file: %w", err) + } + + if err = fs.RenameWithFallback(tmpFile.Name(), path); err != nil { + return fmt.Errorf("failed to write provenance to file %s: %w", path, err) + } + return nil +} + +func (r *ChartRepository) resolveChartURL(chart *repo.ChartVersion) (*url.URL, error) { if len(chart.URLs) == 0 { return nil, fmt.Errorf("chart '%s' has no downloadable URLs", chart.Name) } @@ -217,11 +258,7 @@ func (r *ChartRepository) DownloadChart(chart *repo.ChartVersion) (*bytes.Buffer u.RawQuery = q.Encode() } - t := transport.NewOrIdle(r.tlsConfig) - clientOpts := append(r.Options, getter.WithTransport(t)) - defer transport.Release(t) - - return r.Client.Get(u.String(), clientOpts...) + return u, nil } // LoadIndexFromBytes loads Index from the given bytes. diff --git a/internal/helm/testdata/charts/helmchart-0.1.0.tgz b/internal/helm/testdata/charts/helmchart-0.1.0.tgz index 1ffdde531..622f95636 100644 Binary files a/internal/helm/testdata/charts/helmchart-0.1.0.tgz and b/internal/helm/testdata/charts/helmchart-0.1.0.tgz differ diff --git a/internal/helm/testdata/charts/helmchart-0.1.0.tgz.prov b/internal/helm/testdata/charts/helmchart-0.1.0.tgz.prov new file mode 100644 index 000000000..573d72a91 --- /dev/null +++ b/internal/helm/testdata/charts/helmchart-0.1.0.tgz.prov @@ -0,0 +1,26 @@ +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +apiVersion: v2 +appVersion: 1.16.0 +description: A Helm chart for Kubernetes +name: helmchart +type: application +version: 0.1.0 + +... +files: + helmchart-0.1.0.tgz: sha256:c6c5a41ff83f415e18d2ed8ab3a386021e3f1742ea3d1bc0ba759a09aaeb8f2a +-----BEGIN PGP SIGNATURE----- + +wsDcBAEBCgAQBQJiIwjxCRCDj6SUu5W1LwAAndgMAEDZsARwLEczJYw3lKYeftcy +lebfr81jxNS1a4J2DQvOEcltdA1MBHBoir3GoG53xjMWMYMKLUj0FQLQFoLwHTvf +zl5KkfMQnH85KL5TAbzm+Oiz/WiKYQ9cza5T+50WoXFVjdfoF6efZ6tOxV+FtS/o +toga+N8z4FtkhbuY0qQx4nxM2wRd/XZHPFO0LRx+Z8E5lghedLOD7ocV7kN/FD9p +0/MMZ5kpeLevfnp4GBYjZKxojH8eOFni7WPovHUts/QHvEnYxucNwej8OTIy699w +APJbwEV3BVwzjqgsfywQxH80JEpNGzCRUt5yfnXpF4IUxzPVM1z2tp+/leoHxkxw +yfhL8FVfUbWnWvqIMY8QK6zhkqy22jb4lFE7jPEUwVu+HQc6KTzkYhZQQ5fOHmoa +pYsCbAF/AMZ1U8yT6OljVF904yIiLohR7F6s/maEu6mCdy82sNjpCdasuThqe7k0 +Hv4m4NcrULqhvKyyAqp/XgWMPeNuFkq3wqBk1DxLTw== +=oQfE +-----END PGP SIGNATURE----- \ No newline at end of file diff --git a/internal/helm/testdata/charts/pub.gpg b/internal/helm/testdata/charts/pub.gpg new file mode 100644 index 000000000..5b711ded6 Binary files /dev/null and b/internal/helm/testdata/charts/pub.gpg differ diff --git a/internal/helm/testdata/charts/sec.gpg b/internal/helm/testdata/charts/sec.gpg new file mode 100644 index 000000000..9df8a2779 Binary files /dev/null and b/internal/helm/testdata/charts/sec.gpg differ diff --git a/internal/util/file.go b/internal/util/file.go new file mode 100644 index 000000000..28636ae7d --- /dev/null +++ b/internal/util/file.go @@ -0,0 +1,58 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/fluxcd/source-controller/internal/fs" +) + +func writeBytesToFile(bytes []byte, file *os.File) error { + if _, err := file.Write(bytes); err != nil { + _ = file.Close() + return fmt.Errorf("failed to write to file %s: %w", file.Name(), err) + } + if err := file.Close(); err != nil { + return err + } + return nil +} + +// Writes the provided bytes to a temp file with the name provided in the path and +// returns the file handle. If renameToOriginal is true, it renames the temp file to +// the intended file name (since temp file names have random bytes as suffix). +func WriteToTempFile(bytes []byte, out string, renameToOriginal bool) (*os.File, error) { + file, err := os.CreateTemp("", filepath.Base(out)) + if err != nil { + return nil, fmt.Errorf("failed to create temporary file %s: %w", filepath.Base(out), err) + } + err = writeBytesToFile(bytes, file) + if err != nil { + return nil, err + } + if renameToOriginal { + err = fs.RenameWithFallback(file.Name(), filepath.Join("/tmp", filepath.Base(out))) + file, err = os.Open(filepath.Join("/tmp", filepath.Base(out))) + if err != nil { + return nil, fmt.Errorf("failed to rename temporary file %s: %w", filepath.Base(out), err) + } + } + return file, nil +}