Post-quantum and TLS

Post-quantum mTLS in a Kuma mesh, the secure way

Kuma says every service-to-service call is mTLS, and it is. Look at the sidecar counters and you find TLS 1.2 with classical X25519 on every hop, which is fine today and readable later by anyone recording your traffic.

The short answer

Set MeshTLS tlsVersion to TLS13, then add a MeshProxyPatch that sets ecdhCurves to MLKEM1024, X25519MLKEM768, X25519 on both the inbound listener and the outbound cluster TLS contexts. Both sides must offer ML-KEM: patching only the server leaves every connection on X25519. Verify with the ssl.curves counters of both sidecars.

Updated Houssam Hammoudi, CTOTested with Kuma 2.14.5 (universal mode), Envoy 1.38.4 in kuma-dp

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

A Kuma mesh encrypts every call between sidecars with mTLS. Kuma chooses the certificates; Envoy, inside each sidecar, chooses the TLS version and the key exchange. With Kuma's defaults, a sidecar-to-sidecar connection in Kuma 2.14 negotiates TLS 1.2 and X25519. Service-to-service traffic often carries the most sensitive data you have, and all of it is recorded-now, decrypt-later material.

Kuma's MeshTLS policy fixes the version but has no field for the key exchange group. The only way to set it is a MeshProxyPatch, a low-level patch on the Envoy configuration Kuma generates.

That patch has a trap. A TLS client offers groups, and the server picks one of them. The inbound listener is the server side; the outbound cluster is the client side. Patch only the inbound side, which is what most people do first, and the client still offers only classical groups. Nothing changes, and nothing tells you.

What the docs say

This policy enables Kuma to configure TLS mode, ciphers and version.

Source: Kuma docs, MeshTLS

The MeshProxyPatch provides configuration options for low-level Envoy resources that Kuma policies do not directly expose.

Source: Kuma docs, MeshProxyPatch

If you use JSONPatch, remember to always use camelCase instead of snake_case in path parameter even though you see snake_case in Envoy Config Dump.

Source: Kuma docs, MeshProxyPatch

If not specified, the default curves will be used.

Source: Envoy docs, TlsParameters ecdh_curves

The MeshTLS page does not mention key exchange groups at all. The MeshProxyPatch page shows how to patch tlsParams on a cluster, but not that the listener side needs the same patch.

The secure configuration

Kubernetes form (in universal mode the spec is identical, with type, name and mesh at the top level instead of metadata):

yaml
# TLS 1.3 for every mesh connection: ML-KEM groups exist only in TLS 1.3.
apiVersion: kuma.io/v1alpha1
kind: MeshTLS
metadata:
  name: tls13
  namespace: kuma-system
  labels:
    kuma.io/mesh: default
spec:
  targetRef:
    kind: Mesh
  rules:
  - default:
      tlsVersion:
        min: TLS13
        max: TLS13
---
# Server side: the TLS context of every inbound listener.
apiVersion: kuma.io/v1alpha1
kind: MeshProxyPatch
metadata:
  name: pq-inbound
  namespace: kuma-system
  labels:
    kuma.io/mesh: default
spec:
  targetRef:
    kind: Mesh
  default:
    appendModifications:
    - listener:
        operation: Patch
        match:
          origin: inbound
        jsonPatches:
        - op: add        # "add" replaces tlsParams if MeshTLS already set it
          path: /filterChains/0/transportSocket/typedConfig/commonTlsContext/tlsParams
          value:
            tlsMinimumProtocolVersion: TLSv1_3
            tlsMaximumProtocolVersion: TLSv1_3
            ecdhCurves: [MLKEM1024, X25519MLKEM768, X25519]
---
# Client side: the TLS context of every outbound cluster.
apiVersion: kuma.io/v1alpha1
kind: MeshProxyPatch
metadata:
  name: pq-outbound
  namespace: kuma-system
  labels:
    kuma.io/mesh: default
spec:
  targetRef:
    kind: Mesh
  default:
    appendModifications:
    - cluster:
        operation: Patch
        match:
          origin: outbound
        jsonPatches:
        - op: add
          path: /transportSocket/typedConfig/commonTlsContext/tlsParams
          value:
            tlsMinimumProtocolVersion: TLSv1_3
            tlsMaximumProtocolVersion: TLSv1_3   # Envoy's client default max is TLS 1.2
            ecdhCurves: [MLKEM1024, X25519MLKEM768, X25519]

MLKEM1024 comes first, so two patched sidecars agree on ML-KEM-1024 (NIST level 5). X25519 stays last so a sidecar that has not received the patch yet can still connect during the rollout.

