Skip to content

Commit ce4e9c6

Browse files
committed
feat: add built-in Cosign verification for ClusterGKMCache with dual v2/v3 support
This commit removes ClusterGKMCache's dependency on Kyverno and implements built-in signature verification directly in the admission webhook with automatic Cosign v2 and v3 format detection. Signed-off-by: Maryam Tahhan <[email protected]>
1 parent 339ebeb commit ce4e9c6

File tree

2,975 files changed

+120697
-727138
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

2,975 files changed

+120697
-727138
lines changed

.github/workflows/pull-request.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
strategy:
2020
fail-fast: false
2121
matrix:
22-
go: ["1.24"]
22+
go: ["1.25"]
2323
arch:
2424
- arch: amd64
2525
filename: linux-x86_64
@@ -46,17 +46,17 @@ jobs:
4646
uses: actions/checkout@v6
4747

4848
- name: Check format
49-
if: ${{ matrix.arch.arch == 'amd64' && matrix.go == '1.24' }}
49+
if: ${{ matrix.arch.arch == 'amd64' && matrix.go == '1.25' }}
5050
run: make fmt && git add -A && git diff --exit-code
5151

5252
# TBD: Currently failing
5353
# - name: Run lint
54-
# if: ${{ matrix.arch.arch == 'amd64' && matrix.go == '1.24' }}
54+
# if: ${{ matrix.arch.arch == 'amd64' && matrix.go == '1.25' }}
5555
# run: make lint
5656

5757
- name: Build Operator, Agent and CSI-Plugin
5858
run: GOARCH=${{ matrix.arch.arch }} make build
5959

6060
- name: Unit Tests
61-
if: ${{ matrix.arch.arch == 'amd64' && matrix.go == '1.24' }}
61+
if: ${{ matrix.arch.arch == 'amd64' && matrix.go == '1.25' }}
6262
run: sudo env "PATH=$PATH" make test

Containerfile.gkm-agent

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Build the agent binary
2-
FROM public.ecr.aws/docker/library/golang:1.24.4 AS builder
2+
FROM public.ecr.aws/docker/library/golang:1.25.0 AS builder
33

44
WORKDIR /workspace
55

Containerfile.gkm-csi

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM public.ecr.aws/docker/library/golang:1.24.4 AS csi-builder
1+
FROM public.ecr.aws/docker/library/golang:1.25.0 AS csi-builder
22

33
RUN apt-get update && apt-get install -y --no-install-recommends \
44
libgpgme-dev \

Containerfile.gkm-operator

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Build the operator binary
2-
FROM public.ecr.aws/docker/library/golang:1.24.4 AS builder
2+
FROM public.ecr.aws/docker/library/golang:1.25.0 AS builder
33

44
WORKDIR /workspace
55

Makefile

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -314,15 +314,15 @@ ifneq ($(KYVERNO_ENABLED),true)
314314
endif
315315

316316
.PHONY: deploy
317-
deploy: manifests kustomize prepare-deploy deploy-cert-manager redeploy ## Deploy controller and agent to the K8s cluster specified in ~/.kube/config
317+
deploy: manifests kustomize prepare-deploy webhook-secret-file deploy-cert-manager redeploy ## Deploy controller and agent to the K8s cluster specified in ~/.kube/config
318318

319319
.PHONY: redeploy
320320
redeploy: ## Redeploy controller and agent to the K8s cluster after deploy and undeploy have been called. Skips some onetime steps in deploy.
321321
$(KUSTOMIZE) build $(DEPLOY_PATH) | $(KUBECTL) apply -f -
322322
@echo "Deployment to $(DEPLOY_PATH) completed."
323323

324324
.PHONY: undeploy
325-
undeploy: kustomize ## Undeploy operator and agent from the K8s cluster specified in ~/.kube/config.
325+
undeploy: kustomize delete-webhook-secret-file ## Undeploy operator and agent from the K8s cluster specified in ~/.kube/config.
326326
@echo "Calling undeploy script"
327327
$(UNDEPLOY_SCRIPT) $(FORCE)
328328
@if [ $$? -ne 0 ]; then \
@@ -376,6 +376,22 @@ get-example-images:
376376
deploy-webhook-certs:
377377
$(KUBECTL) apply -k config/webhook
378378

