Portada del artículo: Armé un handshake completamente post-cuántico, y después fui a ver cuál de mis servidores podía hacerlo
Criptografía post-cuánticaTLSmTLSML-DSAFinanzas AbiertasSeguridad

Armé un handshake completamente post-cuántico, y después fui a ver cuál de mis servidores podía hacerlo

Rustls activó los certificados ML-DSA por defecto, solo para jerarquías privadas. Armé un mTLS donde el intercambio de claves, la firma del servidor y la identidad del cliente son post-cuánticos. Medí cuánto pesa la cadena, qué versión de cada servidor lo soporta, y corregí un número que había dado mal: la clave no es 19 veces más grande, son 128 bytes.

Efrain Garay 7 de septiembre de 2026

Rustls, la librería de TLS escrita en Rust, publicó la versión 0.23.44 el 7 de septiembre de 2026, y activa por defecto los certificados ML-DSA. La nota del changelog tiene una segunda frase que es más interesante que la primera: esos certificados no sirven en la web pública, pero sí en jerarquías de certificados privadas. Es decir, la criptografía post-cuántica no está entrando por la puerta principal de internet. Está entrando por la puerta de atrás, la de las PKI que cada organización emite para sí misma. Que es, casualmente, el modelo sobre el que corre la banca abierta chilena.

Me quedé con una pregunta concreta: ¿se puede armar hoy, con herramientas que ya tengo instaladas, una conexión donde nada dependa de criptografía que un computador cuántico sabría romper? No de aquí a cinco años, y no en una demo de un fabricante. Hoy, en un portátil. Resultó que sí se puede, que me costó una tarde, y que el camino pasó por descubrir cuál de mis servidores podía hacerlo y cuál no. También pasó por corregir un número que yo mismo había dado mal. Para contar dónde falla hay que empezar por lo que ocurre cuando dos máquinas se saludan.

En 58 segundos: la mitad del handshake que ya es post-cuántica sin que nadie la configure, por qué la identidad quedó para después, y el muro que resultó ser una versión del compilador.Verlo en el visualizador de reels →

Qué pasa realmente cuando dos máquinas se conectan

Cuando dos máquinas abren una conexión cifrada, antes de que viaje un solo byte útil ocurre una conversación de ida y vuelta que dura milisegundos y que se llama handshake. Ahí se resuelven tres cosas, en orden.

Primero, ponerse de acuerdo en una llave que solo ellas dos conozcan, sin haberla pactado antes y hablando por un canal que cualquiera puede leer. Eso es el intercambio de claves.

Segundo, demostrar quién es cada uno. El servidor presenta un certificado y firma algo en ese momento, ahí mismo, para probar que la llave de ese certificado es suya y no de alguien que lo copió. Cuando la conexión exige mTLS, es decir autenticación mutua, el cliente hace exactamente lo mismo.

Tercero, verificar que nadie alteró nada de lo anterior. Recién entonces empieza el HTTP.

Un handshake, medido paso a pasoCada línea es lo que imprimió openssl s_client contra el servidor que armé el 7 de septiembre de 2026.
clientedos listas, una de cada una

salida medida

Supported groups: X25519MLKEM768:X25519:P-256

El cliente lista los grupos que acepta para acordar la clave y los algoritmos con los que acepta que le firmen. Son dos listas independientes, y ahí empieza la confusión: un servidor puede anunciar una CA post-cuántica y no pedir firmas post-cuánticas, porque el RFC 8446 las trata por separado.

X25519MLKEM768 va primero sin que nadie lo configure

ambospost-cuántico

salida medida

Negotiated TLS1.3 group: X25519MLKEM768

Aquí se acuerda el secreto que cifra la sesión, y ya es post-cuántico por defecto: la curva clásica X25519 combinada con ML-KEM-768. Se activó primero porque el tráfico de hoy se puede grabar y descifrar dentro de diez años; una firma, en cambio, solo tiene que aguantar el instante en que se verifica.

no lo pedí: venía negociado

servidorpost-cuántico

salida medida

Peer signature type: mldsa65

El servidor demuestra quién es firmando con ML-DSA-65. Esta es la mitad que no viene activada en ninguna parte, y la que la web pública no acepta: solo sirve dentro de una jerarquía de certificados privada.

la cadena que viaja pasa de 765 a 11.037 bytes

clientepost-cuántico

salida medida

Peer signature type: mldsa44

