Post-quantum and TLS

ECDSA P-384 certificates with cert-manager, the secure way

You asked cert-manager for a certificate and got RSA-2048, the default nobody chose. For an edge that already runs ML-KEM-1024, the certificate is now the weakest link, and it is the one you can fix in two lines.

The short answer

Create an explicit cert-manager Certificate with privateKey.algorithm ECDSA, privateKey.size 384 and rotationPolicy Always. cert-manager then generates a P-384 key, a fresh one at every renewal, and Let's Encrypt signs it from an ECDSA P-384 intermediate. Check the Secret with openssl before and after the change.

Updated Houssam Hammoudi, CTOTested with cert-manager v1.21.2, cmctl v2.6.1, Envoy Gateway v1.9.1, Kubernetes 1.34 (kind); kubeconform 0.7.0

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

cert-manager generates the private key for every Certificate. If you do not say which algorithm, it uses RSA with a 2048-bit key. If you say ECDSA and forget the size, it uses P-256. Both are sound today. RSA-2048 is also the first to go: NIST's transition draft (IR 8547) deprecates 112-bit signatures after 2030, five years before all classical signatures are disallowed.

P-384 is the curve of NSA's CNSA 1.0 suite, it is the natural pair for an edge that runs ML-KEM-1024 key exchange, and every current browser supports it.

The second failure is key reuse. Before cert-manager v1.18 the default was rotationPolicy: Never: the same private key was reused at every renewal, for years. A key that leaked once stayed valid through every new certificate.

The third failure hides in shortcuts. Annotating an Ingress or a Gateway creates a Certificate for you, with the defaults, unless you add the private key annotations too.

What the docs say

If algorithm is specified and size is not provided, key size of 2048 will be used for RSA key algorithm and key size of 256 will be used for ECDSA key algorithm.

Source: cert-manager API reference, CertificatePrivateKey

In cert-manager >= v1.18.0 the default is rotationPolicy: Always ; the private key is rotated automatically.

Source: cert-manager docs, Certificate resource

Subscriber certificates containing an ECDSA public key will be issued from one of the ECDSA intermediates

Source: Let's Encrypt, Chains of Trust

The cert-manager docs do not say which curve to choose, and the Let's Encrypt page does not say that its default chain for an ECDSA certificate still ends in a cross-sign by the RSA root ISRG Root X1. That cross-sign is there for old clients and is not a weakness of your key.

The secure configuration

An explicit Certificate for the Gateway listener:

yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: www-example-com
  namespace: edge                  # the Gateway's namespace
spec:
  secretName: www-example-com-tls  # referenced by the Gateway listener
  dnsNames:
  - www.example.com
  issuerRef:
    kind: ClusterIssuer
    name: letsencrypt
  privateKey:
    algorithm: ECDSA               # not the RSA default
    size: 384                      # P-384; the ECDSA default is 256
    rotationPolicy: Always         # new key at every renewal (default since v1.18, set it anyway)
    encoding: PKCS8

If you use Ingress annotations instead of a Certificate, add the private key annotations next to the issuer:

yaml
metadata:
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
    cert-manager.io/private-key-algorithm: ECDSA
    cert-manager.io/private-key-size: "384"
    cert-manager.io/private-key-rotation-policy: Always

For a Gateway, prefer the explicit Certificate above: it keeps the key settings in one reviewed object.

For a private CA issuer (the ca issuer), the CA signs with its own key. Make the CA key P-384 as well. In the lab, cert-manager then signed with ecdsa-with-SHA384 on its own; signatureAlgorithm: ECDSAWithSHA384 on the Certificate makes that explicit. With an ACME issuer the CA chooses the signature, and the field has no effect.

Prove it

The test in secure-tests/ecdsa-p-384-certificates-cert-manager/run.sh checks the Certificate against the cert-manager v1 CRD schema with kubeconform, and a copy with a misspelled rotationPolicy:

text
== kubeconform certificate.yaml
Summary: 1 resource found in 1 file - Valid: 1, Invalid: 0, Errors: 0, Skipped: 0
== kubeconform bad.yaml
bad.yaml - Certificate www-example-com is invalid: problem validating schema. Check JSON formatting: jsonschema validation failed with 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/cert-manager.io/certificate_v1.json#' - at '/spec/privateKey/rotationPolicy': value must be one of 'Never', 'Always'
Summary: 1 resource found in 1 file - Valid: 0, Invalid: 1, Errors: 0, Skipped: 0

