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.
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.
Two better options exist, in order:
- Kubernetes 1.34 or newer: express the same injection declaratively as a
MutatingAdmissionPolicywith no service to run at all. This project exists because that API is not available before 1.34, while a plainMutatingWebhookConfigurationhas been GA for years. - Cilium implementing
tls.options: Gateway API already reserves the place for this. A listener'stls.optionsis 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.gopasses the listener TLS block to atoTLShelper that resolves certificate references and nothing else. If that changes, delete this.
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| 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.
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"}}}'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/nullTwo 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.
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.
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.
Written up in detail, including how the diagnosis nearly went wrong, at Post-quantum TLS on Cilium Gateway API.
Apache-2.0