En mTLS el cliente también presenta certificado, así que hay dos cadenas post-cuánticas viajando en el mismo handshake. Sin certificado, o con uno que no valida contra la CA, el servidor corta con alerta 116: falla cerrado, no deja pasar a nadie por presentar un algoritmo que el otro extremo no entiende.

sin certificado válido: alert 116, sin excepción

ambosclásico

salida medida

HTTP/1.1 200 OK — AUTENTICADO-PQ

A partir de acá no cambia nada: AES sobre la clave acordada. La criptografía simétrica no necesita reemplazo, solo llaves más largas, y por eso el cambio post-cuántico se concentra entero en el saludo.

el backend recibió la cabecera de 20.044 bytes intacta

El handshake entero, en una imagenLos cuatro participantes y los nueve mensajes de la captura del 7 de septiembre de 2026.
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 bordeLeyendapeticionrespuestacertificadomensaje simple

Lo importante para lo que sigue: son dos piezas de criptografía distintas. Una acuerda el secreto, la otra acredita la identidad. Se pueden cambiar por separado. Y es justo lo que está pasando.

La mitad que ya tenía puesta y no sabía

Fui a mi propio sandbox a ver cuál de las dos piezas tenía. Miré un handshake del servidor de autorización y encontré una línea que no había escrito yo:

Negotiated TLS1.3 group: X25519MLKEM768

Ese nombre son dos algoritmos pegados. X25519 es la curva elíptica de siempre. ML-KEM es el mecanismo de intercambio de claves post-cuántico que NIST estandarizó. Van juntos a propósito: si uno de los dos resultara débil, el otro sostiene el secreto. No lo configuré, no lo pedí, no aparece en ningún archivo mío. Llegó en una actualización y el sandbox llevaba semanas hablando así.

Esa mitad es la urgente, y la analogía explica por qué. Cifrar es meter la carta en un sobre lacrado. Alguien puede robarse el sobre hoy y guardarlo en una bodega hasta el día en que tenga cómo abrirlo. Se le llama cosechar ahora, descifrar después, y por eso el intercambio de claves corre contra el reloj aunque la máquina que lo rompa no exista todavía.

La otra mitad, la que nadie activó

La identidad funciona distinto. Firmar es el guardia que te mira la cédula en la puerta: solo tiene que aguantar el segundo que dura la revisión. Nadie puede volver en 2035 a falsificar la cédula que mostré hoy.

Por eso el ecosistema arregló primero el sobre. No fue un olvido: fue el orden correcto. Y por eso la otra mitad sigue firmada con los algoritmos de siempre: el certificado que cada participante presenta en cada llamada. Hay que ir a buscarla.

Conviene decir aquí lo que dirá el primer comentario: ML-DSA no es una novedad. NIST lo estandarizó en 2024 como FIPS 204, y hay servicios en la nube que firman con él desde 2025. Pero firmar datos y presentar identidad en TLS son dos problemas distintos: un servicio en la nube te firma un papel que guardas y verifica quien quieras, cuando quiera, mientras que el certificado hay que mostrarlo en la puerta, cada vez, y lo hace la librería de TLS sin que tu código participe. Lo nuevo del changelog de Rustls no es el algoritmo. Es que se activó en el handshake y por defecto.

Por qué llegó primero a las PKI privadas

La segunda frase del changelog es la que ordena todo: estos certificados no sirven en la web pública, solo en jerarquías privadas.

Cambiar el formato del pasaporte requiere que se pongan de acuerdo doscientos países, y toma una década. Cambiar la credencial de empleado lo decide la oficina que las emite, y puede ser el lunes. La web usa pasaportes: cientos de autoridades certificadoras, registros públicos de transparencia, miles de millones de clientes que nadie controla. Una jerarquía privada usa credenciales: una raíz, un emisor y un conjunto cerrado de participantes que se ponen de acuerdo por norma.

Lo público es un problema de consenso. Lo privado es un problema de ingeniería. Por eso lo privado va primero.

El Sistema de Finanzas Abiertas chileno funciona exactamente así. El Directorio de Participantes emite los certificados de bancos y proveedores contra su propia raíz: no son certificados web, son credenciales de un conjunto cerrado. Y mTLS no es un adorno, está en el camino crítico. Cada llamada al PAR, al endpoint de token y a cada API de recursos presenta certificado de cliente.

Tengo un sandbox del SFA montado desde agosto, con esos seis subdominios corriendo, y ahí hice las pruebas. Como siempre: es un sandbox no oficial y no productivo, y no sustituye al de la CMF.

