Portada del artículo: I built a fully post-quantum handshake, then went to see which of my servers could do it
Post-quantum cryptographyTLSmTLSML-DSAOpen FinanceSecurity

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.

Efrain Garay 7 September 2026

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.

In 55 seconds: the half of the handshake that is already post-quantum with nobody configuring it, why identity was left for later, and the wall that turned out to be a compiler version.Watch it in the reel viewer →

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.

One handshake, measured step by stepEvery line is what openssl s_client printed against the server I set up on 7 September 2026.
clienttwo lists, one each

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

bothpost-quantum

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

serverpost-quantum

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

clientpost-quantum

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

bothclassic

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

The whole handshake, in one imageThe four participants and the nine messages from the 7 September 2026 capture.
ClientHello · grupos y firmasX25519MLKEM768 negociadocadena ML-DSA-65 · 11.037 Bid-ml-dsa-44:65:87 primerocadena ML-DSA-44 · 7.979 Bvalida contra la raizVerify return code: 0cabecera 20.044 BAUTENTICADO-PQAcuerdo de claveIdentidadesDatosCliente mTLS · OpenSSL 3.6.3 · Sequence participantCliente mTLSOpenSSL 3.6.3Caddy 2.11.3 · Go 1.27 · Sequence participantCaddy 2.11.3Go 1.27Directorio · raiz privada · Sequence participantDirectorioraiz privadaServicio · detras del borde · Sequence participantServiciodetras del bordeLegendrequestreturnsecuritydefault message

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.

What a post-quantum identity weighsThree-level chains I issued and measured on 7 September 2026. What travels is leaf + intermediate.
TLS record limit · 16,384 B

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:

Go 1.26.3

tls: failed to parse private key. ML-DSA does not appear among the requested signatures. Client with an ML-DSA certificate: alert 116.

Go 1.27

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.

The same Caddy 2.11.3, the same configuration, the same certificate. The only thing that changed was the compiler.
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

Who can do it todaySeven pieces of software, the same certificate, tested between 5 and 7 September 2026.
  • 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 SFA comes into force on 3 July 2027. NIST deprecates today's algorithms after 2030 and disallows them after 2035.

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.

The drafts that define how it enters TLS.

The exact versions I tested.

The Chilean timetable.

Comments

No comments yet. The first one is yours.

Reviewed before publishing. The email is not stored and never appears anywhere.