Post-quantum and TLS

ML-KEM-1024 on Envoy Gateway, the secure way

Envoy Gateway is up, the certificate is fresh, TLS 1.3 is on, and every handshake still uses plain X25519. Envoy has spoken ML-KEM for a while; it just does not offer it until you ask.

The short answer

Attach a ClientTrafficPolicy to the Gateway with tls.minVersion "1.3" and tls.ecdhCurves [MLKEM1024, X25519MLKEM768, X25519]. Envoy then prefers ML-KEM-1024 (NIST level 5) for clients that offer it, gives browsers the X25519MLKEM768 hybrid, and keeps X25519 for classical clients. Verify from the client side with OpenSSL 3.5.

Updated Houssam Hammoudi, CTOTested with Envoy Gateway v1.9.1, Envoy 1.39.1, OpenSSL 3.5.8, Kubernetes 1.34 (kind)

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

Envoy picks the TLS key exchange group from a list. When you do not set that list, Envoy uses its built-in default: X25519 and P-256. No ML-KEM group is in it. Envoy Gateway passes nothing unless you tell it to, so a fresh Gateway runs classical key exchange for every client, including browsers that offer X25519MLKEM768 in every handshake.

That matters because of "harvest now, decrypt later". Traffic recorded today with a classical key exchange can be decrypted when a large quantum computer exists. The certificate does not help here; the key exchange does.

The second trap is the order of preference. If you add only X25519MLKEM768, you get NIST level 3 and never level 5. If you add only MLKEM1024, browsers cannot connect, because no browser offers it yet.

What the docs say

If specified, the TLS connection will only support the specified ECDH curves. If not specified, the default curves will be used.

Source: Envoy docs, TlsParameters

ECDHCurves specifies the set of supported ECDH curves. In non-FIPS Envoy Proxy builds the default curves are:

  • X25519
  • P-256

Source: Envoy Gateway API reference, ClientTLSSettings

Neither page mentions ML-KEM, which group names are valid, or that the list order is the server's preference. The names MLKEM1024 and X25519MLKEM768 are BoringSSL group names that Envoy accepts in this field; the test below shows it.

The secure configuration

yaml
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: ClientTrafficPolicy
metadata:
  name: pq-tls
  namespace: edge                # same namespace as the Gateway
spec:
  targetRefs:
  - group: gateway.networking.k8s.io
    kind: Gateway
    name: edge
  tls:
    # ML-KEM groups exist only in TLS 1.3; a TLS 1.2 client would get
    # classical ECDHE.
    minVersion: "1.3"
    # Server preference, strongest first:
    #  MLKEM1024       ML-KEM-1024, NIST level 5, the size CNSA 2.0 names
    #  X25519MLKEM768  hybrid, NIST level 3, what browsers offer today
    #  X25519          classical fallback for clients without ML-KEM
    ecdhCurves:
    - MLKEM1024
    - X25519MLKEM768
    - X25519

Envoy honors this order even when the client sends its key share for a weaker group first: it asks for MLKEM1024 when the client lists it.

If some clients support neither X25519 nor ML-KEM (some FIPS-only or old Java clients use only NIST curves), add P-256 at the end.

One ClientTrafficPolicy per Gateway. Envoy Gateway attaches only one ClientTrafficPolicy to a Gateway. If one already targets it (for client IP detection, for example), a second one is refused, and kubectl apply still says created. Put the tls block into the existing policy instead:

yaml
# One ClientTrafficPolicy per Gateway: client IP detection and TLS together.
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: ClientTrafficPolicy
metadata:
  name: client-ip
  namespace: edge
spec:
  targetRefs:
  - group: gateway.networking.k8s.io
    kind: Gateway
    name: edge
  clientIPDetection:
    xForwardedFor:
      numTrustedHops: 1
  tls:
    minVersion: "1.3"
    ecdhCurves:
    - MLKEM1024
    - X25519MLKEM768
    - X25519

Prove it

What Envoy Gateway renders (offline, no cluster)

The test in secure-tests/mlkem1024-envoy-gateway/run.sh renders the policy with egctl experimental translate, which runs the Envoy Gateway translator without a cluster:

bash
egctl experimental translate --from gateway-api --to xds --type listener \
  --output yaml --file gateway-and-policy.yaml -n edge | grep -A6 "tlsParams:"
text
  ClientTrafficPolicy Accepted="True": Policy has been accepted.
                  tlsParams:
                    ecdhCurves:
                    - MLKEM1024
                    - X25519MLKEM768
                    - X25519
                    tlsMaximumProtocolVersion: TLSv1_3
                    tlsMinimumProtocolVersion: TLSv1_3

The same listener, run on Envoy 1.39.1

The same script runs that listener on a standalone Envoy 1.39.1 (the Envoy that Envoy Gateway v1.9.1 ships) and probes it with OpenSSL 3.5.8. First without ecdh_curves, then with it:

text
== Envoy 1.39.1, no ecdh_curves (Envoy defaults)
$ openssl s_client -connect edge.example.com:443 -groups MLKEM1024:X25519MLKEM768:X25519
Peer Temp Key: X25519, 253 bits
$ openssl s_client -connect edge.example.com:443 -groups X25519MLKEM768:X25519
Peer Temp Key: X25519, 253 bits

