implement Cosign verification for HelmCharts
If implemented, users will be able to enable chart verification for OCI based helm charts. Signed-off-by: Soule BA <soule@weave.works>
This commit is contained in:
parent
55dd799dad
commit
0e97547eeb
|
@ -86,6 +86,14 @@ type HelmChartSpec struct {
|
|||
// NOTE: Not implemented, provisional as of https://github.com/fluxcd/flux2/pull/2092
|
||||
// +optional
|
||||
AccessFrom *acl.AccessFrom `json:"accessFrom,omitempty"`
|
||||
|
||||
// Verify contains the secret name containing the trusted public keys
|
||||
// used to verify the signature and specifies which provider to use to check
|
||||
// whether OCI image is authentic.
|
||||
// This field is only supported for OCI sources.
|
||||
// Chart dependencies, which are not bundled in the umbrella chart artifact, are not verified.
|
||||
// +optional
|
||||
Verify *OCIRepositoryVerification `json:"verify,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
|
|
|
@ -464,6 +464,11 @@ func (in *HelmChartSpec) DeepCopyInto(out *HelmChartSpec) {
|
|||
*out = new(acl.AccessFrom)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
if in.Verify != nil {
|
||||
in, out := &in.Verify, &out.Verify
|
||||
*out = new(OCIRepositoryVerification)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmChartSpec.
|
||||
|
|
|
@ -403,6 +403,33 @@ spec:
|
|||
items:
|
||||
type: string
|
||||
type: array
|
||||
verify:
|
||||
description: Verify contains the secret name containing the trusted
|
||||
public keys used to verify the signature and specifies which provider
|
||||
to use to check whether OCI image is authentic. This field is only
|
||||
supported for OCI sources. Chart dependencies, which are not bundled
|
||||
in the umbrella chart artifact, are not verified.
|
||||
properties:
|
||||
provider:
|
||||
default: cosign
|
||||
description: Provider specifies the technology used to sign the
|
||||
OCI Artifact.
|
||||
enum:
|
||||
- cosign
|
||||
type: string
|
||||
secretRef:
|
||||
description: SecretRef specifies the Kubernetes Secret containing
|
||||
the trusted public keys.
|
||||
properties:
|
||||
name:
|
||||
description: Name of the referent.
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
required:
|
||||
- provider
|
||||
type: object
|
||||
version:
|
||||
default: '*'
|
||||
description: Version is the chart version semver expression, ignored
|
||||
|
|
|
@ -19,3 +19,17 @@ spec:
|
|||
name: podinfo
|
||||
version: '6.1.*'
|
||||
interval: 1m
|
||||
---
|
||||
apiVersion: source.toolkit.fluxcd.io/v1beta2
|
||||
kind: HelmChart
|
||||
metadata:
|
||||
name: podinfo-keyless
|
||||
spec:
|
||||
chart: podinfo
|
||||
sourceRef:
|
||||
kind: HelmRepository
|
||||
name: podinfo
|
||||
version: '6.2.1'
|
||||
interval: 1m
|
||||
verify:
|
||||
provider: cosign
|
||||
|
|
|
@ -28,6 +28,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
soci "github.com/fluxcd/source-controller/internal/oci"
|
||||
helmgetter "helm.sh/helm/v3/pkg/getter"
|
||||
helmreg "helm.sh/helm/v3/pkg/registry"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
|
@ -57,6 +58,7 @@ import (
|
|||
"github.com/fluxcd/pkg/runtime/predicates"
|
||||
"github.com/fluxcd/pkg/untar"
|
||||
"github.com/google/go-containerregistry/pkg/authn"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
|
||||
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
|
||||
"github.com/fluxcd/source-controller/internal/cache"
|
||||
|
@ -80,6 +82,7 @@ var helmChartReadyCondition = summarize.Conditions{
|
|||
sourcev1.BuildFailedCondition,
|
||||
sourcev1.ArtifactOutdatedCondition,
|
||||
sourcev1.ArtifactInStorageCondition,
|
||||
sourcev1.SourceVerifiedCondition,
|
||||
meta.ReadyCondition,
|
||||
meta.ReconcilingCondition,
|
||||
meta.StalledCondition,
|
||||
|
@ -90,6 +93,7 @@ var helmChartReadyCondition = summarize.Conditions{
|
|||
sourcev1.BuildFailedCondition,
|
||||
sourcev1.ArtifactOutdatedCondition,
|
||||
sourcev1.ArtifactInStorageCondition,
|
||||
sourcev1.SourceVerifiedCondition,
|
||||
meta.StalledCondition,
|
||||
meta.ReconcilingCondition,
|
||||
},
|
||||
|
@ -564,9 +568,30 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *
|
|||
}()
|
||||
}
|
||||
|
||||
var verifiers []soci.Verifier
|
||||
if obj.Spec.Verify != nil {
|
||||
provider := obj.Spec.Verify.Provider
|
||||
verifiers, err = r.makeVerifiers(ctx, obj, authenticator, keychain)
|
||||
if err != nil {
|
||||
if obj.Spec.Verify.SecretRef == nil {
|
||||
provider = fmt.Sprintf("%s keyless", provider)
|
||||
}
|
||||
e := serror.NewGeneric(
|
||||
fmt.Errorf("failed to verify the signature using provider '%s': %w", provider, err),
|
||||
sourcev1.VerificationError,
|
||||
)
|
||||
conditions.MarkFalse(obj, sourcev1.SourceVerifiedCondition, e.Reason, e.Err.Error())
|
||||
return sreconcile.ResultEmpty, e
|
||||
}
|
||||
}
|
||||
|
||||
// Tell the chart repository to use the OCI client with the configured getter
|
||||
clientOpts = append(clientOpts, helmgetter.WithRegistryClient(registryClient))
|
||||
ociChartRepo, err := repository.NewOCIChartRepository(normalizedURL, repository.WithOCIGetter(r.Getters), repository.WithOCIGetterOptions(clientOpts), repository.WithOCIRegistryClient(registryClient))
|
||||
ociChartRepo, err := repository.NewOCIChartRepository(normalizedURL,
|
||||
repository.WithOCIGetter(r.Getters),
|
||||
repository.WithOCIGetterOptions(clientOpts),
|
||||
repository.WithOCIRegistryClient(registryClient),
|
||||
repository.WithVerifiers(verifiers))
|
||||
if err != nil {
|
||||
return chartRepoConfigErrorReturn(err, obj)
|
||||
}
|
||||
|
@ -574,7 +599,7 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *
|
|||
|
||||
// If login options are configured, use them to login to the registry
|
||||
// The OCIGetter will later retrieve the stored credentials to pull the chart
|
||||
if keychain != nil {
|
||||
if loginOpt != nil {
|
||||
err = ociChartRepo.Login(loginOpt)
|
||||
if err != nil {
|
||||
e := &serror.Event{
|
||||
|
@ -622,6 +647,17 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *
|
|||
opts := chart.BuildOptions{
|
||||
ValuesFiles: obj.GetValuesFiles(),
|
||||
Force: obj.Generation != obj.Status.ObservedGeneration,
|
||||
// The remote builder will not attempt to download the chart if
|
||||
// an artifact exist with the same name and version and the force is false.
|
||||
// It will try to verify the chart if:
|
||||
// - we are on the first reconciliation
|
||||
// - the HelmChart spec has changed (generation drift)
|
||||
// - the previous reconciliation resulted in a failed artifact verification
|
||||
// - there is no artifact in storage
|
||||
Verify: obj.Spec.Verify != nil && (obj.Generation <= 0 ||
|
||||
conditions.GetObservedGeneration(obj, sourcev1.SourceVerifiedCondition) != obj.Generation ||
|
||||
conditions.IsFalse(obj, sourcev1.SourceVerifiedCondition) ||
|
||||
obj.GetArtifact() == nil),
|
||||
}
|
||||
if artifact := obj.GetArtifact(); artifact != nil {
|
||||
opts.CachedChart = r.Storage.LocalPath(*artifact)
|
||||
|
@ -1030,7 +1066,7 @@ func (r *HelmChartReconciler) namespacedChartRepositoryCallback(ctx context.Cont
|
|||
|
||||
// If login options are configured, use them to login to the registry
|
||||
// The OCIGetter will later retrieve the stored credentials to pull the chart
|
||||
if keychain != nil {
|
||||
if loginOpt != nil {
|
||||
err = ociChartRepo.Login(loginOpt)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to login to OCI chart repository for HelmRepository '%s': %w", repo.Name, err))
|
||||
|
@ -1239,6 +1275,11 @@ func observeChartBuild(obj *sourcev1.HelmChart, build *chart.Build, err error) {
|
|||
if build.Complete() {
|
||||
conditions.Delete(obj, sourcev1.FetchFailedCondition)
|
||||
conditions.Delete(obj, sourcev1.BuildFailedCondition)
|
||||
conditions.MarkTrue(obj, sourcev1.SourceVerifiedCondition, meta.SucceededReason, fmt.Sprintf("verified signature of version %s", build.Version))
|
||||
}
|
||||
|
||||
if obj.Spec.Verify == nil {
|
||||
conditions.Delete(obj, sourcev1.SourceVerifiedCondition)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
@ -1251,7 +1292,7 @@ func observeChartBuild(obj *sourcev1.HelmChart, build *chart.Build, err error) {
|
|||
}
|
||||
|
||||
switch buildErr.Reason {
|
||||
case chart.ErrChartMetadataPatch, chart.ErrValuesFilesMerge, chart.ErrDependencyBuild, chart.ErrChartPackage:
|
||||
case chart.ErrChartMetadataPatch, chart.ErrValuesFilesMerge, chart.ErrDependencyBuild, chart.ErrChartPackage, chart.ErrChartVerification:
|
||||
conditions.Delete(obj, sourcev1.FetchFailedCondition)
|
||||
conditions.MarkTrue(obj, sourcev1.BuildFailedCondition, buildErr.Reason.Reason, buildErr.Error())
|
||||
default:
|
||||
|
@ -1290,3 +1331,60 @@ func chartRepoConfigErrorReturn(err error, obj *sourcev1.HelmChart) (sreconcile.
|
|||
return sreconcile.ResultEmpty, e
|
||||
}
|
||||
}
|
||||
|
||||
// makeVerifiers returns a list of verifiers for the given chart.
|
||||
func (r *HelmChartReconciler) makeVerifiers(ctx context.Context, obj *sourcev1.HelmChart, auth authn.Authenticator, keychain authn.Keychain) ([]soci.Verifier, error) {
|
||||
var verifiers []soci.Verifier
|
||||
verifyOpts := []remote.Option{}
|
||||
if auth != nil {
|
||||
verifyOpts = append(verifyOpts, remote.WithAuth(auth))
|
||||
} else {
|
||||
verifyOpts = append(verifyOpts, remote.WithAuthFromKeychain(keychain))
|
||||
}
|
||||
|
||||
switch obj.Spec.Verify.Provider {
|
||||
case "cosign":
|
||||
defaultCosignOciOpts := []soci.Options{
|
||||
soci.WithRemoteOptions(verifyOpts...),
|
||||
}
|
||||
|
||||
// get the public keys from the given secret
|
||||
if secretRef := obj.Spec.Verify.SecretRef; secretRef != nil {
|
||||
certSecretName := types.NamespacedName{
|
||||
Namespace: obj.Namespace,
|
||||
Name: secretRef.Name,
|
||||
}
|
||||
|
||||
var pubSecret corev1.Secret
|
||||
if err := r.Get(ctx, certSecretName, &pubSecret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for k, data := range pubSecret.Data {
|
||||
// search for public keys in the secret
|
||||
if strings.HasSuffix(k, ".pub") {
|
||||
verifier, err := soci.NewCosignVerifier(ctx, append(defaultCosignOciOpts, soci.WithPublicKey(data))...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
verifiers = append(verifiers, verifier)
|
||||
}
|
||||
}
|
||||
|
||||
if len(verifiers) == 0 {
|
||||
return nil, fmt.Errorf("no public keys found in secret '%s'", certSecretName)
|
||||
}
|
||||
return verifiers, nil
|
||||
}
|
||||
|
||||
// if no secret is provided, add a keyless verifier
|
||||
verifier, err := soci.NewCosignVerifier(ctx, defaultCosignOciOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
verifiers = append(verifiers, verifier)
|
||||
return verifiers, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported verification provider: %s", obj.Spec.Verify.Provider)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ import (
|
|||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
@ -33,6 +34,9 @@ import (
|
|||
"time"
|
||||
|
||||
. "github.com/onsi/gomega"
|
||||
coptions "github.com/sigstore/cosign/cmd/cosign/cli/options"
|
||||
"github.com/sigstore/cosign/cmd/cosign/cli/sign"
|
||||
"github.com/sigstore/cosign/pkg/cosign"
|
||||
hchart "helm.sh/helm/v3/pkg/chart"
|
||||
"helm.sh/helm/v3/pkg/chart/loader"
|
||||
helmreg "helm.sh/helm/v3/pkg/registry"
|
||||
|
@ -57,6 +61,7 @@ import (
|
|||
serror "github.com/fluxcd/source-controller/internal/error"
|
||||
"github.com/fluxcd/source-controller/internal/helm/chart"
|
||||
"github.com/fluxcd/source-controller/internal/helm/registry"
|
||||
"github.com/fluxcd/source-controller/internal/oci"
|
||||
sreconcile "github.com/fluxcd/source-controller/internal/reconcile"
|
||||
"github.com/fluxcd/source-controller/internal/reconcile/summarize"
|
||||
)
|
||||
|
@ -2213,6 +2218,228 @@ func TestHelmChartReconciler_reconcileSourceFromOCI_authStrategy(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestHelmChartReconciler_reconcileSourceFromOCI_verifySignature(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
server, err := setupRegistryServer(ctx, tmpDir, registryOptions{})
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
const (
|
||||
chartPath = "testdata/charts/helmchart-0.1.0.tgz"
|
||||
)
|
||||
|
||||
// Load a test chart
|
||||
chartData, err := ioutil.ReadFile(chartPath)
|
||||
|
||||
// Upload the test chart
|
||||
metadata, err := loadTestChartToOCI(chartData, chartPath, server)
|
||||
g.Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
storage, err := NewStorage(tmpDir, "example.com", retentionTTL, retentionRecords)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
cachedArtifact := &sourcev1.Artifact{
|
||||
Revision: "0.1.0",
|
||||
Path: metadata.Name + "-" + metadata.Version + ".tgz",
|
||||
}
|
||||
g.Expect(storage.CopyFromPath(cachedArtifact, "testdata/charts/helmchart-0.1.0.tgz")).To(Succeed())
|
||||
|
||||
pf := func(b bool) ([]byte, error) {
|
||||
return []byte("cosign-password"), nil
|
||||
}
|
||||
|
||||
keys, err := cosign.GenerateKeyPair(pf)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = os.WriteFile(path.Join(tmpDir, "cosign.key"), keys.PrivateBytes, 0600)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
defer func() {
|
||||
err := os.Remove(path.Join(tmpDir, "cosign.key"))
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
want sreconcile.Result
|
||||
wantErr bool
|
||||
wantErrMsg string
|
||||
shouldSign bool
|
||||
beforeFunc func(obj *sourcev1.HelmChart)
|
||||
assertConditions []metav1.Condition
|
||||
cleanFunc func(g *WithT, build *chart.Build)
|
||||
}{
|
||||
{
|
||||
name: "unsigned charts should not pass verification",
|
||||
beforeFunc: func(obj *sourcev1.HelmChart) {
|
||||
obj.Spec.Chart = metadata.Name
|
||||
obj.Spec.Version = metadata.Version
|
||||
obj.Spec.Verify = &sourcev1.OCIRepositoryVerification{
|
||||
Provider: "cosign",
|
||||
SecretRef: &meta.LocalObjectReference{Name: "cosign-key"},
|
||||
}
|
||||
},
|
||||
wantErr: true,
|
||||
wantErrMsg: "chart verification error: failed to verify <url>: no matching signatures:",
|
||||
want: sreconcile.ResultEmpty,
|
||||
assertConditions: []metav1.Condition{
|
||||
*conditions.TrueCondition(sourcev1.BuildFailedCondition, "ChartVerificationError", "chart verification error: failed to verify <url>: no matching signatures:"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unsigned charts should not pass keyless verification",
|
||||
beforeFunc: func(obj *sourcev1.HelmChart) {
|
||||
obj.Spec.Chart = metadata.Name
|
||||
obj.Spec.Version = metadata.Version
|
||||
obj.Spec.Verify = &sourcev1.OCIRepositoryVerification{
|
||||
Provider: "cosign",
|
||||
}
|
||||
},
|
||||
wantErr: true,
|
||||
want: sreconcile.ResultEmpty,
|
||||
assertConditions: []metav1.Condition{
|
||||
*conditions.TrueCondition(sourcev1.BuildFailedCondition, "ChartVerificationError", "chart verification error: failed to verify <url>: no matching signatures:"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "signed charts should pass verification",
|
||||
beforeFunc: func(obj *sourcev1.HelmChart) {
|
||||
obj.Spec.Chart = metadata.Name
|
||||
obj.Spec.Version = metadata.Version
|
||||
obj.Spec.Verify = &sourcev1.OCIRepositoryVerification{
|
||||
Provider: "cosign",
|
||||
SecretRef: &meta.LocalObjectReference{Name: "cosign-key"},
|
||||
}
|
||||
},
|
||||
shouldSign: true,
|
||||
want: sreconcile.ResultSuccess,
|
||||
assertConditions: []metav1.Condition{
|
||||
*conditions.TrueCondition(sourcev1.ArtifactOutdatedCondition, "NewChart", "pulled '<name>' chart with version '<version>'"),
|
||||
*conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of version <version>"),
|
||||
},
|
||||
cleanFunc: func(g *WithT, build *chart.Build) {
|
||||
g.Expect(os.Remove(build.Path)).To(Succeed())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "verify failed before, removed from spec, remove condition",
|
||||
beforeFunc: func(obj *sourcev1.HelmChart) {
|
||||
obj.Spec.Chart = metadata.Name
|
||||
obj.Spec.Version = metadata.Version
|
||||
obj.Spec.Verify = nil
|
||||
conditions.MarkFalse(obj, sourcev1.SourceVerifiedCondition, "VerifyFailed", "fail msg")
|
||||
obj.Status.Artifact = &sourcev1.Artifact{Path: metadata.Name + "-" + metadata.Version + ".tgz"}
|
||||
},
|
||||
want: sreconcile.ResultSuccess,
|
||||
assertConditions: []metav1.Condition{
|
||||
*conditions.TrueCondition(sourcev1.ArtifactOutdatedCondition, "NewChart", "pulled '<name>' chart with version '<version>'"),
|
||||
},
|
||||
cleanFunc: func(g *WithT, build *chart.Build) {
|
||||
g.Expect(os.Remove(build.Path)).To(Succeed())
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
|
||||
clientBuilder := fake.NewClientBuilder()
|
||||
|
||||
repository := &sourcev1.HelmRepository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
GenerateName: "helmrepository-",
|
||||
},
|
||||
Spec: sourcev1.HelmRepositorySpec{
|
||||
URL: fmt.Sprintf("oci://%s/testrepo", server.registryHost),
|
||||
Timeout: &metav1.Duration{Duration: timeout},
|
||||
Provider: sourcev1.GenericOCIProvider,
|
||||
Type: sourcev1.HelmRepositoryTypeOCI,
|
||||
},
|
||||
}
|
||||
|
||||
secret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "cosign-key",
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"cosign.pub": keys.PublicBytes,
|
||||
}}
|
||||
|
||||
clientBuilder.WithObjects(repository, secret)
|
||||
|
||||
r := &HelmChartReconciler{
|
||||
Client: clientBuilder.Build(),
|
||||
EventRecorder: record.NewFakeRecorder(32),
|
||||
Getters: testGetters,
|
||||
Storage: storage,
|
||||
RegistryClientGenerator: registry.ClientGenerator,
|
||||
}
|
||||
|
||||
obj := &sourcev1.HelmChart{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
GenerateName: "helmchart-",
|
||||
},
|
||||
Spec: sourcev1.HelmChartSpec{
|
||||
SourceRef: sourcev1.LocalHelmChartSourceReference{
|
||||
Kind: sourcev1.HelmRepositoryKind,
|
||||
Name: repository.Name,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
chartUrl := fmt.Sprintf("oci://%s/testrepo/%s:%s", server.registryHost, metadata.Name, metadata.Version)
|
||||
|
||||
if tt.beforeFunc != nil {
|
||||
tt.beforeFunc(obj)
|
||||
}
|
||||
|
||||
if tt.shouldSign {
|
||||
ko := coptions.KeyOpts{
|
||||
KeyRef: path.Join(tmpDir, "cosign.key"),
|
||||
PassFunc: pf,
|
||||
}
|
||||
|
||||
ro := &coptions.RootOptions{
|
||||
Timeout: timeout,
|
||||
}
|
||||
|
||||
err = sign.SignCmd(ro, ko, coptions.RegistryOptions{Keychain: oci.Anonymous{}},
|
||||
nil, []string{fmt.Sprintf("%s/testrepo/%s:%s", server.registryHost, metadata.Name, metadata.Version)}, "",
|
||||
"", true, "",
|
||||
"", "", false,
|
||||
false, "", false)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
assertConditions := tt.assertConditions
|
||||
for k := range assertConditions {
|
||||
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<name>", metadata.Name)
|
||||
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<version>", metadata.Version)
|
||||
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<url>", chartUrl)
|
||||
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<provider>", "cosign")
|
||||
}
|
||||
|
||||
var b chart.Build
|
||||
if tt.cleanFunc != nil {
|
||||
defer tt.cleanFunc(g, &b)
|
||||
}
|
||||
|
||||
got, err := r.reconcileSource(ctx, obj, &b)
|
||||
if tt.wantErr {
|
||||
tt.wantErrMsg = strings.ReplaceAll(tt.wantErrMsg, "<url>", chartUrl)
|
||||
g.Expect(err).ToNot(BeNil())
|
||||
g.Expect(err.Error()).To(ContainSubstring(tt.wantErrMsg))
|
||||
} else {
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
g.Expect(got).To(Equal(tt.want))
|
||||
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(tt.assertConditions))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// extractChartMeta is used to extract a chart metadata from a byte array
|
||||
func extractChartMeta(chartData []byte) (*hchart.Metadata, error) {
|
||||
ch, err := loader.LoadArchive(bytes.NewReader(chartData))
|
||||
|
|
|
@ -628,7 +628,7 @@ func (r *OCIRepositoryReconciler) verifySignature(ctx context.Context, obj *sour
|
|||
for k, data := range pubSecret.Data {
|
||||
// search for public keys in the secret
|
||||
if strings.HasSuffix(k, ".pub") {
|
||||
verifier, err := soci.NewVerifier(ctxTimeout, append(defaultCosignOciOpts, soci.WithPublicKey(data))...)
|
||||
verifier, err := soci.NewCosignVerifier(ctxTimeout, append(defaultCosignOciOpts, soci.WithPublicKey(data))...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -654,7 +654,7 @@ func (r *OCIRepositoryReconciler) verifySignature(ctx context.Context, obj *sour
|
|||
|
||||
// if no secret is provided, try keyless verification
|
||||
ctrl.LoggerFrom(ctx).Info("no secret reference is provided, trying to verify the image using keyless method")
|
||||
verifier, err := soci.NewVerifier(ctxTimeout, defaultCosignOciOpts...)
|
||||
verifier, err := soci.NewCosignVerifier(ctxTimeout, defaultCosignOciOpts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -670,6 +670,24 @@ 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>verify</code><br>
|
||||
<em>
|
||||
<a href="#source.toolkit.fluxcd.io/v1beta2.OCIRepositoryVerification">
|
||||
OCIRepositoryVerification
|
||||
</a>
|
||||
</em>
|
||||
</td>
|
||||
<td>
|
||||
<em>(Optional)</em>
|
||||
<p>Verify contains the secret name containing the trusted public keys
|
||||
used to verify the signature and specifies which provider to use to check
|
||||
whether OCI image is authentic.
|
||||
This field is only supported for OCI sources.
|
||||
Chart dependencies, which are not bundled in the umbrella chart artifact, are not verified.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -2237,6 +2255,24 @@ 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>verify</code><br>
|
||||
<em>
|
||||
<a href="#source.toolkit.fluxcd.io/v1beta2.OCIRepositoryVerification">
|
||||
OCIRepositoryVerification
|
||||
</a>
|
||||
</em>
|
||||
</td>
|
||||
<td>
|
||||
<em>(Optional)</em>
|
||||
<p>Verify contains the secret name containing the trusted public keys
|
||||
used to verify the signature and specifies which provider to use to check
|
||||
whether OCI image is authentic.
|
||||
This field is only supported for OCI sources.
|
||||
Chart dependencies, which are not bundled in the umbrella chart artifact, are not verified.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
@ -3123,6 +3159,7 @@ github.com/fluxcd/pkg/apis/meta.ReconcileRequestStatus
|
|||
</h3>
|
||||
<p>
|
||||
(<em>Appears on:</em>
|
||||
<a href="#source.toolkit.fluxcd.io/v1beta2.HelmChartSpec">HelmChartSpec</a>,
|
||||
<a href="#source.toolkit.fluxcd.io/v1beta2.OCIRepositorySpec">OCIRepositorySpec</a>)
|
||||
</p>
|
||||
<p>OCIRepositoryVerification verifies the authenticity of an OCI Artifact</p>
|
||||
|
|
|
@ -165,6 +165,7 @@ echo "Run HelmChart from OCI registry tests"
|
|||
kubectl -n source-system apply -f "${ROOT_DIR}/config/testdata/helmchart-from-oci/source.yaml"
|
||||
kubectl -n source-system wait helmrepository/podinfo --for=condition=ready --timeout=1m
|
||||
kubectl -n source-system wait helmchart/podinfo --for=condition=ready --timeout=1m
|
||||
kubectl -n source-system wait helmchart/podinfo-keyless --for=condition=ready --timeout=1m
|
||||
|
||||
echo "Run OCIRepository verify tests"
|
||||
kubectl -n source-system apply -f "${ROOT_DIR}/config/testdata/ocirepository/signed-with-key.yaml"
|
||||
|
|
|
@ -113,6 +113,8 @@ type BuildOptions struct {
|
|||
// Force can be set to force the build of the chart, for example
|
||||
// because the list of ValuesFiles has changed.
|
||||
Force bool
|
||||
// Verifier can be set to the verification of the chart.
|
||||
Verify bool
|
||||
}
|
||||
|
||||
// GetValuesFiles returns BuildOptions.ValuesFiles, except if it equals
|
||||
|
|
|
@ -63,7 +63,7 @@ func NewRemoteBuilder(repository repository.Downloader) Builder {
|
|||
// After downloading the chart, it is only packaged if required due to BuildOptions
|
||||
// modifying the chart, otherwise the exact data as retrieved from the repository
|
||||
// is written to p, after validating it to be a chart.
|
||||
func (b *remoteChartBuilder) Build(_ context.Context, ref Reference, p string, opts BuildOptions) (*Build, error) {
|
||||
func (b *remoteChartBuilder) Build(ctx context.Context, ref Reference, p string, opts BuildOptions) (*Build, error) {
|
||||
remoteRef, ok := ref.(RemoteReference)
|
||||
if !ok {
|
||||
err := fmt.Errorf("expected remote chart reference")
|
||||
|
@ -74,9 +74,9 @@ func (b *remoteChartBuilder) Build(_ context.Context, ref Reference, p string, o
|
|||
return nil, &BuildError{Reason: ErrChartReference, Err: err}
|
||||
}
|
||||
|
||||
res, result, err := b.downloadFromRepository(b.remote, remoteRef, opts)
|
||||
res, result, err := b.downloadFromRepository(ctx, b.remote, remoteRef, opts)
|
||||
if err != nil {
|
||||
return nil, &BuildError{Reason: ErrChartPull, Err: err}
|
||||
return nil, err
|
||||
}
|
||||
if res == nil {
|
||||
return result, nil
|
||||
|
@ -124,7 +124,7 @@ func (b *remoteChartBuilder) Build(_ context.Context, ref Reference, p string, o
|
|||
return result, nil
|
||||
}
|
||||
|
||||
func (b *remoteChartBuilder) downloadFromRepository(remote repository.Downloader, remoteRef RemoteReference, opts BuildOptions) (*bytes.Buffer, *Build, error) {
|
||||
func (b *remoteChartBuilder) downloadFromRepository(ctx context.Context, remote repository.Downloader, remoteRef RemoteReference, opts BuildOptions) (*bytes.Buffer, *Build, error) {
|
||||
// Get the current version for the RemoteReference
|
||||
cv, err := remote.GetChartVersion(remoteRef.Name, remoteRef.Version)
|
||||
if err != nil {
|
||||
|
@ -132,6 +132,13 @@ func (b *remoteChartBuilder) downloadFromRepository(remote repository.Downloader
|
|||
return nil, nil, &BuildError{Reason: ErrChartReference, Err: err}
|
||||
}
|
||||
|
||||
// Verify the chart if necessary
|
||||
if opts.Verify {
|
||||
if err := remote.VerifyChart(ctx, cv); err != nil {
|
||||
return nil, nil, &BuildError{Reason: ErrChartVerification, Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
result, shouldReturn, err := generateBuildResult(cv, opts)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
|
|
@ -84,5 +84,6 @@ var (
|
|||
ErrValuesFilesMerge = BuildErrorReason{Reason: "ValuesFilesError", Summary: "values files merge error"}
|
||||
ErrDependencyBuild = BuildErrorReason{Reason: "DependencyBuildError", Summary: "dependency build error"}
|
||||
ErrChartPackage = BuildErrorReason{Reason: "ChartPackageError", Summary: "chart package error"}
|
||||
ErrChartVerification = BuildErrorReason{Reason: "ChartVerificationError", Summary: "chart verification error"}
|
||||
ErrUnknown = BuildErrorReason{Reason: "Unknown", Summary: "unknown build error"}
|
||||
)
|
||||
|
|
|
@ -18,6 +18,7 @@ package repository
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
|
@ -520,3 +521,12 @@ func (r *ChartRepository) RemoveCache() error {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyChart verifies the chart against a signature.
|
||||
// If no signature is provided, a keyless verification is performed.
|
||||
// It returns an error on failure.
|
||||
func (r *ChartRepository) VerifyChart(_ context.Context, _ *repo.ChartVersion) error {
|
||||
// no-op
|
||||
// this is a no-op because this is not implemented yet.
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ package repository
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
@ -32,7 +33,10 @@ import (
|
|||
"helm.sh/helm/v3/pkg/repo"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
|
||||
"github.com/fluxcd/pkg/version"
|
||||
"github.com/fluxcd/source-controller/internal/oci"
|
||||
"github.com/fluxcd/source-controller/internal/transport"
|
||||
)
|
||||
|
||||
|
@ -63,12 +67,23 @@ type OCIChartRepository struct {
|
|||
RegistryClient RegistryClient
|
||||
// credentialsFile is a temporary credentials file to use while downloading tags or charts from a registry.
|
||||
credentialsFile string
|
||||
|
||||
// verifiers is a list of verifiers to use when verifying a chart.
|
||||
verifiers []oci.Verifier
|
||||
}
|
||||
|
||||
// OCIChartRepositoryOption is a function that can be passed to NewOCIChartRepository
|
||||
// to configure an OCIChartRepository.
|
||||
type OCIChartRepositoryOption func(*OCIChartRepository) error
|
||||
|
||||
// WithVerifiers returns a ChartRepositoryOption that will set the chart verifiers
|
||||
func WithVerifiers(verifiers []oci.Verifier) OCIChartRepositoryOption {
|
||||
return func(r *OCIChartRepository) error {
|
||||
r.verifiers = verifiers
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithOCIRegistryClient returns a ChartRepositoryOption that will set the registry client
|
||||
func WithOCIRegistryClient(client RegistryClient) OCIChartRepositoryOption {
|
||||
return func(r *OCIChartRepository) error {
|
||||
|
@ -215,7 +230,6 @@ func (r *OCIChartRepository) DownloadChart(chart *repo.ChartVersion) (*bytes.Buf
|
|||
// Login attempts to login to the OCI registry.
|
||||
// It returns an error on failure.
|
||||
func (r *OCIChartRepository) Login(opts ...registry.LoginOption) error {
|
||||
// Get login credentials from keychain
|
||||
err := r.RegistryClient.Login(r.URL.Host, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -297,3 +311,32 @@ func getLastMatchingVersionOrConstraint(cvs []string, ver string) (string, error
|
|||
|
||||
return matchingVersions[0].Original(), nil
|
||||
}
|
||||
|
||||
// VerifyChart verifies the chart against a signature.
|
||||
// If no signature is provided, a keyless verification is performed.
|
||||
// It returns an error on failure.
|
||||
func (r *OCIChartRepository) VerifyChart(ctx context.Context, chart *repo.ChartVersion) error {
|
||||
if len(r.verifiers) == 0 {
|
||||
return fmt.Errorf("no verifiers available")
|
||||
}
|
||||
|
||||
if len(chart.URLs) == 0 {
|
||||
return fmt.Errorf("chart '%s' has no downloadable URLs", chart.Name)
|
||||
}
|
||||
|
||||
ref, err := name.ParseReference(strings.TrimPrefix(chart.URLs[0], fmt.Sprintf("%s://", registry.OCIScheme)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid chart reference: %s", err)
|
||||
}
|
||||
|
||||
// verify the chart
|
||||
for _, verifier := range r.verifiers {
|
||||
if verified, err := verifier.Verify(ctx, ref); err != nil {
|
||||
return fmt.Errorf("failed to verify %s: %w", chart.URLs[0], err)
|
||||
} else if verified {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("no matching signatures were found for '%s'", ref.Name())
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ package repository
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
|
||||
"helm.sh/helm/v3/pkg/repo"
|
||||
)
|
||||
|
@ -29,6 +30,8 @@ type Downloader interface {
|
|||
GetChartVersion(name, version string) (*repo.ChartVersion, error)
|
||||
// DownloadChart downloads a chart from the remote Helm repository or OCI Helm repository.
|
||||
DownloadChart(chart *repo.ChartVersion) (*bytes.Buffer, error)
|
||||
// VerifyChart verifies the chart against a signature.
|
||||
VerifyChart(ctx context.Context, chart *repo.ChartVersion) error
|
||||
// Clear removes all temporary files created by the downloader, caching the files if the cache is configured,
|
||||
// and calling garbage collector to remove unused files.
|
||||
Clear() error
|
||||
|
|
|
@ -34,13 +34,18 @@ import (
|
|||
"github.com/sigstore/sigstore/pkg/signature"
|
||||
)
|
||||
|
||||
// Verifier is an interface for verifying the authenticity of an OCI image.
|
||||
type Verifier interface {
|
||||
Verify(ctx context.Context, ref name.Reference) (bool, error)
|
||||
}
|
||||
|
||||
// options is a struct that holds options for verifier.
|
||||
type options struct {
|
||||
PublicKey []byte
|
||||
ROpt []remote.Option
|
||||
}
|
||||
|
||||
// Options is a function that configures the options applied to a Verifier.
|
||||
// Options is a function that configures the options applied to a CosignVerifier.
|
||||
type Options func(opts *options)
|
||||
|
||||
// WithPublicKey sets the public key.
|
||||
|
@ -58,13 +63,13 @@ func WithRemoteOptions(opts ...remote.Option) Options {
|
|||
}
|
||||
}
|
||||
|
||||
// Verifier is a struct which is responsible for executing verification logic.
|
||||
type Verifier struct {
|
||||
// CosignVerifier is a struct which is responsible for executing verification logic.
|
||||
type CosignVerifier struct {
|
||||
opts *cosign.CheckOpts
|
||||
}
|
||||
|
||||
// NewVerifier initializes a new Verifier.
|
||||
func NewVerifier(ctx context.Context, opts ...Options) (*Verifier, error) {
|
||||
// NewCosignVerifier initializes a new CosignVerifier.
|
||||
func NewCosignVerifier(ctx context.Context, opts ...Options) (*CosignVerifier, error) {
|
||||
o := options{}
|
||||
for _, opt := range opts {
|
||||
opt(&o)
|
||||
|
@ -117,12 +122,28 @@ func NewVerifier(ctx context.Context, opts ...Options) (*Verifier, error) {
|
|||
checkOpts.RekorClient = rc
|
||||
}
|
||||
|
||||
return &Verifier{
|
||||
return &CosignVerifier{
|
||||
opts: checkOpts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyImageSignatures verify the authenticity of the given ref OCI image.
|
||||
func (v *Verifier) VerifyImageSignatures(ctx context.Context, ref name.Reference) ([]oci.Signature, bool, error) {
|
||||
func (v *CosignVerifier) VerifyImageSignatures(ctx context.Context, ref name.Reference) ([]oci.Signature, bool, error) {
|
||||
return cosign.VerifyImageSignatures(ctx, ref, v.opts)
|
||||
}
|
||||
|
||||
// Verify verifies the authenticity of the given ref OCI image.
|
||||
// It returns a boolean indicating if the verification was successful.
|
||||
// It returns an error if the verification fails, nil otherwise.
|
||||
func (v *CosignVerifier) Verify(ctx context.Context, ref name.Reference) (bool, error) {
|
||||
signatures, _, err := v.VerifyImageSignatures(ctx, ref)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if len(signatures) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue