-
Notifications
You must be signed in to change notification settings - Fork 203
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
base: main
Are you sure you want to change the base?
Changes from all commits
fae51ba
36f302a
d435fe7
4988803
cef5bee
f39ee52
51a5e77
a6bb5f0
6ee9864
fcd34a8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
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()) | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
||
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 | ||
} |
Large diffs are not rendered by default.
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----- |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,95 @@ | ||||||
/* | ||||||
Copyright 2021 The Flux authors | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
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 | ||||||
darkowlzz marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
|
||||||
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) { | ||||||
aryan9600 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
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 | ||||||
} |
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()) | ||
} |
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----- |
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 | ||
aryan9600 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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 | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SecretRef
andVerificationKeyring
can have some description for documentation purposes.