Skip to content

Commit c9a5a56

Browse files
authored
Merge pull request #876 from developer-guy/feature/863
[RFC-0003] Implement OCIRepository verification using Cosign
2 parents 54d706a + 3b637a8 commit c9a5a56

File tree

16 files changed

+1844
-89
lines changed

16 files changed

+1844
-89
lines changed

api/v1beta2/condition_types.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ const (
7171
// required fields, or the provided credentials do not match.
7272
AuthenticationFailedReason string = "AuthenticationFailed"
7373

74+
// VerificationError signals that the Source's verification
75+
// check failed.
76+
VerificationError string = "VerificationError"
77+
7478
// DirCreationFailedReason signals a failure caused by a directory creation
7579
// operation.
7680
DirCreationFailedReason string = "DirectoryCreationFailed"

api/v1beta2/ocirepository_types.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ type OCIRepositorySpec struct {
7878
// +optional
7979
SecretRef *meta.LocalObjectReference `json:"secretRef,omitempty"`
8080

81+
// Verify contains the secret name containing the trusted public keys
82+
// used to verify the signature and specifies which provider to use to check
83+
// whether OCI image is authentic.
84+
// +optional
85+
Verify *OCIRepositoryVerification `json:"verify,omitempty"`
86+
8187
// ServiceAccountName is the name of the Kubernetes ServiceAccount used to authenticate
8288
// the image pull if the service account has attached pull secrets. For more information:
8389
// https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#add-imagepullsecrets-to-a-service-account
@@ -156,11 +162,13 @@ type OCILayerSelector struct {
156162
type OCIRepositoryVerification struct {
157163
// Provider specifies the technology used to sign the OCI Artifact.
158164
// +kubebuilder:validation:Enum=cosign
165+
// +kubebuilder:default:=cosign
159166
Provider string `json:"provider"`
160167

161168
// SecretRef specifies the Kubernetes Secret containing the
162169
// trusted public keys.
163-
SecretRef meta.LocalObjectReference `json:"secretRef"`
170+
// +optional
171+
SecretRef *meta.LocalObjectReference `json:"secretRef,omitempty"`
164172
}
165173

166174
// OCIRepositoryStatus defines the observed state of OCIRepository

api/v1beta2/zz_generated.deepcopy.go

Lines changed: 10 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,31 @@ spec:
148148
on a remote container registry.
149149
pattern: ^oci://.*$
150150
type: string
151+
verify:
152+
description: Verify contains the secret name containing the trusted
153+
public keys used to verify the signature and specifies which provider
154+
to use to check whether OCI image is authentic.
155+
properties:
156+
provider:
157+
default: cosign
158+
description: Provider specifies the technology used to sign the
159+
OCI Artifact.
160+
enum:
161+
- cosign
162+
type: string
163+
secretRef:
164+
description: SecretRef specifies the Kubernetes Secret containing
165+
the trusted public keys.
166+
properties:
167+
name:
168+
description: Name of the referent.
169+
type: string
170+
required:
171+
- name
172+
type: object
173+
required:
174+
- provider
175+
type: object
151176
required:
152177
- interval
153178
- url

config/manager/deployment.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ spec:
5151
valueFrom:
5252
fieldRef:
5353
fieldPath: metadata.namespace
54+
- name: TUF_ROOT # store the Fulcio root CA file in tmp
55+
value: "/tmp/.sigstore"
5456
args:
5557
- --watch-all-namespaces
5658
- --log-level=info
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
apiVersion: source.toolkit.fluxcd.io/v1beta2
3+
kind: OCIRepository
4+
metadata:
5+
name: podinfo-deploy-signed-with-key
6+
spec:
7+
interval: 5m
8+
url: oci://ghcr.io/stefanprodan/podinfo-deploy
9+
ref:
10+
semver: "6.2.x"
11+
verify:
12+
provider: cosign
13+
secretRef:
14+
name: cosign-key
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
apiVersion: source.toolkit.fluxcd.io/v1beta2
3+
kind: OCIRepository
4+
metadata:
5+
name: podinfo-deploy-signed-with-keyless
6+
spec:
7+
interval: 5m
8+
url: oci://ghcr.io/stefanprodan/manifests/podinfo
9+
ref:
10+
semver: "6.2.x"
11+
verify:
12+
provider: cosign

controllers/ocirepository_controller.go

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"time"
3030

3131
"github.com/Masterminds/semver/v3"
32+
soci "github.com/fluxcd/source-controller/internal/oci"
3233
"github.com/google/go-containerregistry/pkg/authn"
3334
"github.com/google/go-containerregistry/pkg/authn/k8schain"
3435
"github.com/google/go-containerregistry/pkg/crane"
@@ -75,6 +76,7 @@ var ociRepositoryReadyCondition = summarize.Conditions{
7576
sourcev1.FetchFailedCondition,
7677
sourcev1.ArtifactOutdatedCondition,
7778
sourcev1.ArtifactInStorageCondition,
79+
sourcev1.SourceVerifiedCondition,
7880
meta.ReadyCondition,
7981
meta.ReconcilingCondition,
8082
meta.StalledCondition,
@@ -84,6 +86,7 @@ var ociRepositoryReadyCondition = summarize.Conditions{
8486
sourcev1.FetchFailedCondition,
8587
sourcev1.ArtifactOutdatedCondition,
8688
sourcev1.ArtifactInStorageCondition,
89+
sourcev1.SourceVerifiedCondition,
8790
meta.StalledCondition,
8891
meta.ReconcilingCondition,
8992
},
@@ -308,7 +311,7 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, obj *sour
308311
}
309312
options = append(options, crane.WithAuthFromKeychain(keychain))
310313

311-
if _, ok := keychain.(util.Anonymous); obj.Spec.Provider != sourcev1.GenericOCIProvider && ok {
314+
if _, ok := keychain.(soci.Anonymous); obj.Spec.Provider != sourcev1.GenericOCIProvider && ok {
312315
auth, authErr := oidcAuth(ctxTimeout, obj.Spec.URL, obj.Spec.Provider)
313316
if authErr != nil && !errors.Is(authErr, oci.ErrUnconfiguredProvider) {
314317
e := serror.NewGeneric(
@@ -406,6 +409,33 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, obj *sour
406409
}
407410
}()
408411

412+
// Verify artifact if:
413+
// - the upstream digest differs from the one in storage (revision drift)
414+
// - the OCIRepository spec has changed (generation drift)
415+
// - the previous reconciliation resulted in a failed artifact verification (retry with exponential backoff)
416+
if obj.Spec.Verify == nil {
417+
// Remove old observations if verification was disabled
418+
conditions.Delete(obj, sourcev1.SourceVerifiedCondition)
419+
} else if !obj.GetArtifact().HasRevision(revision) ||
420+
conditions.GetObservedGeneration(obj, sourcev1.SourceVerifiedCondition) != obj.Generation ||
421+
conditions.IsFalse(obj, sourcev1.SourceVerifiedCondition) {
422+
err := r.verifyOCISourceSignature(ctx, obj, url, keychain)
423+
if err != nil {
424+
provider := obj.Spec.Verify.Provider
425+
if obj.Spec.Verify.SecretRef == nil {
426+
provider = fmt.Sprintf("%s keyless", provider)
427+
}
428+
e := serror.NewGeneric(
429+
fmt.Errorf("failed to verify the signature using provider '%s': %w", provider, err),
430+
sourcev1.VerificationError,
431+
)
432+
conditions.MarkFalse(obj, sourcev1.SourceVerifiedCondition, e.Reason, e.Err.Error())
433+
return sreconcile.ResultEmpty, e
434+
}
435+
436+
conditions.MarkTrue(obj, sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of digest %s", revision)
437+
}
438+
409439
// Extract the content of the first artifact layer
410440
if !obj.GetArtifact().HasRevision(revision) {
411441
layers, err := img.Layers()
@@ -484,6 +514,86 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, obj *sour
484514
return sreconcile.ResultSuccess, nil
485515
}
486516

517+
// verifyOCISourceSignature verifies the authenticity of the given image reference url. First, it tries using a key
518+
// if a secret with a valid public key is provided. If not, it falls back to a keyless approach for verification.
519+
func (r *OCIRepositoryReconciler) verifyOCISourceSignature(ctx context.Context, obj *sourcev1.OCIRepository, url string, keychain authn.Keychain) error {
520+
ctxTimeout, cancel := context.WithTimeout(ctx, obj.Spec.Timeout.Duration)
521+
defer cancel()
522+
523+
provider := obj.Spec.Verify.Provider
524+
switch provider {
525+
case "cosign":
526+
defaultCosignOciOpts := []soci.Options{
527+
soci.WithAuthnKeychain(keychain),
528+
}
529+
530+
ref, err := name.ParseReference(url)
531+
if err != nil {
532+
return err
533+
}
534+
535+
// get the public keys from the given secret
536+
if secretRef := obj.Spec.Verify.SecretRef; secretRef != nil {
537+
certSecretName := types.NamespacedName{
538+
Namespace: obj.Namespace,
539+
Name: secretRef.Name,
540+
}
541+
542+
var pubSecret corev1.Secret
543+
if err := r.Get(ctxTimeout, certSecretName, &pubSecret); err != nil {
544+
return err
545+
}
546+
547+
signatureVerified := false
548+
for k, data := range pubSecret.Data {
549+
// search for public keys in the secret
550+
if strings.HasSuffix(k, ".pub") {
551+
verifier, err := soci.NewVerifier(ctxTimeout, append(defaultCosignOciOpts, soci.WithPublicKey(data))...)
552+
if err != nil {
553+
return err
554+
}
555+
556+
signatures, _, err := verifier.VerifyImageSignatures(ctxTimeout, ref)
557+
if err != nil {
558+
continue
559+
}
560+
561+
if signatures != nil {
562+
signatureVerified = true
563+
break
564+
}
565+
}
566+
}
567+
568+
if !signatureVerified {
569+
return fmt.Errorf("no matching signatures were found for '%s'", url)
570+
}
571+
572+
return nil
573+
}
574+
575+
// if no secret is provided, try keyless verification
576+
ctrl.LoggerFrom(ctx).Info("no secret reference is provided, trying to verify the image using keyless method")
577+
verifier, err := soci.NewVerifier(ctxTimeout, defaultCosignOciOpts...)
578+
if err != nil {
579+
return err
580+
}
581+
582+
signatures, _, err := verifier.VerifyImageSignatures(ctxTimeout, ref)
583+
if err != nil {
584+
return err
585+
}
586+
587+
if len(signatures) > 0 {
588+
return nil
589+
}
590+
591+
return fmt.Errorf("no matching signatures were found for '%s'", url)
592+
}
593+
594+
return nil
595+
}
596+
487597
// parseRepositoryURL validates and extracts the repository URL.
488598
func (r *OCIRepositoryReconciler) parseRepositoryURL(obj *sourcev1.OCIRepository) (string, error) {
489599
if !strings.HasPrefix(obj.Spec.URL, sourcev1.OCIRepositoryPrefix) {
@@ -591,7 +701,7 @@ func (r *OCIRepositoryReconciler) keychain(ctx context.Context, obj *sourcev1.OC
591701

592702
// if no pullsecrets available return an AnonymousKeychain
593703
if len(pullSecretNames) == 0 {
594-
return util.Anonymous{}, nil
704+
return soci.Anonymous{}, nil
595705
}
596706

597707
// lookup image pull secrets
@@ -651,7 +761,6 @@ func (r *OCIRepositoryReconciler) transport(ctx context.Context, obj *sourcev1.O
651761
tlsConfig.RootCAs = syscerts
652762
}
653763
return transport, nil
654-
655764
}
656765

657766
// oidcAuth generates the OIDC credential authenticator based on the specified cloud provider.

0 commit comments

Comments
 (0)