Lo armé, y el número que había dado mal

Generé una jerarquía con OpenSSL 3.6.3: servidor con certificado ML-DSA-44, cliente con ML-DSA-65, autenticación mutua obligatoria. El resultado:

Peer signature type: mldsa44
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Verify return code: 0 (ok)

Autenticación mutua post-cuántica, funcionando: dos comandos en un portátil.

Al llevarlo a Go me estrellé con esto:

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 genera la clave privada en un formato que incluye la semilla y la clave expandida. Go acepta solo la semilla. El mensaje trae la solución, que es una conversión de un comando:

openssl pkey -in clave.pem -provparam ml-dsa.output_formats=seed-only -out clave-semilla.pem

Y aquí está el número que me hizo corregirme. Antes de convertirla, esa clave pesa 3.613 bytes. Después, 128. La semilla de 32 bytes basta para derivar todo lo demás.

Yo había afirmado que la clave privada post-cuántica era diecinueve veces más grande que una de curva elíptica. Es falso en el formato que importa: 128 bytes contra los 138 de EC P-256, y bastante menos que los 1.218 de una RSA-2048. La clave post-cuántica le gana a RSA.

Cuánto pesa

Armé una cadena de tres niveles (raíz, intermedia y hoja) como la que emitiría un Directorio de Participantes, y medí lo que viaja de verdad en el handshake, que es hoja más intermedia.

Lo que pesa una identidad post-cuánticaCadenas de tres niveles que emití y medí el 7 de septiembre de 2026. Lo que viaja es hoja + intermedia.
límite de un registro TLS · 16.384 B

En mTLS viajan dos cadenas, una por extremo, en cada llamada.

hoja
379 B
intermedia
386 B
cadena en el cable
765 B
clave privada
241 B

El piso: 765 bytes toda la cadena. Todo lo demás se mide contra esto.

hoja
776 B
intermedia
785 B
cadena en el cable
1.561 B
clave privada
1.218 B

El doble que la curva elíptica, y nunca nadie se quejó. Esto es lo que significaba "pesado" hasta ahora.

hoja
3.982 B
intermedia
3.997 B
cadena en el cable
7.979 B
clave privada
128 B

Diez veces EC, y aun así es la opción post-cuántica más liviana. La clave privada, en cambio, son 128 bytes: menos que RSA.

hoja
5.511 B
intermedia
5.526 B
cadena en el cable
11.037 B
clave privada
128 B

El nivel que usé en el servidor. Catorce veces EC, y entra con margen.

hoja
7.469 B
intermedia
7.484 B
cadena en el cable
14.953 B
clave privada
128 B

Roza el límite de 16.384 bytes de un registro TLS: el mensaje de certificado se fragmenta y cuesta viajes extra, justo cuando la conexión todavía va lenta.

De 765 bytes a 14.953 son veinte veces. Y el número no es el que parece: en mTLS viajan dos cadenas, una por cada extremo, y eso ocurre en cada llamada al PAR, al token y a cada API de recursos. La cadena de ML-DSA-87 además queda a un pelo del límite de 16.384 bytes de un registro TLS, así que el mensaje de certificado se fragmenta y cuesta viajes extra justo cuando la conexión todavía va lenta.

El costo está en los bytes, no en el cálculo.

Tres librerías TLS en la misma máquina, tres respuestas

Antes de que funcionara, perdí un rato con esto, y es lo que más le va a servir a quien lo intente. En el mismo Mac conviven dos OpenSSL. El de la línea de comandos es 3.6.3 y hace mTLS post-cuántico completo. El que trae Python es 3.0.16, y con la misma clave devuelve:

ssl.SSLError: [SSL: EE_KEY_TOO_SMALL] ee key too small

La clave no es pequeña. El algoritmo es desconocido para esa versión, y el error apunta al lado equivocado. El mismo script funciona o falla según qué binario lo ejecute, y nada avisa. Al curl de macOS le pasa lo mismo: usa LibreSSL y responde unsupported algorithm.

¿Y mis servidores?

Con la parte de OpenSSL resuelta, fui a mi borde real. Caddy 2.11.3, que en su momento se compiló con Go 1.26.3:

Error: loading certificates: tls: failed to parse private key