The schema does not check the key size (a size of 385 passes it), so the real proof is the issued key.

Issued on a cluster

There is no public ACME endpoint in the lab, so the Certificate above was issued by a private CA whose intermediate is itself P-384 (the one from an offline internal CA), for www.internal.example.com:

text
$ kubectl get certificate www-example-com -n edge
NAME              READY   SECRET                AGE
www-example-com   True    www-example-com-tls   10s
$ kubectl get secret www-example-com-tls -n edge -o jsonpath='{.data.tls\.crt}' \
    | base64 -d | openssl x509 -noout -text \
    | grep -E "Public Key Algorithm|Public-Key|NIST CURVE|Signature Algorithm|Issuer:" | sort -u
                NIST CURVE: P-384
                Public-Key: (384 bit)
            Public Key Algorithm: id-ecPublicKey
        Issuer: CN = Example Cluster Issuing CA 2026, O = Example
        Signature Algorithm: ecdsa-with-SHA384
    Signature Algorithm: ecdsa-with-SHA384
$ kubectl get secret www-example-com-tls -n edge -o jsonpath='{.data.tls\.key}' | base64 -d | head -1
-----BEGIN PRIVATE KEY-----

BEGIN PRIVATE KEY is PKCS#8, as encoding: PKCS8 asks (the default would be BEGIN EC PRIVATE KEY). With Let's Encrypt, expect the same key lines and an issuer that is one of its ECDSA intermediates (YE1 or YE2 at the time of writing).

Rotation. The public key hash before and after a forced renewal:

text
$ kubectl get secret www-example-com-tls -n edge -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -pubkey | sha256sum
abdd2cefa649d66c24d3d28aef9a0553587bed2d458a6901f6a980db8b142f72  -
$ cmctl renew www-example-com -n edge
Manually triggered issuance of Certificate edge/www-example-com
$ kubectl get secret www-example-com-tls -n edge -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -pubkey | sha256sum
cd202414485de28163f089e36874728860f9b7fd8f05a367282c9cde97a73a67  -

Served. The Secret on a Gateway listener, seen by a client:

text
$ openssl s_client -connect <gateway>:443 -servername www.internal.example.com </dev/null 2>/dev/null \
    | openssl x509 -noout -text | grep -E "Public-Key|NIST CURVE"
                Public-Key: (384 bit)
                NIST CURVE: P-384
Peer signature type: ecdsa_secp384r1_sha384

On your cluster, run the same commands against your Secret and your public name (openssl s_client -connect www.example.com:443 -servername www.example.com). If READY stays False, run kubectl describe certificate www-example-com -n edge and read the events of the latest CertificateRequest.

Mistakes people make

Setting the algorithm and forgetting the size

algorithm: ECDSA alone gives P-256. The size is a separate field, and the annotation is a separate annotation.

Changing the algorithm on a Certificate with rotationPolicy Never

With Never, cert-manager re-uses the private key already in the Secret at every issuance. Changing the algorithm alone does not give you a new P-384 key. Set rotationPolicy: Always in the same change.

Expecting signatureAlgorithm to change a Let's Encrypt certificate

The ACME CA decides how it signs. The field matters for the ca and self-signed issuers only.

Mixing key types without planning for old clients

Very old clients may lack ECDSA support. If you still serve them, Envoy and nginx can hold an RSA and an ECDSA certificate side by side; check your access logs before you drop RSA.

Believing P-384 is post-quantum

It is not. A large quantum computer breaks ECDSA at any size. P-384 is the strongest classical choice while publicly trusted post-quantum certificates do not exist; see the ML-DSA page. The key exchange, which is what recorded traffic depends on, is covered by ML-KEM on the edge.

Checklist

  • Write an explicit Certificate for every public hostname.
  • Set privateKey.algorithm: ECDSA and privateKey.size: 384.
  • Set privateKey.rotationPolicy: Always.
  • Add the private key annotations wherever you still use Ingress annotations.
  • Check the Secret shows NIST CURVE: P-384.
  • Force one renewal and confirm the public key hash changed.
  • Pair the certificate with ML-KEM key exchange on the listener.

Two fields, and the default nobody chose becomes the curve you did.

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