Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFC-0003] Implement OCIRepository verification using Cosign #876

Merged
merged 12 commits into from
Sep 22, 2022
Merged
4 changes: 4 additions & 0 deletions api/v1beta2/condition_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ const (
// required fields, or the provided credentials do not match.
AuthenticationFailedReason string = "AuthenticationFailed"

// VerificationError signals that the Source's verification
// check failed.
VerificationError string = "VerificationError"

// DirCreationFailedReason signals a failure caused by a directory creation
// operation.
DirCreationFailedReason string = "DirectoryCreationFailed"
Expand Down
10 changes: 9 additions & 1 deletion api/v1beta2/ocirepository_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ type OCIRepositorySpec struct {
// +optional
SecretRef *meta.LocalObjectReference `json:"secretRef,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.
// +optional
Verify *OCIRepositoryVerification `json:"verify,omitempty"`

// ServiceAccountName is the name of the Kubernetes ServiceAccount used to authenticate
// the image pull if the service account has attached pull secrets. For more information:
// https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#add-imagepullsecrets-to-a-service-account
Expand Down Expand Up @@ -156,11 +162,13 @@ type OCILayerSelector struct {
type OCIRepositoryVerification struct {
// Provider specifies the technology used to sign the OCI Artifact.
// +kubebuilder:validation:Enum=cosign
// +kubebuilder:default:=cosign
Provider string `json:"provider"`

// SecretRef specifies the Kubernetes Secret containing the
// trusted public keys.
SecretRef meta.LocalObjectReference `json:"secretRef"`
// +optional
SecretRef *meta.LocalObjectReference `json:"secretRef,omitempty"`
}

// OCIRepositoryStatus defines the observed state of OCIRepository
Expand Down
11 changes: 10 additions & 1 deletion api/v1beta2/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,31 @@ spec:
on a remote container registry.
pattern: ^oci://.*$
type: string
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.
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
required:
- interval
- url
Expand Down
2 changes: 2 additions & 0 deletions config/manager/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: TUF_ROOT # store the Fulcio root CA file in tmp
value: "/tmp/.sigstore"
args:
- --watch-all-namespaces
- --log-level=info
Expand Down
14 changes: 14 additions & 0 deletions config/testdata/ocirepository/signed-with-key.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: OCIRepository
metadata:
name: podinfo-deploy-signed-with-key
spec:
interval: 5m
url: oci://ghcr.io/stefanprodan/podinfo-deploy
ref:
semver: "6.2.x"
verify:
provider: cosign
secretRef:
name: cosign-key
12 changes: 12 additions & 0 deletions config/testdata/ocirepository/signed-with-keyless.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: OCIRepository
metadata:
name: podinfo-deploy-signed-with-keyless
spec:
interval: 5m
url: oci://ghcr.io/stefanprodan/manifests/podinfo
ref:
semver: "6.2.x"
verify:
provider: cosign
115 changes: 112 additions & 3 deletions controllers/ocirepository_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"time"

"github.com/Masterminds/semver/v3"
soci "github.com/fluxcd/source-controller/internal/oci"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/authn/k8schain"
"github.com/google/go-containerregistry/pkg/crane"
Expand Down Expand Up @@ -75,6 +76,7 @@ var ociRepositoryReadyCondition = summarize.Conditions{
sourcev1.FetchFailedCondition,
sourcev1.ArtifactOutdatedCondition,
sourcev1.ArtifactInStorageCondition,
sourcev1.SourceVerifiedCondition,
meta.ReadyCondition,
meta.ReconcilingCondition,
meta.StalledCondition,
Expand All @@ -84,6 +86,7 @@ var ociRepositoryReadyCondition = summarize.Conditions{
sourcev1.FetchFailedCondition,
sourcev1.ArtifactOutdatedCondition,
sourcev1.ArtifactInStorageCondition,
sourcev1.SourceVerifiedCondition,
meta.StalledCondition,
meta.ReconcilingCondition,
},
Expand Down Expand Up @@ -308,7 +311,7 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, obj *sour
}
options = append(options, crane.WithAuthFromKeychain(keychain))

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

// Verify artifact if:
// - the upstream digest differs from the one in storage (revision drift)
// - the OCIRepository spec has changed (generation drift)
// - the previous reconciliation resulted in a failed artifact verification (retry with exponential backoff)
if obj.Spec.Verify == nil {
// Remove old observations if verification was disabled
conditions.Delete(obj, sourcev1.SourceVerifiedCondition)
} else if !obj.GetArtifact().HasRevision(revision) ||
conditions.GetObservedGeneration(obj, sourcev1.SourceVerifiedCondition) != obj.Generation ||
conditions.IsFalse(obj, sourcev1.SourceVerifiedCondition) {
err := r.verifyOCISourceSignature(ctx, obj, url, keychain)
if err != nil {
provider := obj.Spec.Verify.Provider
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
}
developer-guy marked this conversation as resolved.
Show resolved Hide resolved

conditions.MarkTrue(obj, sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of digest %s", revision)
}

// Extract the content of the first artifact layer
if !obj.GetArtifact().HasRevision(revision) {
layers, err := img.Layers()
Expand Down Expand Up @@ -484,6 +514,86 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, obj *sour
return sreconcile.ResultSuccess, nil
}

// verifyOCISourceSignature verifies the authenticity of the given image reference url. First, it tries using a key
// if a secret with a valid public key is provided. If not, it falls back to a keyless approach for verification.
func (r *OCIRepositoryReconciler) verifyOCISourceSignature(ctx context.Context, obj *sourcev1.OCIRepository, url string, keychain authn.Keychain) error {
ctxTimeout, cancel := context.WithTimeout(ctx, obj.Spec.Timeout.Duration)
defer cancel()

provider := obj.Spec.Verify.Provider
switch provider {
case "cosign":
defaultCosignOciOpts := []soci.Options{
soci.WithAuthnKeychain(keychain),
}

ref, err := name.ParseReference(url)
if err != nil {
return err
}

// 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(ctxTimeout, certSecretName, &pubSecret); err != nil {
return err
}

signatureVerified := false
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))...)
if err != nil {
return err
}

signatures, _, err := verifier.VerifyImageSignatures(ctxTimeout, ref)
if err != nil {
continue
}

if signatures != nil {
signatureVerified = true
break
}
}
}

if !signatureVerified {
return fmt.Errorf("no matching signatures were found for '%s'", url)
}

return nil
}

developer-guy marked this conversation as resolved.
Show resolved Hide resolved
// 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...)
if err != nil {
return err
}

signatures, _, err := verifier.VerifyImageSignatures(ctxTimeout, ref)
if err != nil {
return err
}

if len(signatures) > 0 {
return nil
}

return fmt.Errorf("no matching signatures were found for '%s'", url)
}

return nil
}

// parseRepositoryURL validates and extracts the repository URL.
func (r *OCIRepositoryReconciler) parseRepositoryURL(obj *sourcev1.OCIRepository) (string, error) {
if !strings.HasPrefix(obj.Spec.URL, sourcev1.OCIRepositoryPrefix) {
Expand Down Expand Up @@ -591,7 +701,7 @@ func (r *OCIRepositoryReconciler) keychain(ctx context.Context, obj *sourcev1.OC

// if no pullsecrets available return an AnonymousKeychain
if len(pullSecretNames) == 0 {
return util.Anonymous{}, nil
return soci.Anonymous{}, nil
}

// lookup image pull secrets
Expand Down Expand Up @@ -651,7 +761,6 @@ func (r *OCIRepositoryReconciler) transport(ctx context.Context, obj *sourcev1.O
tlsConfig.RootCAs = syscerts
}
return transport, nil

}

// oidcAuth generates the OIDC credential authenticator based on the specified cloud provider.
Expand Down
Loading