El control con EC en el mismo comando daba configuración válida. Y al mirar el handshake apareció lo de fondo. En cada conexión el servidor publica la lista de algoritmos de firma que acepta, y el cliente solo puede presentar un certificado firmado con alguno de ellos. La lista que pedía Caddy era RSA-PSS+SHA256, ECDSA+SHA256, ed25519 y variantes. ML-DSA no aparecía. Así que un cliente con certificado ML-DSA legítimo era rechazado con alert 116, que es la alerta de certificate_required: la misma que recibe quien no presenta ningún certificado. El servidor no distingue entre no traer credencial y traer una que no sabe leer.

Keycloak tampoco. Probé cinco variantes del algoritmo en la JVM de la imagen oficial y las cinco fallaron: corre sobre JDK 21, y ML-DSA llegó a Java en el 24. Aquí conviene una precisión que yo mismo tuve que corregir: el producto no es el problema, lo es la imagen. Keycloak soporta JDK más nuevos; la imagen oficial se quedó en el 21 por compatibilidad con FIPS.

Y hay un matiz más fino en Java. Tener el algoritmo en la biblioteca no es tenerlo en la puerta: ML-DSA está en java.security desde el JDK 24, pero el handshake no lo hace la biblioteca, lo hace la capa de TLS, y ahí todavía no llegó.

El muro era el compilador

Go 1.27 salió en agosto de 2026 con ML-DSA hasta crypto/tls, que es lo que importa: los esquemas de firma llegan al handshake, no se quedan en la librería. Mi Caddy era de un toolchain anterior. Recompilé el mismo Caddy 2.11.3 con Go 1.27 y repetí la prueba:

Go 1.26.3

tls: failed to parse private key. ML-DSA no aparece entre las firmas pedidas. Cliente con certificado ML-DSA: alert 116.

Go 1.27

Carga el certificado. id-ml-dsa-44, 65 y 87 encabezan las firmas pedidas, delante de RSA y ECDSA. Cliente con certificado ML-DSA: autenticado.

El mismo Caddy 2.11.3, la misma configuración, el mismo certificado. Lo único que cambió fue el compilador.
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

Las tres líneas juntas son la respuesta a la pregunta del principio: intercambio de claves, firma del servidor e identidad del cliente, las dos piezas del handshake post-cuánticas al mismo tiempo, sobre un servidor que ya tenía instalado.

El muro no era criptográfico. Era una versión del compilador.

El mapa: quién puede hoy

Quién puede hoySiete piezas de software, el mismo certificado, probadas entre el 5 y el 7 de septiembre de 2026.
  • genera la clave
  • la usa en el handshake
  • viene activado

changelog

ML-DSA certificates are not supported in the public web PKI, but they can be used with private certificate hierarchies

El único que lo trae encendido de fábrica, y su propio changelog pone el límite: sirve dentro de una jerarquía privada, no en la web pública. Esa frase es la que dispara todo este artículo.

  • genera la clave
  • la usa en el handshake
  • viene activadono

salida medida

Peer signature type: mldsa44 · Verify return code: 0 (ok)

mTLS completo: servidor con ML-DSA-65, cliente con ML-DSA-44, verificación correcta. Es la herramienta con la que se puede montar todo hoy mismo.

  • genera la clave
  • la usa en el handshake
  • viene activadono

salida medida

Requested Signature Algorithms: id-ml-dsa-44:id-ml-dsa-65:id-ml-dsa-87:RSA-PSS+SHA256:…

El mismo binario, la misma configuración, el mismo certificado: lo único que cambió fue el compilador. Y ML-DSA no solo aparece, encabeza la lista de firmas pedidas.

  • genera la claveno
  • la usa en el handshakeno
  • viene activadono

error devuelto

Error: loading certificates: tls: failed to parse private key

Ni siquiera carga la clave. Peor: un cliente con certificado ML-DSA legítimo se rechaza con alert 116, exactamente igual que uno sin certificado. El error no distingue.

  • genera la claveno
  • la usa en el handshakeno
  • viene activadono

error devuelto

ssl.SSLError: [SSL: EE_KEY_TOO_SMALL] ee key too small

El error miente: la clave no es pequeña, el algoritmo es desconocido para esa versión. En la misma máquina conviven dos OpenSSL y el script funciona o falla según cuál lo ejecute, sin que nada avise.

  • genera la clave
  • la usa en el handshakeno
  • viene activadono

estado

JEP 497 · ML-DSA en la biblioteca, no en la capa TLS

Tener el algoritmo en la biblioteca no es tenerlo en la puerta. Firma y verifica, pero el handshake lo hace la capa de TLS, y ahí todavía no llegó. Sin fecha pública.

  • genera la claveno
  • la usa en el handshakeno
  • viene activadono