Roll out to one namespace first with targetRef: {kind: MeshSubset, tags: {...}} instead of Mesh, check the counters, then widen.

Prove it

The test in secure-tests/post-quantum-mtls-kuma-mesh/run.sh runs Kuma 2.14.5 in universal mode on Docker: a control plane and two workloads, web calling backend through their sidecars, with builtin mTLS. After each step it restarts both sidecars, sends a request, and reads Envoy's TLS counters for the backend's inbound listener and the web's outbound cluster through the control plane API (kumactl inspect dataplane <name> --type=stats returns the same counters).

text
== 1. Builtin mTLS, Kuma defaults
  backend, inbound listener (server side):
    ssl.curves.X25519: 2
    ssl.versions.TLSv1.2: 2
  web, outbound cluster to backend (client side):
    ssl.curves.X25519: 2
    ssl.versions.TLSv1.2: 2

== 2. + MeshTLS tlsVersion TLS13
  backend, inbound listener (server side):
    ssl.curves.X25519: 2
    ssl.versions.TLSv1.3: 2
  web, outbound cluster to backend (client side):
    ssl.curves.X25519: 2
    ssl.versions.TLSv1.3: 2

== 3. + MeshProxyPatch on inbound listeners only
  backend, inbound listener (server side):
    ssl.curves.X25519: 1
    ssl.versions.TLSv1.3: 1
  web, outbound cluster to backend (client side):
    ssl.curves.X25519: 1
    ssl.versions.TLSv1.3: 1

== 4. + MeshProxyPatch on outbound clusters too
  backend, inbound listener (server side):
    ssl.curves.MLKEM1024: 2
    ssl.versions.TLSv1.3: 2
  web, outbound cluster to backend (client side):
    ssl.curves.MLKEM1024: 2
    ssl.versions.TLSv1.3: 2

== Envoy in the Kuma data plane image
1.38.4-contrib/Modified/RELEASE/BoringSSL

Step 3 is the trap: the server offers ML-KEM, the client does not, and the connection stays on X25519. Step 4 is the fix.

On a Kubernetes cluster:

bash
kumactl inspect dataplane <backend-pod>.<namespace> --type=stats | grep -E "_[0-9]+\.ssl\.curves\."
kumactl inspect dataplane <web-pod>.<namespace> --type=stats | grep -E "^cluster\..*\.ssl\.curves\."

What you should see: ssl.curves.MLKEM1024 counters growing on both sides for new connections. Existing connections keep their old key exchange until they close; restart a test workload to see the change at once.

Mistakes people make

Patching only the server side

The inbound listener picks from what the client offers. Until the outbound clusters offer ML-KEM, every connection stays classical. Patch both, and check both sets of counters.

Forgetting the client's TLS 1.2 ceiling

Envoy's upstream (client) TLS contexts default to a maximum of TLS 1.2. Set tlsMaximumProtocolVersion: TLSv1_3 in the outbound patch, or rely on MeshTLS setting it, but never set only the minimum.

Assuming filter chain 0 in permissive mode

In strict mTLS the inbound listener has one filter chain, and the patch path uses index 0. Permissive mode may add chains for plaintext traffic. Check the config dump (kumactl inspect dataplane <name> --type=config-dump) for the index of the TLS chain before you patch, or run strict mTLS.

Outbound clusters without TLS

A JSON patch needs its path to exist. Clusters Kuma generates without a TLS transport socket, for example to external services reached in plain text, do not have it. Check the config dump of a proxy that uses them after you apply the patch, and scope the patch if needed.

Treating the patch as set-and-forget

MeshProxyPatch edits generated Envoy config, which can change shape between Kuma releases. Re-run the counter check after every Kuma upgrade.

Forgetting the control plane channel

The sidecar's own connection to the Kuma control plane (the ads_cluster) is outside MeshProxyPatch. In this test it negotiated TLS 1.2 with X25519. It carries configuration and certificates, not application data, but list it in your inventory.

Checklist

  • Apply MeshTLS with tlsVersion min and max TLS13.
  • Apply the inbound MeshProxyPatch with ecdhCurves: [MLKEM1024, X25519MLKEM768, X25519].
  • Apply the outbound MeshProxyPatch with the same groups and a TLS 1.3 maximum.
  • Roll out with MeshSubset first, then the whole mesh.
  • Check ssl.curves.MLKEM1024 on both the inbound listener and the outbound cluster.
  • Check permissive-mode listeners and plain-text outbound clusters in the config dump.
  • Re-check after every Kuma upgrade.

mTLS in a mesh is a conversation with two sides. Teach both of them the new words, or they keep talking in the old ones.

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