== ecdh_curves: [MLKEM1024, X25519MLKEM768, X25519]
$ openssl s_client -connect edge.example.com:443 -groups MLKEM1024:X25519MLKEM768:X25519
Negotiated TLS1.3 group: MLKEM1024
$ openssl s_client -connect edge.example.com:443 -groups X25519MLKEM768:MLKEM1024
Negotiated TLS1.3 group: MLKEM1024
$ openssl s_client -connect edge.example.com:443 -groups X25519MLKEM768:X25519
Negotiated TLS1.3 group: X25519MLKEM768
$ openssl s_client -connect edge.example.com:443 -groups X25519
Peer Temp Key: X25519, 253 bits
$ openssl s_client -connect edge.example.com:443 -groups P-256
handshake failure (alert 40)

The second probe is the important one: the client lists MLKEM1024 second and sends its key share for the hybrid, and Envoy still settles on MLKEM1024. The third probe is what a browser offers. The last line is a client with only P-256: refused, because P-256 is not in the list.

On a cluster

The policy on a lab Gateway with an HTTPS listener for www.example.com, probed with OpenSSL 3.5.8 from a pod. The Gateway already had the client-ip policy from the IP blocklist page:

text
$ kubectl get clienttrafficpolicy -n edge -o jsonpath='...'
client-ip: Accepted=True Policy has been accepted.
pq-tls: Accepted=False Unable to target Gateway edge, another ClientTrafficPolicy has already attached to it

What the listener did in that state, on Envoy's defaults:

text
  -groups MLKEM1024:X25519MLKEM768:X25519 Peer Temp Key: X25519, 253 bits
  -groups X25519MLKEM768:MLKEM1024        ...SSL alert number 40
  -groups P-256                           Peer Temp Key: ECDH, prime256v1, 256 bits
  TLS 1.2:                                Peer Temp Key: X25519, 253 bits

No ML-KEM at all, and a client that offers only ML-KEM groups is refused. With the tls block merged into client-ip:

text
client-ip (merged): Accepted=True Policy has been accepted.
  -groups MLKEM1024:X25519MLKEM768:X25519 Negotiated TLS1.3 group: MLKEM1024
  -groups X25519MLKEM768:MLKEM1024        Negotiated TLS1.3 group: MLKEM1024
  -groups X25519MLKEM768:X25519           Negotiated TLS1.3 group: X25519MLKEM768
  -groups X25519                          Peer Temp Key: X25519, 253 bits
  -groups P-256                           ...SSL alert number 40
  TLS 1.2:                                ...tlsv1 alert protocol version ... SSL alert number 70

The same results as the standalone Envoy, and TLS 1.2 is refused by minVersion: "1.3".

On your own cluster:

bash
kubectl get clienttrafficpolicy -n edge \
  -o jsonpath='{range .items[*]}{.metadata.name}: {range .status.ancestors[*].conditions[*]}{.type}={.status} {.message}{end}{"\n"}{end}'

What you should see: Accepted=True for the policy that carries tls, and no other ClientTrafficPolicy on the same Gateway reporting Accepted=False.

bash
openssl s_client -connect www.example.com:443 -servername www.example.com \
  -groups X25519MLKEM768:MLKEM1024 </dev/null 2>/dev/null | grep -E "^Negotiated TLS1.3 group|^Peer Temp Key"

What you should see: Negotiated TLS1.3 group: MLKEM1024. If you see Peer Temp Key: X25519 or a handshake failure, the policy is not attached to the listener that served you, or a load balancer in front terminates TLS instead of Envoy.

Mistakes people make

A second ClientTrafficPolicy on the same Gateway

The first one wins; the second shows Accepted=False in its status and nothing else. Keep one policy per Gateway and add fields to it.

Stopping at X25519MLKEM768

The hybrid is the right answer for browsers and the wrong ceiling for everything else. Put MLKEM1024 first so every client that can do level 5 gets it: API clients, service-to-service calls, CLI tools on OpenSSL 3.5.

Listing MLKEM1024 alone on a public listener

No browser offers ML-KEM-1024 today. A list with only MLKEM1024 refuses every browser. Use a separate listener or hostname for level 5 only clients, as on the CNSA 2.0 page.

Testing from a client that cannot speak ML-KEM

An OpenSSL older than 3.5 never offers ML-KEM, so every server looks classical to it. See the key exchange check page.

A cloud load balancer terminating TLS in front of Envoy

If the load balancer terminates TLS, the client never talks to Envoy's TLS stack and your policy does nothing for that hop. Pass TLS through (TCP or PROXY protocol) to the Gateway, or configure the load balancer's own post-quantum settings.

Forgetting the hops behind the edge

The policy covers client to Gateway. Gateway to backend is a separate TLS context. See upstream TLS with a pinned CA and the mesh page.

Checklist

  • Attach one ClientTrafficPolicy per Gateway with minVersion: "1.3".
  • Set ecdhCurves to MLKEM1024, X25519MLKEM768, X25519, in that order.
  • Check the policy status reads Accepted=True.
  • Probe with -groups X25519MLKEM768:MLKEM1024 and expect MLKEM1024.
  • Probe with the client default and expect X25519MLKEM768.
  • Make sure no load balancer terminates TLS before Envoy.
  • Cover the upstream and mesh hops separately.

Three group names in the right order, and your edge speaks level 5 to anyone who asks.

H2-CPQE

Learn it on a live range

Post-quantum TLS, in Edge and Post-Quantum Networking: a real host in your browser, and every objective checked on the machine.

Start free

The Dome

Want it run for you?

The Dome puts post-quantum TLS, a WAF that blocks, signed DNS and a zero-trust mesh in front of your application. Tell us what you run.

See the Dome