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:
Soule BA 2022-10-03 17:07:00 +02:00
parent 55dd799dad
commit 0e97547eeb
No known key found for this signature in database
GPG Key ID: 4D40965192802994
16 changed files with 522 additions and 18 deletions

View File

@ -86,6 +86,14 @@ type HelmChartSpec struct {
// NOTE: Not implemented, provisional as of https://github.com/fluxcd/flux2/pull/2092 // NOTE: Not implemented, provisional as of https://github.com/fluxcd/flux2/pull/2092
// +optional // +optional
AccessFrom *acl.AccessFrom `json:"accessFrom,omitempty"` 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 ( const (

View File

@ -464,6 +464,11 @@ func (in *HelmChartSpec) DeepCopyInto(out *HelmChartSpec) {
*out = new(acl.AccessFrom) *out = new(acl.AccessFrom)
(*in).DeepCopyInto(*out) (*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. // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmChartSpec.

View File

@ -403,6 +403,33 @@ spec:
items: items:
type: string type: string
type: array 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: version:
default: '*' default: '*'
description: Version is the chart version semver expression, ignored description: Version is the chart version semver expression, ignored

View File

@ -19,3 +19,17 @@ spec:
name: podinfo name: podinfo
version: '6.1.*' version: '6.1.*'
interval: 1m 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" "strings"
"time" "time"
soci "github.com/fluxcd/source-controller/internal/oci"
helmgetter "helm.sh/helm/v3/pkg/getter" helmgetter "helm.sh/helm/v3/pkg/getter"
helmreg "helm.sh/helm/v3/pkg/registry" helmreg "helm.sh/helm/v3/pkg/registry"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
@ -57,6 +58,7 @@ import (
"github.com/fluxcd/pkg/runtime/predicates" "github.com/fluxcd/pkg/runtime/predicates"
"github.com/fluxcd/pkg/untar" "github.com/fluxcd/pkg/untar"
"github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/v1/remote"
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
"github.com/fluxcd/source-controller/internal/cache" "github.com/fluxcd/source-controller/internal/cache"
@ -80,6 +82,7 @@ var helmChartReadyCondition = summarize.Conditions{
sourcev1.BuildFailedCondition, sourcev1.BuildFailedCondition,
sourcev1.ArtifactOutdatedCondition, sourcev1.ArtifactOutdatedCondition,
sourcev1.ArtifactInStorageCondition, sourcev1.ArtifactInStorageCondition,
sourcev1.SourceVerifiedCondition,
meta.ReadyCondition, meta.ReadyCondition,
meta.ReconcilingCondition, meta.ReconcilingCondition,
meta.StalledCondition, meta.StalledCondition,
@ -90,6 +93,7 @@ var helmChartReadyCondition = summarize.Conditions{
sourcev1.BuildFailedCondition, sourcev1.BuildFailedCondition,
sourcev1.ArtifactOutdatedCondition, sourcev1.ArtifactOutdatedCondition,
sourcev1.ArtifactInStorageCondition, sourcev1.ArtifactInStorageCondition,
sourcev1.SourceVerifiedCondition,
meta.StalledCondition, meta.StalledCondition,
meta.ReconcilingCondition, 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 // Tell the chart repository to use the OCI client with the configured getter
clientOpts = append(clientOpts, helmgetter.WithRegistryClient(registryClient)) 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 { if err != nil {
return chartRepoConfigErrorReturn(err, obj) 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 // If login options are configured, use them to login to the registry
// The OCIGetter will later retrieve the stored credentials to pull the chart // The OCIGetter will later retrieve the stored credentials to pull the chart
if keychain != nil { if loginOpt != nil {
err = ociChartRepo.Login(loginOpt) err = ociChartRepo.Login(loginOpt)
if err != nil { if err != nil {
e := &serror.Event{ e := &serror.Event{
@ -622,6 +647,17 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *
opts := chart.BuildOptions{ opts := chart.BuildOptions{
ValuesFiles: obj.GetValuesFiles(), ValuesFiles: obj.GetValuesFiles(),
Force: obj.Generation != obj.Status.ObservedGeneration, 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 { if artifact := obj.GetArtifact(); artifact != nil {
opts.CachedChart = r.Storage.LocalPath(*artifact) 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 // If login options are configured, use them to login to the registry
// The OCIGetter will later retrieve the stored credentials to pull the chart // The OCIGetter will later retrieve the stored credentials to pull the chart
if keychain != nil { if loginOpt != nil {
err = ociChartRepo.Login(loginOpt) err = ociChartRepo.Login(loginOpt)
if err != nil { if err != nil {
errs = append(errs, fmt.Errorf("failed to login to OCI chart repository for HelmRepository '%s': %w", repo.Name, err)) 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() { if build.Complete() {
conditions.Delete(obj, sourcev1.FetchFailedCondition) conditions.Delete(obj, sourcev1.FetchFailedCondition)
conditions.Delete(obj, sourcev1.BuildFailedCondition) 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 { if err != nil {
@ -1251,7 +1292,7 @@ func observeChartBuild(obj *sourcev1.HelmChart, build *chart.Build, err error) {
} }
switch buildErr.Reason { 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.Delete(obj, sourcev1.FetchFailedCondition)
conditions.MarkTrue(obj, sourcev1.BuildFailedCondition, buildErr.Reason.Reason, buildErr.Error()) conditions.MarkTrue(obj, sourcev1.BuildFailedCondition, buildErr.Reason.Reason, buildErr.Error())
default: default:
@ -1290,3 +1331,60 @@ func chartRepoConfigErrorReturn(err error, obj *sourcev1.HelmChart) (sreconcile.
return sreconcile.ResultEmpty, e 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" "io/ioutil"
"net/http" "net/http"
"os" "os"
"path"
"path/filepath" "path/filepath"
"reflect" "reflect"
"strings" "strings"
@ -33,6 +34,9 @@ import (
"time" "time"
. "github.com/onsi/gomega" . "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" hchart "helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chart/loader" "helm.sh/helm/v3/pkg/chart/loader"
helmreg "helm.sh/helm/v3/pkg/registry" helmreg "helm.sh/helm/v3/pkg/registry"
@ -57,6 +61,7 @@ import (
serror "github.com/fluxcd/source-controller/internal/error" serror "github.com/fluxcd/source-controller/internal/error"
"github.com/fluxcd/source-controller/internal/helm/chart" "github.com/fluxcd/source-controller/internal/helm/chart"
"github.com/fluxcd/source-controller/internal/helm/registry" "github.com/fluxcd/source-controller/internal/helm/registry"
"github.com/fluxcd/source-controller/internal/oci"
sreconcile "github.com/fluxcd/source-controller/internal/reconcile" sreconcile "github.com/fluxcd/source-controller/internal/reconcile"
"github.com/fluxcd/source-controller/internal/reconcile/summarize" "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 // extractChartMeta is used to extract a chart metadata from a byte array
func extractChartMeta(chartData []byte) (*hchart.Metadata, error) { func extractChartMeta(chartData []byte) (*hchart.Metadata, error) {
ch, err := loader.LoadArchive(bytes.NewReader(chartData)) ch, err := loader.LoadArchive(bytes.NewReader(chartData))

View File

@ -628,7 +628,7 @@ func (r *OCIRepositoryReconciler) verifySignature(ctx context.Context, obj *sour
for k, data := range pubSecret.Data { for k, data := range pubSecret.Data {
// search for public keys in the secret // search for public keys in the secret
if strings.HasSuffix(k, ".pub") { 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 { if err != nil {
return err return err
} }
@ -654,7 +654,7 @@ func (r *OCIRepositoryReconciler) verifySignature(ctx context.Context, obj *sour
// if no secret is provided, try keyless verification // 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") 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 { if err != nil {
return err 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> 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> </td>
</tr> </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> </table>
</td> </td>
</tr> </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> 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> </td>
</tr> </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> </tbody>
</table> </table>
</div> </div>
@ -3123,6 +3159,7 @@ github.com/fluxcd/pkg/apis/meta.ReconcileRequestStatus
</h3> </h3>
<p> <p>
(<em>Appears on:</em> (<em>Appears on:</em>
<a href="#source.toolkit.fluxcd.io/v1beta2.HelmChartSpec">HelmChartSpec</a>,
<a href="#source.toolkit.fluxcd.io/v1beta2.OCIRepositorySpec">OCIRepositorySpec</a>) <a href="#source.toolkit.fluxcd.io/v1beta2.OCIRepositorySpec">OCIRepositorySpec</a>)
</p> </p>
<p>OCIRepositoryVerification verifies the authenticity of an OCI Artifact</p> <p>OCIRepositoryVerification verifies the authenticity of an OCI Artifact</p>

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 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 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 --for=condition=ready --timeout=1m
kubectl -n source-system wait helmchart/podinfo-keyless --for=condition=ready --timeout=1m
echo "Run OCIRepository verify tests" echo "Run OCIRepository verify tests"
kubectl -n source-system apply -f "${ROOT_DIR}/config/testdata/ocirepository/signed-with-key.yaml" 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 // Force can be set to force the build of the chart, for example
// because the list of ValuesFiles has changed. // because the list of ValuesFiles has changed.
Force bool Force bool
// Verifier can be set to the verification of the chart.
Verify bool
} }
// GetValuesFiles returns BuildOptions.ValuesFiles, except if it equals // 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 // 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 // modifying the chart, otherwise the exact data as retrieved from the repository
// is written to p, after validating it to be a chart. // 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) remoteRef, ok := ref.(RemoteReference)
if !ok { if !ok {
err := fmt.Errorf("expected remote chart reference") 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} 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 { if err != nil {
return nil, &BuildError{Reason: ErrChartPull, Err: err} return nil, err
} }
if res == nil { if res == nil {
return result, nil return result, nil
@ -124,7 +124,7 @@ func (b *remoteChartBuilder) Build(_ context.Context, ref Reference, p string, o
return result, nil 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 // Get the current version for the RemoteReference
cv, err := remote.GetChartVersion(remoteRef.Name, remoteRef.Version) cv, err := remote.GetChartVersion(remoteRef.Name, remoteRef.Version)
if err != nil { if err != nil {
@ -132,6 +132,13 @@ func (b *remoteChartBuilder) downloadFromRepository(remote repository.Downloader
return nil, nil, &BuildError{Reason: ErrChartReference, Err: err} 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) result, shouldReturn, err := generateBuildResult(cv, opts)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err

View File

@ -84,5 +84,6 @@ var (
ErrValuesFilesMerge = BuildErrorReason{Reason: "ValuesFilesError", Summary: "values files merge error"} ErrValuesFilesMerge = BuildErrorReason{Reason: "ValuesFilesError", Summary: "values files merge error"}
ErrDependencyBuild = BuildErrorReason{Reason: "DependencyBuildError", Summary: "dependency build error"} ErrDependencyBuild = BuildErrorReason{Reason: "DependencyBuildError", Summary: "dependency build error"}
ErrChartPackage = BuildErrorReason{Reason: "ChartPackageError", Summary: "chart package 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"} ErrUnknown = BuildErrorReason{Reason: "Unknown", Summary: "unknown build error"}
) )

View File

@ -18,6 +18,7 @@ package repository
import ( import (
"bytes" "bytes"
"context"
"crypto/sha256" "crypto/sha256"
"crypto/tls" "crypto/tls"
"encoding/hex" "encoding/hex"
@ -520,3 +521,12 @@ func (r *ChartRepository) RemoveCache() error {
} }
return nil 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
}

View File

@ -18,6 +18,7 @@ package repository
import ( import (
"bytes" "bytes"
"context"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"net/url" "net/url"
@ -32,7 +33,10 @@ import (
"helm.sh/helm/v3/pkg/repo" "helm.sh/helm/v3/pkg/repo"
"github.com/Masterminds/semver/v3" "github.com/Masterminds/semver/v3"
"github.com/google/go-containerregistry/pkg/name"
"github.com/fluxcd/pkg/version" "github.com/fluxcd/pkg/version"
"github.com/fluxcd/source-controller/internal/oci"
"github.com/fluxcd/source-controller/internal/transport" "github.com/fluxcd/source-controller/internal/transport"
) )
@ -63,12 +67,23 @@ type OCIChartRepository struct {
RegistryClient RegistryClient RegistryClient RegistryClient
// credentialsFile is a temporary credentials file to use while downloading tags or charts from a registry. // credentialsFile is a temporary credentials file to use while downloading tags or charts from a registry.
credentialsFile string 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 // OCIChartRepositoryOption is a function that can be passed to NewOCIChartRepository
// to configure an OCIChartRepository. // to configure an OCIChartRepository.
type OCIChartRepositoryOption func(*OCIChartRepository) error 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 // WithOCIRegistryClient returns a ChartRepositoryOption that will set the registry client
func WithOCIRegistryClient(client RegistryClient) OCIChartRepositoryOption { func WithOCIRegistryClient(client RegistryClient) OCIChartRepositoryOption {
return func(r *OCIChartRepository) error { 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. // Login attempts to login to the OCI registry.
// It returns an error on failure. // It returns an error on failure.
func (r *OCIChartRepository) Login(opts ...registry.LoginOption) error { func (r *OCIChartRepository) Login(opts ...registry.LoginOption) error {
// Get login credentials from keychain
err := r.RegistryClient.Login(r.URL.Host, opts...) err := r.RegistryClient.Login(r.URL.Host, opts...)
if err != nil { if err != nil {
return err return err
@ -297,3 +311,32 @@ func getLastMatchingVersionOrConstraint(cvs []string, ver string) (string, error
return matchingVersions[0].Original(), nil 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 ( import (
"bytes" "bytes"
"context"
"helm.sh/helm/v3/pkg/repo" "helm.sh/helm/v3/pkg/repo"
) )
@ -29,6 +30,8 @@ type Downloader interface {
GetChartVersion(name, version string) (*repo.ChartVersion, error) GetChartVersion(name, version string) (*repo.ChartVersion, error)
// DownloadChart downloads a chart from the remote Helm repository or OCI Helm repository. // DownloadChart downloads a chart from the remote Helm repository or OCI Helm repository.
DownloadChart(chart *repo.ChartVersion) (*bytes.Buffer, error) 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, // 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. // and calling garbage collector to remove unused files.
Clear() error Clear() error

View File

@ -34,13 +34,18 @@ import (
"github.com/sigstore/sigstore/pkg/signature" "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. // options is a struct that holds options for verifier.
type options struct { type options struct {
PublicKey []byte PublicKey []byte
ROpt []remote.Option 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) type Options func(opts *options)
// WithPublicKey sets the public key. // 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. // CosignVerifier is a struct which is responsible for executing verification logic.
type Verifier struct { type CosignVerifier struct {
opts *cosign.CheckOpts opts *cosign.CheckOpts
} }
// NewVerifier initializes a new Verifier. // NewCosignVerifier initializes a new CosignVerifier.
func NewVerifier(ctx context.Context, opts ...Options) (*Verifier, error) { func NewCosignVerifier(ctx context.Context, opts ...Options) (*CosignVerifier, error) {
o := options{} o := options{}
for _, opt := range opts { for _, opt := range opts {
opt(&o) opt(&o)
@ -117,12 +122,28 @@ func NewVerifier(ctx context.Context, opts ...Options) (*Verifier, error) {
checkOpts.RekorClient = rc checkOpts.RekorClient = rc
} }
return &Verifier{ return &CosignVerifier{
opts: checkOpts, opts: checkOpts,
}, nil }, nil
} }
// VerifyImageSignatures verify the authenticity of the given ref OCI image. // 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) 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
}