error devuelto

las cinco variantes del algoritmo fallaron en la JVM de la imagen

El producto no es el problema, lo es la imagen: se quedó en JDK 21 por compatibilidad con FIPS, y ML-DSA llegó a Java en el 24. El que usa media banca del mundo es el único sin fecha para el handshake.

Tres de siete pueden y uno viene activado. El que se queda sin fecha pública para el handshake es Java, que es justo donde corre media banca del mundo.

La apuesta que perdí

Al ver esos tamaños hice una predicción, y la escribí antes de medirla. En mi arquitectura, Caddy valida el certificado de cliente en el borde y le pasa al servicio de atrás el certificado completo dentro de una cabecera HTTP. Una cadena ML-DSA-87 en PEM son 20.360 bytes en una sola cabecera, y los límites habituales rondan los 4 u 8 kilobytes. Mi apuesta era que el mTLS post-cuántico rompía el borde antes que la criptografía: que el handshake funcionaría perfecto y el certificado se perdería camino al backend, con un error que no diría nada.

Caddy pasó los 20.044 bytes sin inmutarse y el servicio de atrás los procesó igual. Probé los cuatro tamaños de cadena, de 1.128 a 20.044 bytes: HTTP 200 en los cuatro.

Fui a buscar dónde estaba el techo de verdad, con cabeceras sintéticas. Caddy aguanta 32.768 bytes y falla en 65.536. La cadena más pesada que puede emitir un Directorio de Participantes usa menos de la mitad de ese margen.

La trampa de diagnóstico

No hay forma de saltarse la autenticación, y lo comprobé a propósito contra el Caddy viejo, el de Go 1.26, porque parecía posible. Ese Caddy anunciaba una autoridad certificadora post-cuántica y aun así rechazaba los certificados que ella misma firmaba. Parece un defecto y no lo es: el RFC 8446 define anunciar la autoridad y pedir un algoritmo de firma como dos extensiones distintas e independientes, y cumplir una no obliga a cumplir la otra. Es la confusión que más tiempo me costó, y la razón de que el rechazo parezca arbitrario.

Con require_and_verify, tanto sin certificado como con ese certificado ML-DSA firmado por la autoridad anunciada, el servidor responde alert 116 y cierra. Falla del lado seguro.

Pero hay una trampa que hace perder tiempo. En TLS 1.3 el handshake se completa antes de validar el certificado de cliente, así que s_client muestra New, TLSv1.3 y Verify return code: 0 (ok) en los dos casos. Parece que pasó. El rechazo llega al primer byte de datos. Si diagnosticas mirando solo el resumen del handshake, vas a concluir que tu mTLS funciona cuando no está autenticando a nadie.

Qué haría hoy

Criptografía post-cuántica en un sistema que entra en vigencia en 2027

Aquí puedes

  • Verificar que el intercambio de claves post-cuántico esté activo: es gratis, ya llegó, y protege contra grabar hoy y descifrar mañana
  • Buscar en el código dónde está escrito que un certificado mide dos kilobytes: límites de cabecera, buffers, campos de base de datos
  • Fijar la versión del toolchain como requisito y no como detalle: el borde no lo define el servidor, lo define con qué compilador se armó
  • Preguntarle hoy al proveedor de PKI su hoja de ruta para ML-DSA

Aquí nunca

  • Emitir certificados ML-DSA en producción ahora: no hay con quién hablarlos y el primero que los adopte se queda solo
  • Suponer que porque el algoritmo está en el sistema, está disponible en el handshake
  • Diagnosticar mTLS mirando solo el resumen del handshake
El SFA entra en vigencia el 3 de julio de 2027. NIST deprecia los algoritmos actuales después de 2030 y los prohíbe después de 2035.

La única tarea que yo haría esta semana es la segunda, y cabe en una tarde. Hoy es una línea de configuración. En 2029 es una ventana de mantención con el sistema abajo.

Mi sandbox llevaba semanas negociando criptografía post-cuántica sin que yo lo supiera. Lo que quedó afuera no fue por difícil: cuando fui a buscarlo, funcionó en una tarde. Fue porque nadie había ido a mirar.

Fuentes

Las normas.

Los borradores que definen cómo entra en TLS.

Las versiones exactas que probé.

El calendario chileno.

Comentarios

Todavía no hay comentarios. El primero es tuyo.

Se revisa antes de publicarse. El correo no se guarda ni aparece en ninguna parte.