Go's go-containerregistry + crypto/ed25519 — A Provenance-Enforcing Admission Webhook in Go
Small interfaces; composable handlers; typed errors. The Ops chain becomes the Go program's call graph.
§ IFrame
Today's Ops lesson described four moving parts of supply-chain trust: a build that produces signing artifacts, a registry that stores them, a key infrastructure the cluster trusts, and an admission gate that reads the documents before the workload runs. The fourth part — the gate — is the one engineers most often write themselves, because the policy that runs through it is the fleet-specific decision no upstream tool can know.
Go is the language Kubernetes is written in, the language cosign is written in, the language Sigstore's policy-controller is written in, and the language a fleet engineer who needs to extend or replace those tools will be writing in. This lesson walks the wire-level mechanics of a custom admission webhook: how Go fetches a signature from a registry, how it verifies an Ed25519 signature against a known public key, how it parses an AdmissionReview from the Kubernetes API server, and how it returns the allowed or denied decision.
§ IILanguage Idiom
Three Go primitives carry the lesson.
The first is the small interface. Go's interface model rewards interfaces of one or two methods. An interface called Verifier with a single Verify(digest, signatureBytes) error method becomes the contract the rest of the system writes against. The Ed25519 implementation, the ECDSA implementation, the KMS-backed implementation, and the mock in the test suite all satisfy the same interface. The webhook's routing handler depends on the interface, never on a concrete type. Replacing the signer is a single line in the wiring.
The second is the structural composition of HTTP handlers. The standard library's net/http package defines a Handler interface and a small set of types — ServeMux, HandlerFunc, middleware-shaped decorators — that compose by wrapping. The same pattern that built the audit-log middleware chain in the Thu 2026-05-28 lesson builds the admission webhook here.
The third is the explicit error chain. Go's error handling is verbose by design. A verification failure must distinguish between the signature is invalid (deny the pod) and the registry was unreachable (the policy decides fail-open or fail-closed). The two failures have different return types in well-written Go; the routing handler reads the type and decides. The verbosity buys clarity at the seam where it matters most.
§ IIICode Worked Example
The webhook receives an AdmissionReview on a TLS-secured HTTPS endpoint, fetches the signature for the pod's image, verifies it against a configured public key, and returns an AdmissionResponse. Five Go files carry the implementation.
The interface contract
Three interfaces, each one method. Verifier answers the cryptographic question; Resolver turns a tag into a digest; SignatureFetcher queries the registry for signatures associated with a digest. Every implementation in the codebase satisfies one of these three.
package verify
import "context"
type Verifier interface {
Verify(ctx context.Context, digest string, signature []byte) error
}
type Resolver interface {
Resolve(ctx context.Context, imageRef string) (digest string, err error)
}
type SignatureFetcher interface {
Fetch(ctx context.Context, digest string) ([][]byte, error)
}
The Ed25519 verifier
The crypto/ed25519 package is in the Go standard library; no external dependency is required for the cryptographic primitive. The Verify call is constant-time over inputs of equal length, which removes a side-channel risk an operator should care about. The constructor validates the key material at load time, so a misconfigured deployment fails at startup rather than at the first admission request.
package verify
import (
"context"
"crypto/ed25519"
"encoding/pem"
"errors"
"fmt"
"os"
)
type ed25519Verifier struct {
pubKey ed25519.PublicKey
}
func NewEd25519Verifier(pemPath string) (Verifier, error) {
raw, err := os.ReadFile(pemPath)
if err != nil {
return nil, fmt.Errorf("read pub key: %w", err)
}
block, _ := pem.Decode(raw)
if block == nil || block.Type != "PUBLIC KEY" {
return nil, errors.New("invalid PEM public key")
}
if len(block.Bytes) != ed25519.PublicKeySize {
return nil, fmt.Errorf("ed25519 pub key must be %d bytes, got %d",
ed25519.PublicKeySize, len(block.Bytes))
}
return &ed25519Verifier{pubKey: ed25519.PublicKey(block.Bytes)}, nil
}
func (v *ed25519Verifier) Verify(ctx context.Context, digest string, signature []byte) error {
if !ed25519.Verify(v.pubKey, []byte(digest), signature) {
return errors.New("ed25519 verification failed")
}
return nil
}
The OCI signature fetcher
The function uses cosign's tag-based discovery scheme: the signature for an image at digest sha256:abc... lives at the tag sha256-abc....sig in the same repository. The function returns a slice because a digest can have multiple signatures from multiple signers, and the policy decides which ones to require.
package verify
import (
"context"
"fmt"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
)
type ociFetcher struct {
auth remote.Option
}
func NewOCIFetcher(auth remote.Option) SignatureFetcher {
return &ociFetcher{auth: auth}
}
func (f *ociFetcher) Fetch(ctx context.Context, digest string) ([][]byte, error) {
ref, err := name.NewDigest(digest)
if err != nil {
return nil, fmt.Errorf("parse digest %q: %w", digest, err)
}
sigTag := fmt.Sprintf("%s:sha256-%s.sig",
ref.Context().String(),
ref.Identifier()[len("sha256:"):])
sigRef, err := name.NewTag(sigTag)
if err != nil {
return nil, fmt.Errorf("parse signature tag: %w", err)
}
img, err := remote.Image(sigRef, f.auth, remote.WithContext(ctx))
if err != nil {
return nil, fmt.Errorf("fetch signature image: %w", err)
}
manifest, err := img.Manifest()
if err != nil {
return nil, fmt.Errorf("read signature manifest: %w", err)
}
sigs := make([][]byte, 0, len(manifest.Layers))
for _, layer := range manifest.Layers {
if sig, ok := layer.Annotations["dev.cosignproject.cosign/signature"]; ok {
sigs = append(sigs, []byte(sig))
}
}
return sigs, nil
}
The admission handler
The handler does five things in sequence: time-box the request, decode the AdmissionReview, unmarshal the embedded Pod, verify each container's image, and respond. The verifyImage helper exits at the first valid signature; a deployment with multiple signers any-of-which-suffices is the default policy, which matches cosign's behavior. A quorum policy that requires all configured signers to sign is one branch in the loop away.
package admission
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
admissionv1 "k8s.io/api/admission/v1"
corev1 "k8s.io/api/core/v1"
"fleet-org/webhook/verify"
)
type Handler struct {
Resolver verify.Resolver
Fetcher verify.SignatureFetcher
Verifier verify.Verifier
Logger *slog.Logger
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
var review admissionv1.AdmissionReview
if err := json.NewDecoder(r.Body).Decode(&review); err != nil {
http.Error(w, fmt.Sprintf("decode: %v", err), http.StatusBadRequest)
return
}
if review.Request == nil {
http.Error(w, "no request body", http.StatusBadRequest)
return
}
var pod corev1.Pod
if err := json.Unmarshal(review.Request.Object.Raw, &pod); err != nil {
respondDeny(w, review, fmt.Sprintf("unmarshal pod: %v", err))
return
}
for _, container := range pod.Spec.Containers {
if err := h.verifyImage(ctx, container.Image); err != nil {
h.Logger.Warn("admission denied",
"namespace", pod.Namespace,
"image", container.Image,
"reason", err.Error())
respondDeny(w, review, err.Error())
return
}
}
respondAllow(w, review)
}
func (h *Handler) verifyImage(ctx context.Context, ref string) error {
digest, err := h.Resolver.Resolve(ctx, ref)
if err != nil {
return fmt.Errorf("resolve %s: %w", ref, err)
}
sigs, err := h.Fetcher.Fetch(ctx, digest)
if err != nil {
return fmt.Errorf("fetch sigs for %s: %w", digest, err)
}
if len(sigs) == 0 {
return fmt.Errorf("no signatures for %s", digest)
}
for _, sig := range sigs {
if h.Verifier.Verify(ctx, digest, sig) == nil {
return nil
}
}
return fmt.Errorf("no valid signature for %s", digest)
}
ValidatingWebhookConfiguration. The interface-based design means swapping in a KMS-backed Verifier, a different registry fetcher, or an attestation parser is a wiring change, not a rewrite.
§ IVConnection to Today's Ops Lesson
The Ops lesson named four moving parts of supply-chain trust: build, registry, keys, and gate. Each of those parts maps to a Go interface or concrete type. The build is upstream — the CI pipeline produces the bytes the webhook later reads. The registry is a SignatureFetcher implementation. The keys are loaded by the Verifier constructor and held by the verifier instance. The gate is the HTTP handler.
A fleet that wants to add a second signing scheme (a hardware-backed KMS, for example) writes a new Verifier implementation and wires it in alongside the Ed25519 one. The handler reads from a slice of verifiers and accepts the pod if any verifier succeeds. The interface-based design buys this composition for free; no refactor of the handler is required.
A fleet that wants to add SLSA attestation parsing implements an AttestationVerifier interface alongside Verifier, fetches the attestation in parallel with the signature, and verifies both before allowing the pod. The Ops lesson's chain becomes the Go program's call graph.
§ VPrior-Lesson Reach
Three earlier Go lessons compose with this one.
The Tue 2026-05-19 lesson on context.Context for distributed tracing established the deadline-and-cancellation idiom this webhook depends on. The context.WithTimeout call in ServeHTTP bounds the registry round-trip; without that bound, a slow registry hangs the API server, and pods stop being admitted cluster-wide.
The Fri 2026-05-22 lesson on channels and pipeline patterns established the fan-out-fan-in shape Go reaches for when work splits and joins. The same shape applies to verifying multiple containers in a pod in parallel; a high-throughput cluster would parallelize the per-container verification. The parallelization is a few lines of channel plumbing the engineer who internalized the Fri lesson writes without thinking.
The Thu 2026-05-28 lesson on middleware chains and structured logging established the decorator pattern this webhook uses for cross-cutting concerns. The production deployment wraps the Handler in a chain — recovery, tracing, metrics, request logging, TLS termination — composed by the wiring. Every admission decision writes a structured log entry keyed by the pod's namespace, the image reference, the decision, and the reason. The audit log is the post-incident answer to why did this pod run.
Four lessons; one pattern. Small interfaces, composable handlers, typed errors, context propagation. The same culture that built the audit pipeline builds the admission webhook. Go's reward is consistency.
§ VIClosing
The supply-chain seam closes at the webhook. The webhook is, in Go, a small set of interfaces and an HTTP handler. The fleet engineer who writes one reads cosign's source as a reference, not a black box, because cosign was written in the same language under the same culture. Replaceability is a property of the design, not a documentation footnote.
Three more Wed lessons in the quarter will revisit β-Trust through the data lens — encryption at rest, classification, lineage. The webhook pattern will return, with a different question each time. The question changes; the architecture holds. Examine the interfaces well; the system lives at the seams between them.
Filed 2026-06-03 Fajr ANCHOR #18; first Wed × β × Go × CKA+CKS crossing in the 12-week supercycle