Skip to content

Add support for verifying helm charts using provenance file #605

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
5 changes: 4 additions & 1 deletion api/go.mod
Original file line number Diff line number Diff line change
@@ -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
15 changes: 10 additions & 5 deletions api/go.sum
Original file line number Diff line number Diff line change
@@ -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=
4 changes: 4 additions & 0 deletions api/v1beta2/condition_types.go
Original file line number Diff line number Diff line change
@@ -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"
)
21 changes: 21 additions & 0 deletions api/v1beta2/helmchart_types.go
Original file line number Diff line number Diff line change
@@ -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"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SecretRef and VerificationKeyring can have some description for documentation purposes.


// 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.
21 changes: 21 additions & 0 deletions api/v1beta2/zz_generated.deepcopy.go

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

20 changes: 20 additions & 0 deletions config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion controllers/gitrepository_controller.go
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion controllers/gitrepository_controller_test.go
Original file line number Diff line number Diff line change
@@ -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"),
},
},
{
81 changes: 79 additions & 2 deletions controllers/helmchart_controller.go
Original file line number Diff line number Diff line change
@@ -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())
}
Copy link
Contributor

@darkowlzz darkowlzz Mar 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also need to ensure that the condition is not present on the object when it's not requested. An else block can be added here that deletes any existing source verified condition. Refer to how it's done for git commit verification.


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
}
329 changes: 318 additions & 11 deletions controllers/helmchart_controller_test.go

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions controllers/storage.go
Original file line number Diff line number Diff line change
@@ -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))
Binary file modified controllers/testdata/charts/helmchart-0.1.0.tgz
Binary file not shown.
26 changes: 26 additions & 0 deletions controllers/testdata/charts/helmchart-0.1.0.tgz.prov
Original file line number Diff line number Diff line change
@@ -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-----
Binary file added controllers/testdata/charts/pub.gpg
Binary file not shown.
Binary file added controllers/testdata/charts/sec.gpg
Binary file not shown.
75 changes: 75 additions & 0 deletions docs/api/source.md
Original file line number Diff line number Diff line change
@@ -668,6 +668,20 @@ references to this object.
NOTE: Not implemented, provisional as of <a href="https://github.com/fluxcd/flux2/pull/2092">https://github.com/fluxcd/flux2/pull/2092</a></p>
</td>
</tr>
<tr>
<td>
<code>verificationKeyring</code><br>
<em>
<a href="#source.toolkit.fluxcd.io/v1beta2.VerificationKeyring">
VerificationKeyring
</a>
</em>
</td>
<td>
<em>(Optional)</em>
<p>VerificationKeyring for verifying the packaged chart&rsquo;s signature using a provenance file.</p>
</td>
</tr>
</table>
</td>
</tr>
@@ -1850,6 +1864,20 @@ references to this object.
NOTE: Not implemented, provisional as of <a href="https://github.com/fluxcd/flux2/pull/2092">https://github.com/fluxcd/flux2/pull/2092</a></p>
</td>
</tr>
<tr>
<td>
<code>verificationKeyring</code><br>
<em>
<a href="#source.toolkit.fluxcd.io/v1beta2.VerificationKeyring">
VerificationKeyring
</a>
</em>
</td>
<td>
<em>(Optional)</em>
<p>VerificationKeyring for verifying the packaged chart&rsquo;s signature using a provenance file.</p>
</td>
</tr>
</tbody>
</table>
</div>
@@ -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.</p>
<h3 id="source.toolkit.fluxcd.io/v1beta2.VerificationKeyring">VerificationKeyring
</h3>
<p>
(<em>Appears on:</em>
<a href="#source.toolkit.fluxcd.io/v1beta2.HelmChartSpec">HelmChartSpec</a>)
</p>
<p>VerificationKeyring contains enough info to get the public GPG key to be used for verifying
the chart signature using a provenance file.</p>
<div class="md-typeset__scrollwrap">
<div class="md-typeset__table">
<table>
<thead>
<tr>
<th>Field</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>secretRef</code><br>
<em>
<a href="https://godoc.org/github.com/fluxcd/pkg/apis/meta#LocalObjectReference">
github.com/fluxcd/pkg/apis/meta.LocalObjectReference
</a>
</em>
</td>
<td>
<p>SecretRef is a reference to the secret that contains the public GPG key.</p>
</td>
</tr>
<tr>
<td>
<code>key</code><br>
<em>
string
</em>
</td>
<td>
<em>(Optional)</em>
<p>Key in the SecretRef that contains the public keyring in legacy GPG format.</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="admonition note">
<p class="last">This page was automatically generated with <code>gen-crd-api-reference-docs</code></p>
</div>
12 changes: 11 additions & 1 deletion internal/helm/chart/builder.go
Original file line number Diff line number Diff line change
@@ -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()
}

27 changes: 26 additions & 1 deletion internal/helm/chart/builder_local.go
Original file line number Diff line number Diff line change
@@ -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
}
43 changes: 43 additions & 0 deletions internal/helm/chart/builder_local_test.go
Original file line number Diff line number Diff line change
@@ -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
63 changes: 49 additions & 14 deletions internal/helm/chart/builder_remote.go
Original file line number Diff line number Diff line change
@@ -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 {
81 changes: 60 additions & 21 deletions internal/helm/chart/builder_remote_test.go
Original file line number Diff line number Diff line change
@@ -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,19 +387,17 @@ 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)
err = validatePackageAndWriteToPath(validF, chartPath)
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())
}
15 changes: 8 additions & 7 deletions internal/helm/chart/errors.go
Original file line number Diff line number Diff line change
@@ -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"}
)
95 changes: 95 additions & 0 deletions internal/helm/chart/verify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
Copyright 2021 The Flux authors
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Copyright 2021 The Flux authors
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 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
}
18 changes: 18 additions & 0 deletions internal/helm/chart/verify_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
47 changes: 42 additions & 5 deletions internal/helm/repository/chart_repository.go
Original file line number Diff line number Diff line change
@@ -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.
Binary file modified internal/helm/testdata/charts/helmchart-0.1.0.tgz
Binary file not shown.
26 changes: 26 additions & 0 deletions internal/helm/testdata/charts/helmchart-0.1.0.tgz.prov
Original file line number Diff line number Diff line change
@@ -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-----
Binary file added internal/helm/testdata/charts/pub.gpg
Binary file not shown.
Binary file added internal/helm/testdata/charts/sec.gpg
Binary file not shown.
58 changes: 58 additions & 0 deletions internal/util/file.go
Original file line number Diff line number Diff line change
@@ -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
}