379+
.PHONY: webhook-secret-file
380+
webhook-secret-file:
381+
@mkdir -p config/secret
382+
@[ -s config/secret/mutation.env ] || \
383+
(echo 'Generating config/secret/mutation.env'; \
384+
printf 'MUTATION_SIGNING_KEY=%s\n' "$$(head -c 32 /dev/urandom | base64 | tr -d '\n')" > config/secret/mutation.env)
385+
386+
.PHONY: delete-webhook-secret-file
387+
delete-webhook-secret-file:
388+
@rm -f config/secret/mutation.env
389+
390+
.PHONY: rotate-webhook-secret
391+
rotate-webhook-secret:
392+
@printf 'MUTATION_SIGNING_KEY=%s\n' "$$(head -c 32 /dev/urandom | base64 | tr -d '\n')" > config/secret/mutation.env
393+
$(KUSTOMIZE) build config/secret | $(KUBECTL) apply -f -
394+
379395
.PHONY: get-cert-manager-images
380396
get-cert-manager-images:
381397
@echo "Getting Images ..."
@@ -408,7 +424,7 @@ deploy-cert-manager: get-cert-manager-images
408424
$(KUBECTL) wait --for=condition=Ready --timeout=120s -n cert-manager pod -l app=webhook
409425

410426
.PHONY: undeploy-cert-manager
411-
undeploy-cert-manager:
427+
undeploy-cert-manager: delete-webhook-secret-file
412428
@echo "Undeploy cert-manager"
413429
$(KUBECTL) delete -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml --ignore-not-found=$(ignore-not-found)
414430

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ decreasing the pod start-up time by up to half.
4141

4242
### Prerequisites
4343

44-
- go version v1.24.0+
44+
- go version v1.25.0+
4545
- podman version 5.3.1+.
4646
- kubectl version v1.11.3+.
4747
- Access to a Kubernetes v1.11.3+ cluster.

api/v1alpha1/clustergkmcache_webhook.go

Lines changed: 102 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ package v1alpha1
22

