Merge pull request #925 from souleb/cosign-verify-helm

implement Cosign verification for HelmCharts
This commit is contained in:
Stefan Prodan 2022-10-21 16:54:42 +03:00 committed by GitHub
commit 09cae78713
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 808 additions and 118 deletions

View File

@ -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 when using HelmRepository source with spec.type 'oci'.
// Chart dependencies, which are not bundled in the umbrella chart artifact, are not verified.
// +optional
Verify *OCIRepositoryVerification `json:"verify,omitempty"`
}
const (

View File

@ -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.

View File

@ -403,6 +403,34 @@ 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 when using HelmRepository source with spec.type 'oci'.
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

View File

@ -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

View File

@ -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"
@ -56,6 +57,8 @@ import (
"github.com/fluxcd/pkg/runtime/patch"
"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"
@ -79,6 +82,7 @@ var helmChartReadyCondition = summarize.Conditions{
sourcev1.BuildFailedCondition,
sourcev1.ArtifactOutdatedCondition,
sourcev1.ArtifactInStorageCondition,
sourcev1.SourceVerifiedCondition,
meta.ReadyCondition,
meta.ReconcilingCondition,
meta.StalledCondition,
@ -89,6 +93,7 @@ var helmChartReadyCondition = summarize.Conditions{
sourcev1.BuildFailedCondition,
sourcev1.ArtifactOutdatedCondition,
sourcev1.ArtifactInStorageCondition,
sourcev1.SourceVerifiedCondition,
meta.StalledCondition,
meta.ReconcilingCondition,
},
@ -209,6 +214,7 @@ func (r *HelmChartReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
summarizeHelper := summarize.NewHelper(r.EventRecorder, patchHelper)
summarizeOpts := []summarize.Option{
summarize.WithConditions(helmChartReadyCondition),
summarize.WithBiPolarityConditionTypes(sourcev1.SourceVerifiedCondition),
summarize.WithReconcileResult(recResult),
summarize.WithReconcileError(retErr),
summarize.WithIgnoreNotFound(),
@ -366,6 +372,12 @@ func (r *HelmChartReconciler) reconcileStorage(ctx context.Context, obj *sourcev
}
func (r *HelmChartReconciler) reconcileSource(ctx context.Context, obj *sourcev1.HelmChart, build *chart.Build) (_ sreconcile.Result, retErr error) {
// Remove any failed verification condition.
// The reason is that a failing verification should be recalculated.
if conditions.IsFalse(obj, sourcev1.SourceVerifiedCondition) {
conditions.Delete(obj, sourcev1.SourceVerifiedCondition)
}
// Retrieve the source
s, err := r.getSource(ctx, obj)
if err != nil {
@ -455,8 +467,9 @@ func (r *HelmChartReconciler) reconcileSource(ctx context.Context, obj *sourcev1
func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *sourcev1.HelmChart,
repo *sourcev1.HelmRepository, b *chart.Build) (sreconcile.Result, error) {
var (
tlsConfig *tls.Config
loginOpts []helmreg.LoginOption
tlsConfig *tls.Config
authenticator authn.Authenticator
keychain authn.Keychain
)
// Used to login with the repository declared provider
ctxTimeout, cancel := context.WithTimeout(ctx, repo.Spec.Timeout.Duration)
@ -481,10 +494,10 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *
}
// Build client options from secret
opts, err := getter.ClientOptionsFromSecret(*secret)
opts, tls, err := r.clientOptionsFromSecret(secret, normalizedURL)
if err != nil {
e := &serror.Event{
Err: fmt.Errorf("failed to configure Helm client with secret data: %w", err),
Err: err,
Reason: sourcev1.AuthenticationFailedReason,
}
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
@ -492,20 +505,10 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *
return sreconcile.ResultEmpty, e
}
clientOpts = append(clientOpts, opts...)
tlsConfig, err = getter.TLSClientConfigFromSecret(*secret, normalizedURL)
if err != nil {
e := &serror.Event{
Err: fmt.Errorf("failed to create TLS client config with secret data: %w", err),
Reason: sourcev1.AuthenticationFailedReason,
}
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
// Requeue as content of secret might change
return sreconcile.ResultEmpty, e
}
tlsConfig = tls
// Build registryClient options from secret
loginOpt, err := registry.LoginOptionFromSecret(normalizedURL, *secret)
keychain, err = registry.LoginOptionFromSecret(normalizedURL, *secret)
if err != nil {
e := &serror.Event{
Err: fmt.Errorf("failed to configure Helm client with secret data: %w", err),
@ -515,10 +518,8 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *
// Requeue as content of secret might change
return sreconcile.ResultEmpty, e
}
loginOpts = append([]helmreg.LoginOption{}, loginOpt)
} else if repo.Spec.Provider != sourcev1.GenericOCIProvider && repo.Spec.Type == sourcev1.HelmRepositoryTypeOCI {
auth, authErr := oidcAuthFromAdapter(ctxTimeout, repo.Spec.URL, repo.Spec.Provider)
auth, authErr := oidcAuth(ctxTimeout, repo.Spec.URL, repo.Spec.Provider)
if authErr != nil && !errors.Is(authErr, oci.ErrUnconfiguredProvider) {
e := &serror.Event{
Err: fmt.Errorf("failed to get credential from %s: %w", repo.Spec.Provider, authErr),
@ -528,10 +529,20 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *
return sreconcile.ResultEmpty, e
}
if auth != nil {
loginOpts = append([]helmreg.LoginOption{}, auth)
authenticator = auth
}
}
loginOpt, err := makeLoginOption(authenticator, keychain, normalizedURL)
if err != nil {
e := &serror.Event{
Err: err,
Reason: sourcev1.AuthenticationFailedReason,
}
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
return sreconcile.ResultEmpty, e
}
// Initialize the chart repository
var chartRepo repository.Downloader
switch repo.Spec.Type {
@ -545,7 +556,7 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *
// this is needed because otherwise the credentials are stored in ~/.docker/config.json.
// TODO@souleb: remove this once the registry move to Oras v2
// or rework to enable reusing credentials to avoid the unneccessary handshake operations
registryClient, credentialsFile, err := r.RegistryClientGenerator(loginOpts != nil)
registryClient, credentialsFile, err := r.RegistryClientGenerator(loginOpt != nil)
if err != nil {
e := &serror.Event{
Err: fmt.Errorf("failed to construct Helm client: %w", err),
@ -564,9 +575,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.Event{
Err: fmt.Errorf("failed to verify the signature using provider '%s': %w", provider, err),
Reason: 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,8 +606,8 @@ 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 loginOpts != nil {
err = ociChartRepo.Login(loginOpts...)
if loginOpt != nil {
err = ociChartRepo.Login(loginOpt)
if err != nil {
e := &serror.Event{
Err: fmt.Errorf("failed to login to OCI registry: %w", err),
@ -622,6 +654,10 @@ 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 exists with the same name and version and `Force` is false.
// It will however try to verify the chart if `obj.Spec.Verify` is set, at every reconciliation.
Verify: obj.Spec.Verify != nil && obj.Spec.Verify.Provider != "",
}
if artifact := obj.GetArtifact(); artifact != nil {
opts.CachedChart = r.Storage.LocalPath(*artifact)
@ -941,8 +977,9 @@ func (r *HelmChartReconciler) garbageCollect(ctx context.Context, obj *sourcev1.
func (r *HelmChartReconciler) namespacedChartRepositoryCallback(ctx context.Context, name, namespace string) chart.GetChartDownloaderCallback {
return func(url string) (repository.Downloader, error) {
var (
tlsConfig *tls.Config
loginOpts []helmreg.LoginOption
tlsConfig *tls.Config
authenticator authn.Authenticator
keychain authn.Keychain
)
normalizedURL := repository.NormalizeURL(url)
repo, err := r.resolveDependencyRepository(ctx, url, namespace)
@ -972,37 +1009,39 @@ func (r *HelmChartReconciler) namespacedChartRepositoryCallback(ctx context.Cont
if err != nil {
return nil, err
}
opts, err := getter.ClientOptionsFromSecret(*secret)
// Build client options from secret
opts, tls, err := r.clientOptionsFromSecret(secret, normalizedURL)
if err != nil {
return nil, err
}
clientOpts = append(clientOpts, opts...)
tlsConfig, err = getter.TLSClientConfigFromSecret(*secret, normalizedURL)
if err != nil {
return nil, fmt.Errorf("failed to create TLS client config for HelmRepository '%s': %w", repo.Name, err)
}
tlsConfig = tls
// Build registryClient options from secret
loginOpt, err := registry.LoginOptionFromSecret(normalizedURL, *secret)
keychain, err = registry.LoginOptionFromSecret(normalizedURL, *secret)
if err != nil {
return nil, fmt.Errorf("failed to create login options for HelmRepository '%s': %w", repo.Name, err)
}
loginOpts = append([]helmreg.LoginOption{}, loginOpt)
} else if repo.Spec.Provider != sourcev1.GenericOCIProvider && repo.Spec.Type == sourcev1.HelmRepositoryTypeOCI {
auth, authErr := oidcAuthFromAdapter(ctxTimeout, repo.Spec.URL, repo.Spec.Provider)
auth, authErr := oidcAuth(ctxTimeout, repo.Spec.URL, repo.Spec.Provider)
if authErr != nil && !errors.Is(authErr, oci.ErrUnconfiguredProvider) {
return nil, fmt.Errorf("failed to get credential from %s: %w", repo.Spec.Provider, authErr)
}
if auth != nil {
loginOpts = append([]helmreg.LoginOption{}, auth)
authenticator = auth
}
}
loginOpt, err := makeLoginOption(authenticator, keychain, normalizedURL)
if err != nil {
return nil, err
}
var chartRepo repository.Downloader
if helmreg.IsOCI(normalizedURL) {
registryClient, credentialsFile, err := r.RegistryClientGenerator(loginOpts != nil)
registryClient, credentialsFile, err := r.RegistryClientGenerator(loginOpt != nil)
if err != nil {
return nil, fmt.Errorf("failed to create registry client for HelmRepository '%s': %w", repo.Name, err)
}
@ -1027,8 +1066,8 @@ 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 loginOpts != nil {
err = ociChartRepo.Login(loginOpts...)
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))
// clean up the credentialsFile
@ -1078,6 +1117,20 @@ func (r *HelmChartReconciler) resolveDependencyRepository(ctx context.Context, u
return nil, fmt.Errorf("no HelmRepository found for '%s' in '%s' namespace", url, namespace)
}
func (r *HelmChartReconciler) clientOptionsFromSecret(secret *corev1.Secret, normalizedURL string) ([]helmgetter.Option, *tls.Config, error) {
opts, err := getter.ClientOptionsFromSecret(*secret)
if err != nil {
return nil, nil, fmt.Errorf("failed to configure Helm client with secret data: %w", err)
}
tlsConfig, err := getter.TLSClientConfigFromSecret(*secret, normalizedURL)
if err != nil {
return nil, nil, fmt.Errorf("failed to create TLS client config with secret data: %w", err)
}
return opts, tlsConfig, nil
}
func (r *HelmChartReconciler) getHelmRepositorySecret(ctx context.Context, repository *sourcev1.HelmRepository) (*corev1.Secret, error) {
if repository.Spec.SecretRef == nil {
return nil, nil
@ -1222,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 {
@ -1237,6 +1295,10 @@ func observeChartBuild(obj *sourcev1.HelmChart, build *chart.Build, err error) {
case chart.ErrChartMetadataPatch, chart.ErrValuesFilesMerge, chart.ErrDependencyBuild, chart.ErrChartPackage:
conditions.Delete(obj, sourcev1.FetchFailedCondition)
conditions.MarkTrue(obj, sourcev1.BuildFailedCondition, buildErr.Reason.Reason, buildErr.Error())
case chart.ErrChartVerification:
conditions.Delete(obj, sourcev1.FetchFailedCondition)
conditions.MarkTrue(obj, sourcev1.BuildFailedCondition, buildErr.Reason.Reason, buildErr.Error())
conditions.MarkFalse(obj, sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, buildErr.Error())
default:
conditions.Delete(obj, sourcev1.BuildFailedCondition)
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, buildErr.Reason.Reason, buildErr.Error())
@ -1273,3 +1335,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)
}
}

View File

@ -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,230 @@ func TestHelmChartReconciler_reconcileSourceFromOCI_authStrategy(t *testing.T) {
}
}
func TestHelmChartReconciler_reconcileSourceFromOCI_verifySignature(t *testing.T) {
g := NewWithT(t)
tmpDir := t.TempDir()
server, err := setupRegistryServer(ctx, tmpDir, registryOptions{})
g.Expect(err).ToNot(HaveOccurred())
const (
chartPath = "testdata/charts/helmchart-0.1.0.tgz"
)
// Load a test chart
chartData, err := ioutil.ReadFile(chartPath)
// Upload the test chart
metadata, err := loadTestChartToOCI(chartData, chartPath, server)
g.Expect(err).NotTo(HaveOccurred())
storage, err := NewStorage(tmpDir, "example.com", retentionTTL, retentionRecords)
g.Expect(err).ToNot(HaveOccurred())
cachedArtifact := &sourcev1.Artifact{
Revision: "0.1.0",
Path: metadata.Name + "-" + metadata.Version + ".tgz",
}
g.Expect(storage.CopyFromPath(cachedArtifact, "testdata/charts/helmchart-0.1.0.tgz")).To(Succeed())
pf := func(b bool) ([]byte, error) {
return []byte("cosign-password"), nil
}
keys, err := cosign.GenerateKeyPair(pf)
g.Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(path.Join(tmpDir, "cosign.key"), keys.PrivateBytes, 0600)
g.Expect(err).ToNot(HaveOccurred())
defer func() {
err := os.Remove(path.Join(tmpDir, "cosign.key"))
g.Expect(err).ToNot(HaveOccurred())
}()
tests := []struct {
name string
shouldSign bool
beforeFunc func(obj *sourcev1.HelmChart)
want sreconcile.Result
wantErr bool
wantErrMsg string
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"},
}
},
want: sreconcile.ResultEmpty,
wantErr: true,
wantErrMsg: "chart verification error: failed to verify <url>: no matching signatures:",
assertConditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.BuildFailedCondition, "ChartVerificationError", "chart verification error: failed to verify <url>: no matching signatures:"),
*conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "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",
}
},
want: sreconcile.ResultEmpty,
wantErr: true,
assertConditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.BuildFailedCondition, "ChartVerificationError", "chart verification error: failed to verify <url>: no matching signatures:"),
*conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "chart verification error: failed to verify <url>: no matching signatures:"),
},
},
{
name: "signed charts should pass verification",
shouldSign: true,
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"},
}
},
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))

View File

@ -45,6 +45,7 @@ import (
helper "github.com/fluxcd/pkg/runtime/controller"
"github.com/fluxcd/pkg/runtime/patch"
"github.com/fluxcd/pkg/runtime/predicates"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/fluxcd/source-controller/api/v1beta2"
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
@ -263,36 +264,21 @@ func (r *HelmRepositoryOCIReconciler) reconcile(ctx context.Context, obj *v1beta
}
conditions.Delete(obj, meta.StalledCondition)
var loginOpts []helmreg.LoginOption
var (
authenticator authn.Authenticator
keychain authn.Keychain
err error
)
// Configure any authentication related options.
if obj.Spec.SecretRef != nil {
// Attempt to retrieve secret.
name := types.NamespacedName{
Namespace: obj.GetNamespace(),
Name: obj.Spec.SecretRef.Name,
}
var secret corev1.Secret
if err := r.Client.Get(ctx, name, &secret); err != nil {
e := fmt.Errorf("failed to get secret '%s': %w", name.String(), err)
conditions.MarkFalse(obj, meta.ReadyCondition, sourcev1.AuthenticationFailedReason, e.Error())
result, retErr = ctrl.Result{}, e
return
}
// Construct login options.
loginOpt, err := registry.LoginOptionFromSecret(obj.Spec.URL, secret)
keychain, err = authFromSecret(ctx, r.Client, obj)
if err != nil {
e := fmt.Errorf("failed to configure Helm client with secret data: %w", err)
conditions.MarkFalse(obj, meta.ReadyCondition, sourcev1.AuthenticationFailedReason, e.Error())
result, retErr = ctrl.Result{}, e
conditions.MarkFalse(obj, meta.ReadyCondition, sourcev1.AuthenticationFailedReason, err.Error())
result, retErr = ctrl.Result{}, err
return
}
if loginOpt != nil {
loginOpts = append(loginOpts, loginOpt)
}
} else if obj.Spec.Provider != sourcev1.GenericOCIProvider && obj.Spec.Type == sourcev1.HelmRepositoryTypeOCI {
auth, authErr := oidcAuthFromAdapter(ctxTimeout, obj.Spec.URL, obj.Spec.Provider)
auth, authErr := oidcAuth(ctxTimeout, obj.Spec.URL, obj.Spec.Provider)
if authErr != nil && !errors.Is(authErr, oci.ErrUnconfiguredProvider) {
e := fmt.Errorf("failed to get credential from %s: %w", obj.Spec.Provider, authErr)
conditions.MarkFalse(obj, meta.ReadyCondition, sourcev1.AuthenticationFailedReason, e.Error())
@ -300,12 +286,19 @@ func (r *HelmRepositoryOCIReconciler) reconcile(ctx context.Context, obj *v1beta
return
}
if auth != nil {
loginOpts = append(loginOpts, auth)
authenticator = auth
}
}
loginOpt, err := makeLoginOption(authenticator, keychain, obj.Spec.URL)
if err != nil {
conditions.MarkFalse(obj, meta.ReadyCondition, sourcev1.AuthenticationFailedReason, err.Error())
result, retErr = ctrl.Result{}, err
return
}
// Create registry client and login if needed.
registryClient, file, err := r.RegistryClientGenerator(loginOpts != nil)
registryClient, file, err := r.RegistryClientGenerator(loginOpt != nil)
if err != nil {
e := fmt.Errorf("failed to create registry client: %w", err)
conditions.MarkFalse(obj, meta.ReadyCondition, meta.FailedReason, e.Error())
@ -332,8 +325,8 @@ func (r *HelmRepositoryOCIReconciler) reconcile(ctx context.Context, obj *v1beta
conditions.Delete(obj, meta.StalledCondition)
// Attempt to login to the registry if credentials are provided.
if loginOpts != nil {
err = chartRepo.Login(loginOpts...)
if loginOpt != nil {
err = chartRepo.Login(loginOpt)
if err != nil {
e := fmt.Errorf("failed to login to registry '%s': %w", obj.Spec.URL, err)
conditions.MarkFalse(obj, meta.ReadyCondition, sourcev1.AuthenticationFailedReason, e.Error())
@ -375,16 +368,37 @@ func (r *HelmRepositoryOCIReconciler) eventLogf(ctx context.Context, obj runtime
r.Eventf(obj, eventType, reason, msg)
}
// oidcAuthFromAdapter generates the OIDC credential authenticator based on the specified cloud provider.
func oidcAuthFromAdapter(ctx context.Context, url, provider string) (helmreg.LoginOption, error) {
auth, err := oidcAuth(ctx, url, provider)
// authFromSecret returns an authn.Keychain for the given HelmRepository.
// If the HelmRepository does not specify a secretRef, an anonymous keychain is returned.
func authFromSecret(ctx context.Context, client client.Client, obj *sourcev1.HelmRepository) (authn.Keychain, error) {
// Attempt to retrieve secret.
name := types.NamespacedName{
Namespace: obj.GetNamespace(),
Name: obj.Spec.SecretRef.Name,
}
var secret corev1.Secret
if err := client.Get(ctx, name, &secret); err != nil {
return nil, fmt.Errorf("failed to get secret '%s': %w", name.String(), err)
}
// Construct login options.
keychain, err := registry.LoginOptionFromSecret(obj.Spec.URL, secret)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to configure Helm client with secret data: %w", err)
}
if auth == nil {
return nil, fmt.Errorf("could not validate OCI provider %s with URL %s", provider, url)
}
return registry.OIDCAdaptHelper(auth)
return keychain, nil
}
// makeLoginOption returns a registry login option for the given HelmRepository.
// If the HelmRepository does not specify a secretRef, a nil login option is returned.
func makeLoginOption(auth authn.Authenticator, keychain authn.Keychain, registryURL string) (helmreg.LoginOption, error) {
if auth != nil {
return registry.AuthAdaptHelper(auth)
}
if keychain != nil {
return registry.KeychainAdaptHelper(keychain)(registryURL)
}
return nil, nil
}

View File

@ -220,15 +220,6 @@ func TestHelmRepositoryOCIReconciler_authStrategy(t *testing.T) {
*conditions.FalseCondition(meta.ReadyCondition, sourcev1.AuthenticationFailedReason, "failed to get credential from"),
},
},
{
name: "with contextual login provider and invalid repository URL",
wantErr: true,
provider: "aws",
providerImg: "oci://123456789000.dkr.ecr.us-east-2.amazonaws.com",
assertConditions: []metav1.Condition{
*conditions.FalseCondition(meta.ReadyCondition, sourcev1.AuthenticationFailedReason, "failed to get credential from"),
},
},
{
name: "with contextual login provider and secretRef",
want: ctrl.Result{RequeueAfter: interval},

View File

@ -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
}

View File

@ -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 when using HelmRepository source with spec.type &lsquo;oci&rsquo;.
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 when using HelmRepository source with spec.type &lsquo;oci&rsquo;.
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>

View File

@ -240,6 +240,103 @@ in a new Artifact. When the field is set to `false` or removed, it will resume.
For practical information, see
[suspending and resuming](#suspending-and-resuming).
### Verification
**Note:** This feature is available only for Helm charts fetched from an OCI Registry.
`.spec.verify` is an optional field to enable the verification of [Cosign](https://github.com/sigstore/cosign)
signatures. The field offers two subfields:
- `.provider`, to specify the verification provider. Only supports `cosign` at present.
- `.secretRef.name`, to specify a reference to a Secret in the same namespace as
the HelmChart, containing the Cosign public keys of trusted authors.
```yaml
---
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: HelmChart
metadata:
name: podinfo
spec:
verify:
provider: cosign
secretRef:
name: cosign-public-keys
```
When the verification succeeds, the controller adds a Condition with the
following attributes to the HelmChart's `.status.conditions`:
- `type: SourceVerified`
- `status: "True"`
- `reason: Succeeded`
#### Public keys verification
To verify the authenticity of HelmChart hosted in an OCI Registry, create a Kubernetes
secret with the Cosign public keys:
```yaml
---
apiVersion: v1
kind: Secret
metadata:
name: cosign-public-keys
type: Opaque
data:
key1.pub: <BASE64>
key2.pub: <BASE64>
```
Note that the keys must have the `.pub` extension for Flux to make use of them.
Flux will loop over the public keys and use them to verify a HelmChart's signature.
This allows for older HelmCharts to be valid as long as the right key is in the secret.
#### Keyless verification
For publicly available HelmCharts, which are signed using the
[Cosign Keyless](https://github.com/sigstore/cosign/blob/main/KEYLESS.md) procedure,
you can enable the verification by omitting the `.verify.secretRef` field.
Example of verifying HelmCharts signed by the
[Cosign GitHub Action](https://github.com/sigstore/cosign-installer) with GitHub OIDC Token:
```yaml
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: HelmChart
metadata:
name: podinfo
spec:
interval: 5m
chart: podinfo
reconcileStrategy: ChartVersion
sourceRef:
kind: HelmRepository
name: podinfo
version: ">=6.1.6"
verify:
provider: cosign
```
```yaml
---
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: HelmRepository
metadata:
name: podinfo
spec:
interval: 1m0s
url: oci://ghcr.io/stefanprodan/charts
type: "oci"
```
The controller verifies the signatures using the Fulcio root CA and the Rekor
instance hosted at [rekor.sigstore.dev](https://rekor.sigstore.dev/).
Note that keyless verification is an **experimental feature**, using
custom root CAs or self-hosted Rekor instances are not currently supported.
## Working with HelmCharts
### Triggering a reconcile

View File

@ -462,6 +462,9 @@ data:
Note that the keys must have the `.pub` extension for Flux to make use of them.
Flux will loop over the public keys and use them to verify an artifact's signature.
This allows for older artifacts to be valid as long as the right key is in the secret.
#### Keyless verification
For publicly available OCI artifacts, which are signed using the

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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"}
)

View File

@ -23,27 +23,42 @@ import (
"github.com/docker/cli/cli/config"
"github.com/docker/cli/cli/config/credentials"
"github.com/fluxcd/source-controller/internal/oci"
"github.com/google/go-containerregistry/pkg/authn"
"helm.sh/helm/v3/pkg/registry"
corev1 "k8s.io/api/core/v1"
)
// helper is a subset of the Docker credential helper credentials.Helper interface used by NewKeychainFromHelper.
type helper struct {
registry string
username, password string
err error
}
func (h helper) Get(serverURL string) (string, string, error) {
if serverURL != h.registry {
return "", "", fmt.Errorf("unexpected serverURL: %s", serverURL)
}
return h.username, h.password, h.err
}
// LoginOptionFromSecret derives authentication data from a Secret to login to an OCI registry. This Secret
// may either hold "username" and "password" fields or be of the corev1.SecretTypeDockerConfigJson type and hold
// a corev1.DockerConfigJsonKey field with a complete Docker configuration. If both, "username" and "password" are
// empty, a nil LoginOption and a nil error will be returned.
func LoginOptionFromSecret(registryURL string, secret corev1.Secret) (registry.LoginOption, error) {
func LoginOptionFromSecret(registryURL string, secret corev1.Secret) (authn.Keychain, error) {
var username, password string
parsedURL, err := url.Parse(registryURL)
if err != nil {
return nil, fmt.Errorf("unable to parse registry URL '%s' while reconciling Secret '%s': %w",
registryURL, secret.Name, err)
}
if secret.Type == corev1.SecretTypeDockerConfigJson {
dockerCfg, err := config.LoadFromReader(bytes.NewReader(secret.Data[corev1.DockerConfigJsonKey]))
if err != nil {
return nil, fmt.Errorf("unable to load Docker config from Secret '%s': %w", secret.Name, err)
}
parsedURL, err := url.Parse(registryURL)
if err != nil {
return nil, fmt.Errorf("unable to parse registry URL '%s' while reconciling Secret '%s': %w",
registryURL, secret.Name, err)
}
authConfig, err := dockerCfg.GetAuthConfig(parsedURL.Host)
if err != nil {
return nil, fmt.Errorf("unable to get authentication data from Secret '%s': %w", secret.Name, err)
@ -63,19 +78,38 @@ func LoginOptionFromSecret(registryURL string, secret corev1.Secret) (registry.L
}
switch {
case username == "" && password == "":
return nil, nil
return oci.Anonymous{}, nil
case username == "" || password == "":
return nil, fmt.Errorf("invalid '%s' secret data: required fields 'username' and 'password'", secret.Name)
}
return registry.LoginOptBasicAuth(username, password), nil
return authn.NewKeychainFromHelper(helper{registry: parsedURL.Host, username: username, password: password}), nil
}
// OIDCAdaptHelper returns an ORAS credentials callback configured with the authorization data
// KeyChainAdaptHelper returns an ORAS credentials callback configured with the authorization data
// from the given authn keychain. This allows for example to make use of credential helpers from
// cloud providers.
// Ref: https://github.com/google/go-containerregistry/tree/main/pkg/authn
func KeychainAdaptHelper(keyChain authn.Keychain) func(string) (registry.LoginOption, error) {
return func(registryURL string) (registry.LoginOption, error) {
parsedURL, err := url.Parse(registryURL)
if err != nil {
return nil, fmt.Errorf("unable to parse registry URL '%s'", registryURL)
}
authenticator, err := keyChain.Resolve(stringResource{parsedURL.Host})
if err != nil {
return nil, fmt.Errorf("unable to resolve credentials for registry '%s': %w", registryURL, err)
}
return AuthAdaptHelper(authenticator)
}
}
// AuthAdaptHelper returns an ORAS credentials callback configured with the authorization data
// from the given authn authenticator. This allows for example to make use of credential helpers from
// cloud providers.
// Ref: https://github.com/google/go-containerregistry/tree/main/pkg/authn
func OIDCAdaptHelper(authenticator authn.Authenticator) (registry.LoginOption, error) {
authConfig, err := authenticator.Authorization()
func AuthAdaptHelper(auth authn.Authenticator) (registry.LoginOption, error) {
authConfig, err := auth.Authorization()
if err != nil {
return nil, fmt.Errorf("unable to get authentication data from OIDC: %w", err)
}
@ -91,3 +125,17 @@ func OIDCAdaptHelper(authenticator authn.Authenticator) (registry.LoginOption, e
}
return registry.LoginOptBasicAuth(username, password), nil
}
// stringResource is there to satisfy the github.com/google/go-containerregistry/pkg/authn.Resource interface.
// It merely wraps a given string and returns it for all of the interface's methods.
type stringResource struct {
registry string
}
func (r stringResource) String() string {
return r.registry
}
func (r stringResource) RegistryStr() string {
return r.registry
}

View File

@ -17,6 +17,7 @@ limitations under the License.
package registry
import (
"net/url"
"testing"
"github.com/google/go-containerregistry/pkg/authn"
@ -24,6 +25,8 @@ import (
corev1 "k8s.io/api/core/v1"
)
const repoURL = "https://example.com"
func TestLoginOptionFromSecret(t *testing.T) {
testURL := "oci://registry.example.com/foo/bar"
testUser := "flux"
@ -131,33 +134,40 @@ func TestLoginOptionFromSecret(t *testing.T) {
}
}
func TestOIDCAdaptHelper(t *testing.T) {
auth := &authn.Basic{
Username: "flux",
Password: "flux_password",
func TestKeychainAdaptHelper(t *testing.T) {
g := NewWithT(t)
reg, err := url.Parse(repoURL)
if err != nil {
g.Expect(err).ToNot(HaveOccurred())
}
auth := helper{
username: "flux",
password: "flux_password",
registry: reg.Host,
}
tests := []struct {
name string
auth authn.Authenticator
auth authn.Keychain
expectedLogin bool
wantErr bool
}{
{
name: "Login from basic auth with empty auth",
auth: &authn.Basic{},
auth: authn.NewKeychainFromHelper(helper{}),
expectedLogin: false,
wantErr: false,
},
{
name: "Login from basic auth",
auth: auth,
auth: authn.NewKeychainFromHelper(auth),
expectedLogin: true,
wantErr: false,
},
{
name: "Login with missing password",
auth: &authn.Basic{Username: "flux"},
auth: authn.NewKeychainFromHelper(helper{username: "flux", registry: reg.Host}),
expectedLogin: false,
wantErr: true,
},
@ -166,7 +176,7 @@ func TestOIDCAdaptHelper(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
loginOpt, err := OIDCAdaptHelper(tt.auth)
loginOpt, err := KeychainAdaptHelper(tt.auth)(repoURL)
if tt.wantErr {
g.Expect(err).To(HaveOccurred())
return

View File

@ -18,6 +18,7 @@ package repository
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
@ -520,3 +521,10 @@ func (r *ChartRepository) RemoveCache() error {
}
return nil
}
// VerifyChart verifies the chart against a signature.
// It returns an error on failure.
func (r *ChartRepository) VerifyChart(_ context.Context, _ *repo.ChartVersion) error {
// this is a no-op because this is not implemented yet.
return fmt.Errorf("not implemented")
}

View File

@ -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 {
@ -296,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())
}

View File

@ -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

View File

@ -34,6 +34,11 @@ 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
@ -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
}