
I built a fully post-quantum handshake, then went to see which of my servers could do it
Rustls turned ML-DSA certificates on by default, but only for private hierarchies. I built an mTLS where the key exchange, the server signature and the client identity are all post-quantum. I measured what the chain weighs, which version of each server supports it, and corrected a number I had got wrong: the private key is not nineteen times bigger, it is 128 bytes.
Rustls, the TLS library written in Rust, released version 0.23.44 on 7 September 2026, and it turns ML-DSA certificates on by default. The changelog note has a second sentence that is more interesting than the first: those certificates do not work in the public web PKI, but they do work in private certificate hierarchies. Which means post-quantum cryptography is not coming in through the front door of the internet. It is coming in through the back, through the PKIs each organisation issues for itself. Which happens to be the model Chilean open banking runs on.
I was left with a concrete question: can I build today, with tools I already have installed, a connection where nothing depends on cryptography a quantum computer would know how to break? Not five years from now, and not in a vendor demo. Today, on a laptop. It turns out you can, it took me an afternoon, and the road went through finding out which of my servers could do it and which could not. It also went through correcting a number I had got wrong myself. To tell you where it fails I have to start with what happens when two machines greet each other.
What actually happens when two machines connect
When two machines open an encrypted connection, before a single useful byte travels there is a back-and-forth conversation lasting milliseconds, called a handshake. Three things get settled there, in order.
First, agreeing on a key only the two of them know, without having agreed on it beforehand and while talking over a channel anyone can read. That is the key exchange.
Second, proving who each one is. The server presents a certificate and signs something right then, on the spot, to prove the key in that certificate is its own and not somebody else’s copy. When the connection requires mTLS, that is mutual authentication, the client does exactly the same.
Third, verifying that nobody altered any of the above. Only then does HTTP begin.
measured output
Supported groups: X25519MLKEM768:X25519:P-256
The client lists the groups it accepts to agree on a key, and the algorithms it accepts to be signed with. They are two independent lists, and that is where the confusion starts: a server can advertise a post-quantum CA and still not request post-quantum signatures, because RFC 8446 treats them separately.
X25519MLKEM768 comes first with nobody configuring it
measured output
Negotiated TLS1.3 group: X25519MLKEM768
This is where the secret that encrypts the session is agreed, and it is already post-quantum by default: the classic X25519 curve combined with ML-KEM-768. It shipped first because today traffic can be recorded and decrypted ten years from now; a signature only has to hold in the instant it is verified.
I did not ask for it: it came negotiated
measured output
Peer signature type: mldsa65
The server proves who it is by signing with ML-DSA-65. This is the half that ships turned off everywhere, and the one the public web will not accept: it only works inside a private certificate hierarchy.
the chain on the wire goes from 765 to 11,037 bytes
measured output
Peer signature type: mldsa44
In mTLS the client presents a certificate too, so two post-quantum chains travel in the same handshake. With no certificate, or one that does not validate against the CA, the server cuts with alert 116: it fails closed, and nobody gets in by presenting an algorithm the other end does not understand.
no valid certificate: alert 116, no exception
measured output
HTTP/1.1 200 OK — AUTENTICADO-PQ
From here nothing changes: AES over the agreed key. Symmetric cryptography needs no replacement, only longer keys, which is why the whole post-quantum change is concentrated in the greeting.
the backend received the 20,044-byte header intact
What matters for what follows: these are two different pieces of cryptography. One agrees the secret, the other establishes the identity. They can be changed separately. And that is exactly what is happening.
The half I already had on and did not know
I went to my own sandbox to see which of the two pieces I had. I looked at a handshake from the authorisation server and found a line I had not written:
Negotiated TLS1.3 group: X25519MLKEM768
That name is two algorithms glued together. X25519 is the elliptic curve we have always used. ML-KEM is the post-quantum key-exchange mechanism NIST standardised. They travel together on purpose: if one of them turns out to be weak, the other still holds the secret. I did not configure it, I did not ask for it, it does not appear in any file of mine. It arrived in an update and the sandbox had been talking like that for weeks.
That half is the urgent one, and the analogy explains why. Encrypting is putting the letter in a sealed envelope. Somebody can steal the envelope today and keep it in a warehouse until the day they have a way to open it. It is called harvest now, decrypt later, and that is why the key exchange is racing a clock even though the machine that breaks it does not exist yet.
The other half, the one nobody switched on
Identity works differently. Signing is the guard checking your ID at the door: it only has to hold for the second the check lasts. Nobody can come back in 2035 to forge the ID I showed today.
That is why the ecosystem fixed the envelope first. It was not an oversight: it was the right order. And that is why the other half is still signed with the same old algorithms: the certificate each participant presents on every call. You have to go looking for it.
Better to say here what the first comment will say: ML-DSA is not news. NIST standardised it in 2024 as FIPS 204, and there are cloud services signing with it since 2025. But signing data and presenting identity in TLS are two different problems: a cloud service signs a document you store and anybody verifies whenever they like, while the certificate has to be shown at the door, every time, and the TLS library does it without your code taking part. What is new in the Rustls changelog is not the algorithm. It is that it was turned on in the handshake and by default.
Why it reached private PKIs first
The second sentence of the changelog is the one that orders everything: these certificates do not work in the public web, only in private hierarchies.
Changing the passport format requires two hundred countries to agree, and takes a decade. Changing the employee badge is decided by the office that issues them, and it can happen on Monday. The web uses passports: hundreds of certificate authorities, public transparency logs, billions of clients nobody controls. A private hierarchy uses badges: one root, one issuer and a closed set of participants who agree by regulation.
Public is a consensus problem. Private is an engineering problem. That is why private goes first.
The Chilean Open Finance System works exactly like that. The Participant Directory issues the certificates for banks and providers against its own root: they are not web certificates, they are badges for a closed set. And mTLS is not decoration, it sits on the critical path. Every call to the PAR, to the token endpoint and to every resource API presents a client certificate.
I have had an SFA sandbox running since August, with those six subdomains up, and that is where I ran the tests. As always: it is an unofficial, non-production sandbox, and it does not replace the CMF’s.
I built it, and the number I had got wrong
I generated a hierarchy with OpenSSL 3.6.3: server with an ML-DSA-44 certificate, client with ML-DSA-65, mutual authentication required. The result:
Peer signature type: mldsa44
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Verify return code: 0 (ok)
Post-quantum mutual authentication, working: two commands on a laptop.
Taking it to Go, I crashed into this:
x509: ML-DSA private keys with both seed and expanded key are not supported,
use e.g. "openssl pkey -provparam ml-dsa.output_formats=seed-only"
to convert to a seed-only key
OpenSSL generates the private key in a format that includes the seed and the expanded key. Go accepts the seed only. The message carries the fix, which is a one-command conversion:
openssl pkey -in key.pem -provparam ml-dsa.output_formats=seed-only -out key-seed.pem
And here is the number that made me correct myself. Before converting, that key weighs 3,613 bytes. After, 128. The 32-byte seed is enough to derive everything else.
I had claimed the post-quantum private key was nineteen times bigger than an elliptic curve one. That is false in the format that matters: 128 bytes against the 138 of EC P-256, and considerably less than the 1,218 of an RSA-2048. The post-quantum key beats RSA.
What it weighs
I built a three-level chain (root, intermediate and leaf) like the one a Participant Directory would issue, and measured what actually travels in the handshake, which is leaf plus intermediate.
In mTLS two chains travel, one per end, on every single call.
- leaf
- 379 B
- intermediate
- 386 B
- chain on the wire
- 765 B
- private key
- 241 B
The floor: 765 bytes for the whole chain. Everything else is measured against this.
- leaf
- 776 B
- intermediate
- 785 B
- chain on the wire
- 1,561 B
- private key
- 1,218 B
Twice the elliptic curve and nobody ever complained. This is what "heavy" used to mean.
- leaf
- 3,982 B
- intermediate
- 3,997 B
- chain on the wire
- 7,979 B
- private key
- 128 B
Ten times EC, and still the smallest post-quantum option. The private key, though, is 128 bytes: smaller than RSA.
- leaf
- 5,511 B
- intermediate
- 5,526 B
- chain on the wire
- 11,037 B
- private key
- 128 B
The level I used on the server. Fourteen times EC, and it fits with room to spare.
- leaf
- 7,469 B
- intermediate
- 7,484 B
- chain on the wire
- 14,953 B
- private key
- 128 B
Grazes the 16,384-byte limit of a TLS record: the certificate message fragments and costs extra round trips, exactly when the connection is still slow.
From 765 bytes to 14,953 is twenty times. And the number is not what it looks like: in mTLS two chains travel, one per end, and that happens on every call to the PAR, to the token endpoint and to every resource API. The ML-DSA-87 chain also sits a hair below the 16,384-byte limit of a TLS record, so the certificate message fragments and costs extra round trips exactly when the connection is still slow.
The cost is in the bytes, not in the computation.
Three TLS libraries on the same machine, three answers
Before it worked, I lost a while on this, and it is the part that will help most whoever tries it. Two OpenSSL live on the same Mac. The command-line one is 3.6.3 and does full post-quantum mTLS. The one Python brings is 3.0.16, and with the same key it returns:
ssl.SSLError: [SSL: EE_KEY_TOO_SMALL] ee key too small
The key is not small. The algorithm is unknown to that version, and the error points at the wrong side. The same script works or fails depending on which binary runs it, and nothing warns you. The same happens to macOS curl: it uses LibreSSL and answers unsupported algorithm.
And my servers?
With the OpenSSL part solved, I went to my real edge. Caddy 2.11.3, which back in the day was compiled with Go 1.26.3:
Error: loading certificates: tls: failed to parse private key
The EC control in the same command gave a valid configuration. And looking at the handshake, the underlying thing showed up. On every connection the server publishes the list of signature algorithms it accepts, and a client can only present a certificate signed with one of them. The list Caddy requested was RSA-PSS+SHA256, ECDSA+SHA256, ed25519 and variants. ML-DSA did not appear. So a client with a legitimate ML-DSA certificate was rejected with alert 116, the certificate_required alert: the same one anybody gets for presenting no certificate at all. The server does not tell apart bringing no credential from bringing one it cannot read.
Keycloak could not either. I tried five variants of the algorithm on the official image’s JVM and all five failed: it runs on JDK 21, and ML-DSA reached Java in 24. Here a precision I had to correct myself on: the product is not the problem, the image is. Keycloak supports newer JDKs; the official image stayed on 21 for FIPS compatibility.
And there is a finer nuance in Java. Having the algorithm in the library is not having it at the door: ML-DSA has been in java.security since JDK 24, but the handshake is not done by the library, it is done by the TLS layer, and it has not landed there yet.
The wall was the compiler
Go 1.27 shipped in August 2026 with ML-DSA all the way to crypto/tls, which is what matters: the signature schemes reach the handshake instead of stopping at the library. My Caddy was from an earlier toolchain. I recompiled the same Caddy 2.11.3 with Go 1.27 and repeated the test:
tls: failed to parse private key. ML-DSA does not appear among the requested signatures. Client with an ML-DSA certificate: alert 116.
It loads the certificate. id-ml-dsa-44, 65 and 87 lead the requested signatures, ahead of RSA and ECDSA. Client with an ML-DSA certificate: authenticated.
Requested Signature Algorithms: id-ml-dsa-44:id-ml-dsa-65:id-ml-dsa-87:RSA-PSS+SHA256:ECDSA+SHA256:…
Peer signature type: mldsa44
Negotiated TLS1.3 group: X25519MLKEM768
AUTENTICADO-PQ
The three lines together are the answer to the question I opened with: key exchange, server signature and client identity, both pieces of the handshake post-quantum at the same time, on a server I already had installed.
The wall was not cryptographic. It was a compiler version.
The map: who can do it today
- generates the keyyes
- uses it in the handshakeyes
- on by defaultyes
changelog
ML-DSA certificates are not supported in the public web PKI, but they can be used with private certificate hierarchies
The only one that ships it on, and its own changelog draws the line: it works inside a private hierarchy, not on the public web. That sentence is what set off this whole article.
- generates the keyyes
- uses it in the handshakeyes
- on by defaultno
measured output
Peer signature type: mldsa44 · Verify return code: 0 (ok)
Full mTLS: server on ML-DSA-65, client on ML-DSA-44, verification clean. This is the tool you can build the whole thing with today.
- generates the keyyes
- uses it in the handshakeyes
- on by defaultno
measured output
Requested Signature Algorithms: id-ml-dsa-44:id-ml-dsa-65:id-ml-dsa-87:RSA-PSS+SHA256:…
Same binary, same config, same certificate: the only thing that changed was the compiler. And ML-DSA does not just appear, it leads the requested signature list.
- generates the keyno
- uses it in the handshakeno
- on by defaultno
error returned
Error: loading certificates: tls: failed to parse private key
It does not even load the key. Worse: a client with a legitimate ML-DSA certificate is rejected with alert 116, exactly like one with no certificate at all. The error does not tell them apart.
- generates the keyno
- uses it in the handshakeno
- on by defaultno
error returned
ssl.SSLError: [SSL: EE_KEY_TOO_SMALL] ee key too small
The error lies: the key is not small, the algorithm is unknown to that version. Two OpenSSL live on the same machine and the script works or fails depending on which one runs it, with no warning.
- generates the keyyes
- uses it in the handshakeno
- on by defaultno
state
JEP 497 · ML-DSA en la biblioteca, no en la capa TLS
Having the algorithm in the library is not having it at the door. It signs and verifies, but the handshake is done by the TLS layer, and it has not landed there. No public date.
- generates the keyno
- uses it in the handshakeno
- on by defaultno
error returned
las cinco variantes del algoritmo fallaron en la JVM de la imagen
The product is not the problem, the image is: it stayed on JDK 21 for FIPS compatibility, and ML-DSA reached Java in 24. The one half the world’s banking runs on is the only one with no date for the handshake.
Three of seven can and one ships it on. The one left with no public date for the handshake is Java, which is exactly where half the world’s banking runs.
The bet I lost
Seeing those sizes I made a prediction, and I wrote it down before measuring it. In my architecture, Caddy validates the client certificate at the edge and passes the full certificate to the service behind it inside an HTTP header. An ML-DSA-87 chain in PEM is 20,360 bytes in a single header, and the usual limits sit around 4 or 8 kilobytes. My bet was that post-quantum mTLS would break the edge before the cryptography: the handshake would work perfectly and the certificate would get lost on the way to the backend, with an error that said nothing.
Caddy passed the 20,044 bytes without blinking and the service behind processed them fine. I tested the four chain sizes, from 1,128 to 20,044 bytes: HTTP 200 on all four.
I went looking for where the real ceiling was, with synthetic headers. Caddy holds 32,768 bytes and fails at 65,536. The heaviest chain a Participant Directory can issue uses less than half that margin.
The diagnostic trap
There is no way to skip authentication, and I checked on purpose against the old Caddy, the Go 1.26 one, because it looked possible. That Caddy announced a post-quantum certificate authority and still rejected the certificates that same authority signed. It looks like a defect and it is not: RFC 8446 defines announcing the authority and requesting a signature algorithm as two separate, independent extensions, and meeting one does not oblige you to meet the other. It is the confusion that cost me the most time, and the reason the rejection looks arbitrary.
With require_and_verify, both with no certificate and with that ML-DSA certificate signed by the announced authority, the server answers alert 116 and closes. It fails on the safe side.
But there is a trap that costs time. In TLS 1.3 the handshake completes before the client certificate is validated, so s_client shows New, TLSv1.3 and Verify return code: 0 (ok) in both cases. It looks like it passed. The rejection arrives at the first byte of data. If you diagnose by looking only at the handshake summary, you will conclude your mTLS works when it is authenticating nobody.
What I would do today
Post-quantum cryptography in a system that comes into force in 2027
✓ Aquí puedes
- Check that post-quantum key exchange is active: it is free, it already arrived, and it protects against record today, decrypt tomorrow
- Search your code for where it is written that a certificate is two kilobytes: header limits, buffers, database fields
- Pin the toolchain version as a requirement and not a detail: the edge is not defined by the server, it is defined by which compiler built it
- Ask your PKI provider today for their ML-DSA roadmap
✗ Aquí nunca
- Issue ML-DSA certificates in production now: there is nobody to speak them with and whoever adopts them first is left alone
- Assume that because the algorithm is in the system, it is available in the handshake
- Diagnose mTLS by looking only at the handshake summary
The only task I would do this week is the second one, and it fits in an afternoon. Today it is a line of configuration. In 2029 it is a maintenance window with the system down.
My sandbox had been negotiating post-quantum cryptography for weeks without me knowing. What was left out was not left out because it was hard: when I went looking for it, it worked in an afternoon. It was because nobody had gone to look.
Sources
The standards.
- FIPS 204: Module-Lattice-Based Digital Signature Standard. ML-DSA, the signature algorithm behind this whole article. The three levels 44, 65 and 87 are in table 1.
- FIPS 203: Module-Lattice-Based Key-Encapsulation Mechanism Standard. ML-KEM, the half of the handshake that already shipped on.
- RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3. Section 4.2.3 defines
signature_algorithmsand 4.2.4 definescertificate_authorities: they are independent extensions, which is why announcing a post-quantum CA is not the same as requesting post-quantum signatures. - NIST IR 8547: Transition to Post-Quantum Cryptography Standards. The 2030 and 2035 deadlines.
The drafts that define how it enters TLS.
- draft-ietf-tls-mldsa-05: Use of ML-DSA in TLS 1.3. The
mldsa44,mldsa65andmldsa87identifiers that show up in the captures, with their codes0x0904,0x0905and0x0906. Still a working group draft, not an RFC. - draft-kwiatkowski-tls-ecdhe-mlkem-03: Post-quantum hybrid ECDHE-MLKEM Key Agreement for TLSv1.3. The
X25519MLKEM768group my sandbox negotiated without me asking.
The exact versions I tested.
- Rustls 0.23.44, released on 7 September 2026. The full sentence about private hierarchies is in the first line of its notes; the change is PR #3249, Enable ML-DSA by default.
- Go 1.27 release notes.
crypto/mldsa, and ML-DSA incrypto/x509andcrypto/tls. - OpenSSL 3.5 release notes. ML-DSA and ML-KEM land here, which explains why the 3.0.16 Python ships answers
EE_KEY_TOO_SMALL. - JEP 497: Quantum-Resistant Module-Lattice-Based Digital Signature Algorithm. ML-DSA in JDK 24, in the library.
- JEP 527: Post-Quantum Hybrid Key Exchange for TLS 1.3. Key exchange in Java’s TLS layer, not signatures: the distance between the two JEPs is exactly the gap I found.
The Chilean timetable.
Comments
No comments yet. The first one is yours.