33
import (
44
"context"
5+
"crypto/hmac"
6+
"crypto/sha256"
7+
"encoding/base64"
58
"fmt"
9+
"os"
610
"time"
711

812
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -12,6 +16,7 @@ import (
1216
"sigs.k8s.io/controller-runtime/pkg/webhook"
1317
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
1418

19+
"github.com/redhat-et/GKM/pkg/cosign"
1520
"github.com/redhat-et/GKM/pkg/utils"
1621
)
1722

@@ -38,6 +43,10 @@ func (w *ClusterGKMCache) SetupWebhookWithManager(mgr ctrl.Manager) error {
3843
// +kubebuilder:webhook:path=/validate-gkm-io-v1alpha1-clustergkmcache,mutating=false,failurePolicy=fail,sideEffects=None,groups=gkm.io,resources=clustergkmcaches,verbs=create;update,versions=v1alpha1,name=z-vclustergkmcache.kb.io,admissionReviewVersions=v1
3944

4045
// Default implements the mutating webhook logic for defaulting.
46+
// The mutating webhook writes both the resolved digest and a
47+
// gkm.io/mutationSig that’s bound to the current AdmissionRequest UID + image
48+
// + digest. The validating webhooks only accept the digest if that signature
49+
// is valid, which guarantees the digest came from the mutator (not the user).
4150
func (w *ClusterGKMCache) Default(ctx context.Context, obj runtime.Object) error {
4251
clustergkmcacheLog.V(1).Info("Mutating Webhook called", "object", obj)
4352

@@ -57,38 +66,46 @@ func (w *ClusterGKMCache) Default(ctx context.Context, obj runtime.Object) error
5766
}
5867

5968
// Resolve & verify image -> digest
60-
cctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
69+
// Note: v3 bundle verification can take 15-20 seconds
70+
cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
6171
defer cancel()
6272

63-
kyvernoEnabled := isKyvernoVerificationEnabled()
64-
var digest string
65-
var err error
66-
if kyvernoEnabled {
67-
// First check if the image already contains a digest (e.g., from Kyverno mutation)
68-
if extractedDigest := extractDigestFromImage(cache.Spec.Image); extractedDigest != "" {
69-
clustergkmcacheLog.Info("Image already contains digest (likely from Kyverno)", "image", cache.Spec.Image, "digest", extractedDigest)
70-
digest = extractedDigest
71-
}
72-
resolvedDigest, digestFound := cache.Annotations[utils.GMKCacheAnnotationResolvedDigest]
73-
if digestFound && digest != "" {
74-
// Digest hasn't changed so just return
75-
if digest == resolvedDigest {
76-
return nil
77-
}
78-
}
79-
} else {
80-
clustergkmcacheLog.V(1).Info("Resolving image digest (Kyverno verification disabled)", "image", cache.Spec.Image)
81-
digest, err = resolveImageDigest(cctx, cache.Spec.Image)
82-
if err != nil {
83-
clustergkmcacheLog.Error(err, "failed to resolve image digest")
84-
return apierrors.NewBadRequest(fmt.Sprintf(
85-
"image digest resolution failed for '%s': %s",
86-
cache.Spec.Image, err.Error(),
87-
))
73+
clustergkmcacheLog.V(1).Info("Verifying image signature", "image", cache.Spec.Image)
74+
digest, err := cosign.VerifyImageSignature(cctx, cache.Spec.Image)
75+
if err != nil {
76+
clustergkmcacheLog.Error(err, "failed to verify image or resolve digest")
77+
return apierrors.NewBadRequest(fmt.Sprintf(
78+
"image signature verification failed for '%s': %s",
79+
cache.Spec.Image, err.Error(),
80+
))
81+
}
82+
resolvedDigest, digestFound := cache.Annotations[utils.GMKCacheAnnotationResolvedDigest]
83+
if digestFound {
84+
// Digest hasn't changed so just return
85+
if digest == resolvedDigest {
86+
return nil
8887
}
8988
}
9089
cache.Annotations[utils.GMKCacheAnnotationResolvedDigest] = digest
9190

91+
// Bind a mutation signature to THIS AdmissionRequest UID
92+
req, err := admission.RequestFromContext(ctx)
93+
if err != nil {
94+
return apierrors.NewBadRequest("unable to read admission request from context")
95+
}
96+
secret, err := mutationKeyFromEnv()
97+
if err != nil {
98+
return apierrors.NewBadRequest(err.Error())
99+
}
100+
sig, err := signMutation(secret, "", cache.Spec.Image, digest)
101+
if err != nil {
102+
return apierrors.NewBadRequest(fmt.Sprintf("failed to sign mutation: %v", err))
103+
}
104+
cache.Annotations[utils.GMKClusterAnnotationMutationSig] = sig
105+
106+
// Audit for convenience (not part of trust)
107+
cache.Annotations[utils.GMKClusterAnnotationLastMutatedBy] = req.UserInfo.Username
108+
92109
clustergkmcacheLog.Info("added/updated resolvedDigest", "image", cache.Spec.Image, "digest", digest)
93110
return nil
94111
}
@@ -104,21 +121,28 @@ func (w *ClusterGKMCache) ValidateCreate(ctx context.Context, obj runtime.Object
104121
return nil, fmt.Errorf("spec.image must be set")
105122
}
106123

107-
if _, exists := cache.Annotations[utils.GMKCacheAnnotationResolvedDigest]; !exists {
108-
return nil, fmt.Errorf("%s must be set by mutating webhook", utils.GMKCacheAnnotationResolvedDigest)
109-
}
124+
// The validator sees the mutated object.
125+
// If resolvedDigest is present, it must carry a valid mutationSig for THIS request.
126+
digest := cache.Annotations[utils.GMKCacheAnnotationResolvedDigest]
127+
sig := cache.Annotations[utils.GMKClusterAnnotationMutationSig]
110128

111-
if isKyvernoVerificationEnabled() {
112-
if _, exists := cache.Annotations[utils.KyvernoVerifyImagesAnnotation]; !exists {
113-
return nil, fmt.Errorf("%s must be set by kyverno", utils.KyvernoVerifyImagesAnnotation)
114-
}
129+
if digest == "" {
130+
return nil, fmt.Errorf("%s must be set by the mutating webhook", utils.GMKCacheAnnotationResolvedDigest)
131+
}
115132

116-
// Check Kyverno verification status if present
117-
if err := verifyKyvernoAnnotation(cache.Annotations); err != nil {
118-
return nil, fmt.Errorf("kyverno verification failed: %w", err)
119-
}
133+
secret, err := mutationKeyFromEnv()
134+
if err != nil {
135+
return nil, fmt.Errorf("%s", err.Error())
136+
}
137+
if !verifyMutation(secret, "", cache.Spec.Image, digest, sig) {
138+
return nil, fmt.Errorf("%s present but missing/invalid %s; digest must be set only by the mutating webhook",
139+
utils.GMKCacheAnnotationResolvedDigest, utils.GMKClusterAnnotationMutationSig)
120140
}
121141

142+
// Signature verified - the mutating webhook already performed expensive Cosign verification
143+
// The valid mutation signature cryptographically proves the digest is correct
144+
clustergkmcacheLog.V(1).Info("Mutation signature validated", "image", cache.Spec.Image, "digest", digest)
145+
122146
return nil, nil
123147
}
124148

@@ -136,6 +160,7 @@ func (w *ClusterGKMCache) ValidateUpdate(_ context.Context, oldObj, newObj runti
136160

137161
oldDigest := oldCache.Annotations[utils.GMKCacheAnnotationResolvedDigest]
138162
newDigest := newCache.Annotations[utils.GMKCacheAnnotationResolvedDigest]
163+
newSig := newCache.Annotations[utils.GMKClusterAnnotationMutationSig]
139164

140165
// If image didn't change, digest must not change.
141166
if oldImg == newImg {
@@ -149,19 +174,16 @@ func (w *ClusterGKMCache) ValidateUpdate(_ context.Context, oldObj, newObj runti
149174
if newImg == "" {
150175
return nil, fmt.Errorf("spec.image must be set")
151176
}
152-
if newDigest == "" {
177+
if newDigest == "" || newSig == "" {
153178
return nil, fmt.Errorf("%s must be set by mutating webhook when spec.image changes", utils.GMKCacheAnnotationResolvedDigest)
154179
}
155180

156-
// Validate Kyverno verification if enabled
157-
if isKyvernoVerificationEnabled() {
158-
if _, exists := newCache.Annotations[utils.KyvernoVerifyImagesAnnotation]; !exists {
159-
return nil, fmt.Errorf("%s must be set by kyverno", utils.KyvernoVerifyImagesAnnotation)
160-
}
161-
162-
if err := verifyKyvernoAnnotation(newCache.Annotations); err != nil {
163-
return nil, fmt.Errorf("kyverno verification failed: %w", err)
164-
}
181+
secret, err := mutationKeyFromEnv()
182+
if err != nil {
183+
return nil, fmt.Errorf("%s", err.Error())
184+
}
185+
if !verifyMutation(secret, "", newImg, newDigest, newSig) {
186+
return nil, fmt.Errorf("invalid %s for updated image; digest must be set only by the mutating webhook", utils.GMKClusterAnnotationMutationSig)
165187
}
166188

167189
return nil, nil
@@ -179,3 +201,36 @@ func (w *ClusterGKMCache) ValidateDelete(_ context.Context, obj runtime.Object)
179201
// Add delete validation logic here if needed.
180202
return nil, nil
181203
}
204+
205+
func mutationKeyFromEnv() (string, error) {
206+
k := os.Getenv("MUTATION_SIGNING_KEY")
207+
if k == "" {
208+
return "", fmt.Errorf("MUTATION_SIGNING_KEY env var not set")
209+
}
210+
return k, nil
211+
}
212+
213+
// HMAC(secret, requestUID|image|digest), base64-encoded
214+
func signMutation(secret, requestUID, image, digest string) (string, error) {
215+
mac := hmac.New(sha256.New, []byte(secret))
216+
mac.Write([]byte(requestUID))
217+
mac.Write([]byte("|"))
218+
mac.Write([]byte(image))
219+
mac.Write([]byte("|"))
220+
mac.Write([]byte(digest))
221+
sum := mac.Sum(nil)
222+
return base64.StdEncoding.EncodeToString(sum), nil
223+
}
224+
225+
func verifyMutation(secret, requestUID, image, digest, sigB64 string) bool {
226+
if sigB64 == "" {
227+
return false
228+
}
229+
wantSig, _ := signMutation(secret, requestUID, image, digest)
230+
want, _ := base64.StdEncoding.DecodeString(wantSig)
231+
got, err := base64.StdEncoding.DecodeString(sigB64)
232+
if err != nil {
233+
return false
234+
}
235+
return hmac.Equal(want, got)
236+
}

config/kyverno/policies/clustergkmcache-policy.yaml

Lines changed: 0 additions & 27 deletions
This file was deleted.
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
apiVersion: kustomize.config.k8s.io/v1beta1
22
kind: Kustomization
33

4-
# Deploy label-based policies for Cosign v2 and v3 support
4+
# Deploy label-based policies for GKMCache (namespaced) resources
5+
# Supports both Cosign v2 and v3 signature formats
56
# See docs/examples/kyverno-image-verification.md for details
7+
#
8+
# Note: ClusterGKMCache resources perform their own signature verification
9+
# and do not require Kyverno policies
610
resources:
7-
- clustergkmcache-policy.yaml
811
- gkmcache-policy-v2.yaml
912
- gkmcache-policy-v3.yaml

0 commit comments

Comments
 (0)