Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

pqc-admission-webhook

Sets Envoy's ecdh_curves on a Cilium Gateway API listener, so TLS to your Gateway negotiates the hybrid post-quantum group X25519MLKEM768.

Cilium's Gateway API translation has no field for TLS parameters. This injects them at admission time instead, leaving the Cilium operator fully in charge of the Gateway.

163 lines of Go, no third-party dependencies, distroless, fail-open.

The problem

Cilium derives a CiliumEnvoyConfig from your Gateway. That derivation covers ALPN, proxy protocol and socket options, but not tls_params, so there is nowhere for ecdh_curves to come from. The upstream request for one, cilium#38089, used post-quantum key exchange as its motivating example, drew no maintainer response, and was auto-closed as stale.

Editing the generated CiliumEnvoyConfig by hand does not work. The patch is accepted and the field is gone about 0.6 seconds later, because the Cilium operator reverts drift on the resource it owns.

Which is the opening. A controller that reliably rewrites a resource is a controller whose writes pass through admission control. So this webhook watches CiliumEnvoyConfig writes and injects the curve preference into every filter chain carrying a TLS context. The operator regenerates the config, that regeneration passes through admission on the way in, and it arrives already carrying the setting. The mechanism that used to undo the change now applies it.

Before you use this

Two better options exist, in order:

  1. Kubernetes 1.34 or newer: express the same injection declaratively as a MutatingAdmissionPolicy with no service to run at all. This project exists because that API is not available before 1.34, while a plain MutatingWebhookConfiguration has been GA for years.
  2. Cilium implementing tls.options: Gateway API already reserves the place for this. A listener's tls.options is a map of implementation-specific key/value pairs whose own doc comment offers "configuring the minimum TLS version or supported cipher suites" as the example use. Cilium does not read it: operator/pkg/model/ingestion/gateway.go passes the listener TLS block to a toTLS helper that resolves certificate references and nothing else. If that changes, delete this.

Install

Requires cert-manager for the webhook's own serving certificate, and its cainjector to populate the caBundle.

# 1. Find the CiliumEnvoyConfig your Gateway generates.
kubectl -n <gateway-namespace> get ciliumenvoyconfig
# Cilium names it cilium-gateway-<your Gateway's name>.

# 2. Set that name in deploy/deployment.yaml (TARGET_CEC) and replace
#    cilium-gateway-REPLACE-ME. There is no default: silently targeting the
#    wrong object looks exactly like the webhook working.

# 3. Apply.
kubectl apply -f deploy/

deploy/deployment.yaml references ghcr.io/paperclipinc/pqc-admission-webhook. If that image is not pullable for you, build your own, which takes a few seconds and has no dependencies to fetch:

docker build -t <your-registry>/pqc-admission-webhook:v1 .
docker push  <your-registry>/pqc-admission-webhook:v1
# then set that image in deploy/deployment.yaml

Configuration

Variable Default Meaning
TARGET_CEC (required) Name of the CiliumEnvoyConfig to patch
TARGET_NAMESPACE gateway Namespace it lives in
ECDH_CURVES X25519MLKEM768,X25519,P-256 Curve preference, in order
LISTEN_ADDR :8443
TLS_CERT_FILE / TLS_KEY_FILE /tls/tls.crt, /tls/tls.key Serving cert

Keep classical curves in the list. A hybrid-only preference hard-fails every client without ML-KEM support, which is the standard way to turn a security upgrade into an outage.

The gotcha that will cost you an afternoon

After applying, the setting will not appear, and nothing will be wrong.

Nudging the Gateway to force a reconcile does nothing on its own. If the config the operator derives is identical to the one already stored, it issues no write, so there is no admission request and nothing to mutate. You need a real write. The reliable way to get one is to patch the stored config yourself so the operator reverts it, because that revert is a write, and it comes back through the webhook carrying the curves:

kubectl -n <ns> patch ciliumenvoyconfig <name> --type=merge \
  -p '{"metadata":{"annotations":{"pqc/force":"1"}}}'

Verify

With OpenSSL 3.5 or later. Offering only the hybrid group means a completed handshake proves it was negotiated:

openssl s_client -connect <host>:443 -servername <host> \
  -groups X25519MLKEM768 -brief </dev/null

# And confirm classical clients still work:
openssl s_client -connect <host>:443 -servername <host> \
  -groups X25519:P-256 -brief </dev/null

Two client-side traps, both of which produce failures that tell you about your machine rather than the server: anything before OpenSSL 3.5 has no ML-KEM support at all, and the openssl shipped on macOS is LibreSSL, which rejects the group name outright.

Safety

It fails open, deliberately. failurePolicy: Ignore, and every path through the handler returns an allowing response, including on input it cannot parse. A webhook in front of the resource that configures your ingress is a good way to take your own site down; this one cannot. If it breaks, writes pass through unmodified and you lose post-quantum key exchange at the next regeneration.

That means failure is silent. No error, no outage, just a page that still loads over a handshake that has quietly returned to classical X25519. Monitor the property from outside, with something like the verification command above on a schedule. Do not rely on noticing.

It finds the TLS chain by inspection, not by index. Indexing the filter chain by position works until a listener is added or reordered, and then it stops matching and drops you back to classical with nothing to signal it. It also skips any chain that already has tlsParams, so reinvocationPolicy: IfNeeded calling it twice cannot clobber the first result.

It patches exactly one named object. Every other CiliumEnvoyConfig passes through untouched.

Cost

Measured over 200 handshakes per arm, interleaved, against a Cilium Gateway running this:

Classical X25519 Hybrid X25519MLKEM768
Handshake bytes out 333 1517
Handshake bytes in 3811 4899
Median handshake 30.7 ms 31.7 ms

The byte cost is exact rather than measured: the extra 1184 bytes outbound is the ML-KEM-768 encapsulation key and the extra 1088 inbound is the ciphertext, both fixed by FIPS 203. About 2.2 KB per handshake that no tuning at either end will improve.

Do not trust anybody's p99 here, including ours: across four runs of the identical experiment the p99 difference came out -10.7, +11.6, -26.6 and +3.4 ms. At that sample size from one client it is measuring the network.

Verified on Cilium 1.18.10 with cilium-envoy v1.36.6 on Kubernetes 1.33. Envoy's documentation does not list X25519MLKEM768 among supported curves, so check your build rather than trusting the docs.

Background

Written up in detail, including how the diagnosis nearly went wrong, at Post-quantum TLS on Cilium Gateway API.

License

Apache-2.0

About

Set Envoy ecdh_curves on a Cilium Gateway API listener via a mutating admission webhook. Adds hybrid post-quantum TLS key exchange (X25519MLKEM768) where Cilium exposes no knob for it.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages