
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.
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.
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.
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
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
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
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
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
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.
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:
tls: failed to parse private key. ML-DSA no aparece entre las firmas pedidas. Cliente con certificado ML-DSA: alert 116.
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.
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
- genera la clavesí
- la usa en el handshakesí
- viene activadosí
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 clavesí
- la usa en el handshakesí
- 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 clavesí
- la usa en el handshakesí
- 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 clavesí
- 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
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.
- FIPS 204: Module-Lattice-Based Digital Signature Standard. ML-DSA, el algoritmo de firma de todo este artículo. Los tres niveles 44, 65 y 87 están en la tabla 1.
- FIPS 203: Module-Lattice-Based Key-Encapsulation Mechanism Standard. ML-KEM, la mitad del handshake que ya venía activada.
- RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3. La sección 4.2.3 define
signature_algorithmsy la 4.2.4,certificate_authorities: son extensiones independientes, y por eso anunciar una CA post-cuántica no equivale a pedir firmas post-cuánticas. - NIST IR 8547: Transition to Post-Quantum Cryptography Standards. Los plazos de 2030 y 2035.
Los borradores que definen cómo entra en TLS.
- draft-ietf-tls-mldsa-05: Use of ML-DSA in TLS 1.3. Los identificadores
mldsa44,mldsa65ymldsa87que aparecen en las capturas, con sus códigos0x0904,0x0905y0x0906. Todavía es borrador del grupo de trabajo, no RFC. - draft-kwiatkowski-tls-ecdhe-mlkem-03: Post-quantum hybrid ECDHE-MLKEM Key Agreement for TLSv1.3. El grupo
X25519MLKEM768que negoció mi sandbox sin que yo lo pidiera.
Las versiones exactas que probé.
- Rustls 0.23.44, publicada el 7 de septiembre de 2026. La frase completa sobre jerarquías privadas está en la primera línea de sus notas; el cambio es el PR #3249, Enable ML-DSA by default.
- Notas de la versión Go 1.27.
crypto/mldsa, y ML-DSA encrypto/x509ycrypto/tls. - Notas de la versión OpenSSL 3.5. ML-DSA y ML-KEM llegan aquí, lo que explica por qué el 3.0.16 que trae Python responde
EE_KEY_TOO_SMALL. - JEP 497: Quantum-Resistant Module-Lattice-Based Digital Signature Algorithm. ML-DSA en el JDK 24, en la biblioteca.
- JEP 527: Post-Quantum Hybrid Key Exchange for TLS 1.3. Intercambio de claves en la capa TLS de Java, no firmas: la distancia entre los dos JEP es exactamente el hueco que encontré.
El calendario chileno.
Comentarios
Todavía no hay comentarios. El primero es tuyo.