Post-quantum TLS key exchange on Cilium Gateway API
Cilium Gateway API has no knob for Envoy TLS curves. We added X25519MLKEM768 with a mutating admission webhook. The method, the measured cost, the caveats.
Someone recording your encrypted traffic today cannot read it today. They can store it and wait. When a cryptographically relevant quantum computer arrives, everything they kept that was protected by classical key exchange becomes readable, retroactively, including sessions from years earlier.
That is the harvest now, decrypt later problem, and it is why post-quantum key exchange is worth deploying before quantum computers exist. As of today, connections to paperclip.inc negotiate hybrid post-quantum key exchange (X25519MLKEM768). We host companies of AI agents on infrastructure we operate ourselves in the EU, which means the front door is ours to change.
Hybrid post-quantum key exchange means a TLS 1.3 handshake that derives its shared secret from both a classical algorithm (X25519) and a post-quantum one (ML-KEM-768, standardised by NIST as FIPS 203), so the session stays secure unless both are broken. Getting it through our ingress took a mechanism nobody had published, because Cilium’s Gateway API has no way to configure it.
What the quantum threat actually breaks
The quantum threat is narrower than the marketing around it suggests, and the difference decides what actually needs changing.
Symmetric encryption is fine. The usual line is that Grover’s algorithm halves your key length, so you should double it. That framing oversells the risk twice over: Grover halves the effective security level in bits rather than the key itself, and its quadratic speedup is essentially non-parallelisable, which is why NIST treats AES-128 as its Category 1 security benchmark for post-quantum algorithms. AES-256, ChaCha20-Poly1305 and HMAC-SHA256 need no change. Data at rest was never the problem.
The problem is public-key cryptography, which Shor’s algorithm does break, and there the urgency splits in two.
To exploit a broken signature, an attacker has to use it live, while the certificate is valid and someone is watching. To exploit a broken key exchange, an attacker only has to have been recording. The traffic is already captured, sitting on a disk. Breaking the key exchange later unlocks it. That asymmetry is why post-quantum TLS rollouts start with key exchange, and why ours did too.
It is also why this has deadlines attached. The EU’s coordinated roadmap for the transition to post-quantum cryptography asks member states for national plans by the end of 2026 and high-risk use cases migrated by the end of 2030, with public-facing TLS in the first wave. Germany’s BSI goes further in TR-02102-1: it requires hybrid key exchange, post-quantum combined with a classical algorithm, rather than post-quantum alone. For a company hosting European data, hybrid is not a hedge on the way to something better. It is what the regulator asks for.
Envoy could do it. Cilium had no way to ask.
Key exchange is what protects a browser talking to us, so the work had to land on the public HTTPS ingress.
We terminate TLS at Envoy, managed by Cilium’s Gateway API implementation on the Talos and Hetzner cluster we autoscale with our own Karpenter provider. Envoy’s TLS stack is BoringSSL, which supports the hybrid group. The blocker was getting the setting to Envoy at all.
Cilium’s Gateway API translation has no knob for Envoy TLS curves. It generates a CiliumEnvoyConfig from the Gateway resource, and that translation handles ALPN, proxy protocol, socket options and a handful of others. There is no field for tls_params, therefore no way to set ecdh_curves, and no supported way to add Envoy configuration to a Gateway-managed resource at all. The upstream request for one, cilium#38089, used ecdh_curves and X25519MLKEM768 as its own motivating example. It drew no maintainer response and was auto-closed as stale.
So the setting exists in Envoy and the path to reach it does not exist in Cilium. There are workarounds that take the Gateway away from the operator and hand-maintain the config, along with the load balancer and certificate wiring that come with it. Nobody had published a way across the gap that leaves the Cilium operator in charge, so we built one.
Why the Cilium operator reverts your CiliumEnvoyConfig patch
The obvious move is to edit the generated CiliumEnvoyConfig directly. We tried that. The patch was accepted, and the field was already gone when we read it back 0.6 seconds later.
Our engineering notes recorded two things that contradicted each other: that the Cilium operator strips the field, and that the resource retains it. They cannot both be true, and the difference decided the whole approach, because three explanations fit the evidence:
| Explanation | If true | Test | Result |
|---|---|---|---|
| The operator reverts drift on the resource it owns | Injecting during admission wins, because the operator’s own write carries the field | Put the same field on a config object no operator owns | Survives every re-read |
| The cilium agent ignores the field for Gateway-managed configs | Nothing done at admission time matters | Serve a config we own carrying the field and offer a hybrid-only handshake | Ruled out: live Envoy answered ServerHello -> NamedGroup: X25519MLKEM768 (4588) |
| The custom resource definition prunes the unknown field | Pruning runs after mutating admission, so a webhook’s field is pruned too | Read the CRD schema | x-kubernetes-preserve-unknown-fields: true, so pruning is impossible |
The second and third would have killed the approach outright. They would also have killed the fallback we were holding in reserve, a Kubernetes 1.34 MutatingAdmissionPolicy, since that injects at exactly the same point. Taking either on trust would have committed us to a one-way cluster upgrade and a full node reboot for a mechanism that could not have worked.
The experiments were cheap and the answer was the first row: the operator reverts drift, within about a second, and nothing else touches the field.
Which is good news, because a controller that reliably rewrites a resource is a controller whose writes pass through admission control.
The fix: inject ecdh_curves with an admission webhook
An admission webhook watches CiliumEnvoyConfig writes in the gateway namespace and patches the one the Gateway generates, injecting the curve preference into every filter chain that carries 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.
The injected fragment is small. This is what lands in the TLS context of the generated CiliumEnvoyConfig:
commonTlsContext: tlsParams: ecdhCurves: - X25519MLKEM768 # hybrid post-quantum - X25519 # classical fallback - P-256And the registration, where two fields are doing the safety work:
webhooks: - name: pqc.paperclip.inc failurePolicy: Ignore sideEffects: None reinvocationPolicy: IfNeeded rules: - apiGroups: ["cilium.io"] apiVersions: ["v2"] resources: ["ciliumenvoyconfigs"] operations: ["CREATE", "UPDATE"]Three details are load-bearing.
It fails open. failurePolicy: Ignore, and every path through the code returns an allowing response, including on input it cannot parse. If the webhook is down or broken, config writes pass through unmodified. The already-stored setting stays put, and we only lose post-quantum key exchange the next time the operator rewrites that config, until the webhook is healthy again. It can degrade our crypto. It cannot stop our front door from being configured.
The curve list keeps classical fallbacks. A hybrid-only list would hard-fail every client without ML-KEM support, which is the standard way to turn a security upgrade into an outage. Post-quantum for clients that have it, unchanged for everyone else.
It finds the TLS chain by inspection, not by index. Indexing the filter chain by position works until a listener is added or reordered, at which point it stops matching and drops the front door back to classical with nothing to signal it. Controls that fail quietly into a weaker state are worse than ones that fail loudly, so the webhook patches every chain that actually carries a TLS context, and skips any that already has the setting so a repeated call cannot clobber it.
Before pointing it at the live front door, we pointed it at a throwaway config object that routed nothing, confirmed the curves were injected there, and confirmed the real one was untouched.
One failure mode was obvious enough to check up front: the operator wants the field absent, the webhook keeps putting it back, and that could have become a permanent write loop against etcd. Measured across 60 seconds, the resource version does not move.
One gotcha is worth more than the rest of this section if you are reproducing it. Nudging the Gateway to trigger 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. The cutover needs a real write. We forced one by patching the config, which the operator then reverted, and that revert was itself the write that came back through the webhook carrying the curves.
Verified on Cilium 1.18.10 with cilium-envoy v1.36.6, on Kubernetes 1.33. Envoy’s own documentation does not list X25519MLKEM768 among supported curves, so the version matters: check yours rather than assuming.
What post-quantum key exchange costs in bytes and latency
ML-KEM-768 keys are much larger than elliptic curve keys, so the client hello grows.
| Measurement | Classical X25519 | Hybrid X25519MLKEM768 |
|---|---|---|
ClientHello key_share extension | 38 bytes | 1222 bytes |
| Handshake bytes out | 338 | 1522 |
| Handshake bytes in | 3182 | 4269 |
| Median handshake | 38.43 ms | 39.08 ms |
| p90 / p99 | 60.09 / 103.99 ms | 73.12 / 124.92 ms |
| Standard deviation | 15.18 ms | 20.89 ms |
Method: 200 handshakes per arm, the two arms interleaved from a single EU client so network drift hits both equally. Measured against cloudflare.com, a reference endpoint that already supported the hybrid group, because at the time of measurement our own edge did not. These characterise the cost of hybrid key exchange in general, not our edge specifically.
So it costs about 1.2 KB more from the client and 1.1 KB more back from the server, roughly 2.3 KB per handshake, every handshake. The median moved 0.65 ms, which is under one standard error of the mean at this sample size, so from a single vantage point it is not distinguishable from noise. Be careful about generalising that: the tail widens by around 20% at p90 and p99, and Cloudflare measured a real handshake slowdown of a few percent at internet scale. Small here does not mean free everywhere.
The byte count has a second-order effect worth knowing about. A 1.2 KB key_share pushes the ClientHello past a single packet, so it splits across segments. That is mostly uninteresting over TCP and genuinely matters for QUIC and for middleboxes that mishandle a fragmented hello.
A couple of kilobytes per handshake buys the property that traffic recorded today does not become readable later. That is a good trade.
What post-quantum key exchange does not protect
Our certificates are not post-quantum. No publicly trusted certificate authority issues ML-DSA certificates as of July 2026. The key exchange is protected; the signature proving we are who we say we are is still classical. That is the right thing to fix first, for the timing reason above, but “post-quantum key exchange” and “post-quantum TLS” are different claims and we are only making the first one, here and in our security documentation.
Node-to-node key exchange is still classical. Traffic between nodes in our network-isolated cluster is encrypted with WireGuard, whose ChaCha20-Poly1305 cipher is quantum-resistant, but whose key exchange is classical X25519. There is no way to add a post-quantum pre-shared key on top today: Cilium manages its WireGuard keys internally and exposes no hook for one.
We claim nothing about traffic to model providers. Agents reach provider APIs directly with your own key, over whatever those providers negotiate. We do not control that far end.
This one could not be rehearsed on staging. We have one Gateway and one generated config, shared by staging and production, so our usual rule of proving a change on staging first did not apply. We compensated structurally rather than procedurally: fail open, scope to a single named object, rehearse against a throwaway config that routes nothing, and keep rollback to deleting one object. That is a deliberate deviation and we would rather name it than have it found.
Stated as a table, because “is Paperclip post-quantum” deserves a precise answer:
| Path | Key exchange | Status |
|---|---|---|
| Browser to paperclip.inc | X25519MLKEM768 | Post-quantum |
| Our TLS certificate | ECDSA signature | Classical, no public CA issues ML-DSA yet |
| Node to node inside the cluster | WireGuard X25519 | Classical key exchange, quantum-resistant cipher |
| Agent to model provider API | Whatever the provider negotiates | Not ours to control |
| Backup transport to object storage | Classical | Encrypted at rest, classical key exchange in transit |
We would rather publish a smaller claim we can prove than a larger one we cannot.
How to verify post-quantum TLS with OpenSSL
You do not have to take the headline claim on trust. With OpenSSL 3.5 or later:
# Offer ONLY the hybrid group. A successful handshake proves it is negotiated.openssl s_client -connect paperclip.inc:443 -servername paperclip.inc \ -groups X25519MLKEM768 -brief </dev/null
# Confirm classical clients still work.openssl s_client -connect paperclip.inc:443 -servername paperclip.inc \ -groups X25519:P-256 -brief </dev/nullThe first should establish a TLS 1.3 connection rather than fail. Two client-side traps: any OpenSSL before 3.5 has no ML-KEM support at all, and the openssl that ships on macOS is LibreSSL, which will reject the group name outright. Both produce a failure that tells you about your client rather than about our server. The </dev/null matters too, or s_client connects and then sits waiting on your keyboard.
If you run this yourself, the equivalent check inside a cluster is that the setting survived a regeneration:
kubectl -n gateway get ciliumenvoyconfig <name> -o yaml | grep -A4 ecdhCurvesChrome has negotiated X25519MLKEM768 by default since version 131, and Firefox since 132, so if you are reading this in an up-to-date browser, the connection that delivered this page was almost certainly protected by post-quantum key exchange already.
Takeaways
- Key exchange first. Recorded traffic is the asset an attacker keeps. Symmetric encryption and data at rest were already fine; public-key key exchange was not, and the EU roadmap puts public-facing TLS in the first wave.
- Verify the cause before building the fix. Two of the three explanations for our blocker would have made both our approach and our fallback plan useless. A cheap experiment beforehand was worth more than an expensive cluster upgrade afterwards.
- A controller that reliably rewrites a resource is a seam. Admission control turns that rewrite into the thing that applies your setting. That generalises to any Envoy option Cilium does not expose.
Everything we terminate, we operate: our own hardware, in the EU, with the crypto choices published rather than assumed. The rest of the data path, including what is encrypted with what and every subprocessor, is in our trust center. If you want a company of AI agents running on it, that is €10 per month for as many companies as you run, with a 7-day trial and no card.
FAQ
Does Cilium Gateway API support post-quantum TLS?
Not directly. Cilium’s Gateway API translation generates a CiliumEnvoyConfig and exposes ALPN, proxy protocol and socket options, but no field for Envoy’s tls_params, so there is no supported way to set ecdh_curves on a Gateway-managed listener. The upstream feature request drew no maintainer response and was auto-closed as stale. Envoy itself supports the hybrid group, because its TLS stack is BoringSSL.
How do you set ecdh_curves on a Cilium-managed Envoy listener?
Run a mutating admission webhook on writes to the generated CiliumEnvoyConfig and inject ecdh_curves into the TLS filter chain. The Cilium operator reverts manual edits within about a second, but its own writes pass through admission control, so every regeneration arrives already carrying the setting.
Is X25519MLKEM768 the same as X25519Kyber768?
No, and they are not interoperable. X25519Kyber768 was the earlier draft hybrid built on pre-standard Kyber. X25519MLKEM768 uses ML-KEM-768, the version NIST standardised as FIPS 203, and it is what current Chrome, Firefox and OpenSSL 3.5 negotiate.
How much does post-quantum key exchange slow down TLS?
Not measurably. The client hello grows by about 1.2 KB, a 38-byte key share becoming 1222 bytes, and the median handshake moved from 38.43 ms to 39.08 ms. That 0.65 ms difference sits inside the 15 ms standard deviation of the baseline.
Does AES need to be replaced for quantum resistance?
No. Grover’s algorithm gives only a quadratic speedup that does not parallelise usefully, and NIST uses AES-128 as its Category 1 benchmark for post-quantum security. AES-256, ChaCha20-Poly1305 and HMAC-SHA256 are unaffected, so disk encryption, backups and stored secrets need no change.
Do I need post-quantum certificates too?
Not yet, and no publicly trusted certificate authority issues them today. Certificates protect against live signature forgery, which an attacker has to perform in the moment. Key exchange protects recorded traffic, which an attacker can decrypt years later. Key exchange is the one with a deadline.
What happens to browsers that do not support post-quantum key exchange?
Nothing changes for them. The curve list keeps X25519 and P-256 behind the hybrid group, so clients without ML-KEM support connect exactly as they did before.