Respuesta breve. ML-DSA es el esquema de firma basado en retículos estandarizado en FIPS 204 (agosto de 2024), antes llamado CRYSTALS-Dilithium. Tiene tres conjuntos de parámetros: ML-DSA-44, ML-DSA-65 y ML-DSA-87. Sus firmas ocupan 2420, 3309 y 4627 bytes, frente a los 64 de Ed25519 o ECDSA P-256. Por defecto, la firma se genera en modo hedged (aleatorizado); un modo determinista solo cambia lo que produce el firmante. Una cadena de contexto opcional de hasta 255 bytes y una variante pre-hash independiente (HashML-DSA) cambian lo que debe hacer el verificador, así que ambas partes tienen que ponerse de acuerdo en ellas. OpenSSL 3.5, Go 1.27, Java 24+, .NET 10, BouncyCastle y @noble/post-quantum lo implementan. Donde más discrepan es en las claves privadas. Guarda la semilla de 32 bytes.
Por qué ML-DSA y por qué ahora
Cualquiera que tenga una computadora cuántica lo bastante grande puede falsificar firmas ECDSA, EdDSA y RSA. A diferencia del tráfico cifrado, una firma no se puede cosechar ahora y romper después de un modo que cause daños retroactivos. Una firma falsificada importa en el momento en que alguien confía en ella. Por eso la migración urgente era la del intercambio de claves, y por eso las firmas tienen más tiempo. Pero ese «más tiempo» tiene límites:
- Las raíces de confianza de larga duración, como las claves de firma de firmware, las CA raíz y las claves de firma de código y de documentos, tienen que seguir siendo de confianza diez o veinte años después de crearse.
- El borrador del plan de transición de NIST, IR 8547, declararía obsoletas las firmas clásicas de 112 bits después de 2030 y prohibiría todas las firmas vulnerables a la computación cuántica después de 2035. Los plazos y cómo planificar en torno a ellos se tratan en Migración poscuántica en 2026.
ML-DSA es la respuesta de propósito general de NIST. Los esquemas hermanos cubren nichos concretos. FIPS 205 SLH-DSA es un esquema basado en hash con supuestos conservadores, claves pequeñas y firmas muy grandes o lentas. LMS y XMSS, basados en hash y con estado (SP 800-208), se usan para firmware. FN-DSA, basado en Falcon, está previsto como FIPS 206. Hasta octubre de 2026, NIST no había publicado ningún borrador suyo en csrc.nist.gov.
Los tres conjuntos de parámetros
| ML-DSA-44 | ML-DSA-65 | ML-DSA-87 | |
|---|---|---|---|
| Categoría de seguridad NIST | 2 | 3 | 5 |
| Clave pública | 1312 B | 1952 B | 2592 B |
| Firma | 2420 B | 3309 B | 4627 B |
| Clave privada expandida | 2560 B | 4032 B | 4896 B |
| Semilla de la clave privada | 32 B | 32 B | 32 B |
| Clave pública en SPKI (DER) | 1334 B | 1974 B | 2614 B |
| OID (RFC 9881) | 2.16.840.1.101.3.4.3.17 | …3.18 | …3.19 |
Los tamaños en bruto proceden de la tabla 2 de FIPS 204. Los tamaños SPKI son los que escribió OpenSSL 3.5.7 para claves recién generadas. Como comparación, una clave pública Ed25519 ocupa 32 bytes y su firma, 64. Una clave pública RSA-2048 ocupa 294 bytes en SPKI y su firma, 256.
Qué conjunto elegir:
- ML-DSA-65 es la opción predeterminada habitual. JEP 497 lo convierte en el predeterminado de Java cuando no se indica ningún conjunto de parámetros, y la mayoría de las guías consideran la categoría 3 un término medio cómodo.
- ML-DSA-87 es lo que exige CNSA 2.0 de la NSA para los sistemas de seguridad nacional de EE. UU. («parámetros de nivel V» para todos los niveles de clasificación).
- ML-DSA-44 es el más pequeño. Encaja donde cada byte cuenta y los datos tienen una vida corta. La documentación de Go incluso dice: «La mayoría de las aplicaciones deberían usar MLDSA44».
En la práctica, los bytes importan más que la velocidad. En la máquina virtual Linux arm64 que usamos para este artículo, una ejecución de openssl speed de 2 segundos en un solo núcleo (OpenSSL 3.5.7) dio estas tasas:
| Algoritmo | Firmas/s | Verificaciones/s |
|---|---|---|
| ML-DSA-65 | 1488 | 8019 |
| RSA-2048 | 1825 | 74.119 |
| ECDSA P-256 | 65.465 | 24.446 |
ML-DSA-65 firma más o menos a la velocidad de RSA-2048 y verifica un orden de magnitud más despacio que RSA, lo que basta para la mayoría de los servicios. El tamaño es el cambio más importante. Una cadena de certificados, un JWT o una entrada de registro firmada crece en kilobytes. En nuestra prueba, un certificado hoja ML-DSA-65 firmado por una CA ML-DSA-65 ocupó 5514 bytes en DER.
Hedged, determinista, contexto: qué cambia una firma
Tres opciones de FIPS 204 deciden qué bytes salen de Sign y qué hay que indicarle a Verify.
Hedged frente a determinista. Dentro de ML-DSA, el proceso de firma deriva un secreto propio de cada firma a partir de la clave privada, el mensaje y un valor de 32 bytes, rnd. Por defecto (modo «hedged»), rnd es aleatoriedad nueva. En la variante determinista son 32 bytes a cero. El §3.4 de FIPS 204 explica el compromiso. La parte precalculada protege frente a un generador de números aleatorios defectuoso, y la parte nueva dificulta los ataques de canal lateral y de inyección de fallos. Añade que la variante determinista «no debería usarse en plataformas en las que preocupen los ataques de canal lateral». La verificación es idéntica en ambos casos, así que la decisión solo afecta al firmante. OpenSSL muestra la diferencia directamente:
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt | sha256sum
f5c30f1347b231f64f7adf48a4dfeb77d1c259cfc568420ad1f2238335e28616 -
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt | sha256sum
c0eada83bdaa8e278defdd974cbd849fac583f7519a075ee53ec2a3d93adc6ba -
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt -pkeyopt deterministic:1 | sha256sum
c179822bec24d34a38c16257b41624cfb3a9ed40b7d7c79a5b5f2bdf0b87d4bf -
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt -pkeyopt deterministic:1 | sha256sum
c179822bec24d34a38c16257b41624cfb3a9ed40b7d7c79a5b5f2bdf0b87d4bf -
No escribas pruebas que comparen una firma recién generada con una guardada, a menos que firmes en modo determinista. Las firmas hedged cambian en cada llamada, por diseño.
Cadena de contexto. Tanto Sign como Verify reciben una cadena de contexto ctx de 255 bytes como máximo, vacía por defecto. ML-DSA firma 0x00 ‖ len(ctx) ‖ ctx ‖ M, no solo M, así que una firma hecha con un contexto no se verifica con otro. El contexto es una herramienta barata de separación de dominios. Si dos protocolos comparten una clave y cada uno usa su propio contexto, no se puede engañar a ninguno para que acepte las firmas del otro. También rompe la verificación de formas confusas cuando un lado lo establece y el otro no:
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt -pkeyopt context-string:invoice-v1 -out sigctx.bin
$ openssl pkeyutl -verify -rawin -pubin -inkey p65.pem -in msg.txt -sigfile sigctx.bin -pkeyopt context-string:invoice-v1
Signature Verified Successfully
$ openssl pkeyutl -verify -rawin -pubin -inkey p65.pem -in msg.txt -sigfile sigctx.bin
...:error:030000EA:digital envelope routines:EVP_DigestVerify:provider signature failure:../crypto/evp/m_sigver.c:781:ML-DSA-65 digest_verify:
Signature Verification Failure
Los protocolos fijan el contexto, así que rara vez lo eliges tú. ML-DSA en X.509 (RFC 9881) y en JOSE y COSE (RFC 9964, mayo de 2026) usan en ambos casos el contexto vacío. RFC 9964 lo dice así: «El parámetro ctx DEBE ser la cadena vacía».
HashML-DSA. El §5.4 de FIPS 204 define además una variante pre-hash. Primero se calcula el hash del mensaje con SHA-512, SHA-256, SHAKE128 u otra función aprobada, y la firma cubre el resumen junto con el OID de la función hash y un byte de separación de dominios distinto (1 en lugar de 0). Es un esquema de firma independiente, con sus propios OID. Una firma HashML-DSA no es una firma ML-DSA válida del mismo mensaje. FIPS 204 dice que «se prefiere la versión “pura” de ML-DSA», y RFC 9881 no permite HashML-DSA en certificados. RFC 9964 tampoco lo define para JOSE ni COSE. Úsalo solo cuando un protocolo lo exija.
Para mensajes muy grandes hay una tercera vía, el μ externo (external μ). El firmante calcula fuera del módulo de firma el representante del mensaje μ, de 64 bytes, y solo le pasa ese valor. RFC 9881 lo describe. Go 1.27 (crypto.MLDSAMu) y .NET 10 (SignMu) lo ofrecen. Produce firmas ML-DSA puras normales, así que los verificadores no necesitan nada especial.
Formatos de clave privada: guarda la semilla
Un par de claves ML-DSA se genera a partir de una semilla ξ de 32 bytes. La clave privada «expandida», de 2560 a 4896 bytes, se calcula a partir de esa semilla, igual que la clave pública. RFC 9881 define tres codificaciones PKCS#8:
seed: solo los 32 bytes,[0] IMPLICIT OCTET STRING. Es la forma recomendada. Ocupa 54 bytes como PKCS#8.expandedKey: solo la clave expandida.both: una secuencia con la semilla y la clave expandida.
El RFC recomienda que las implementaciones «conserven y exporten la semilla». La expansión no se puede revertir, así que una clave guardada sin su semilla nunca se puede volver a convertir a la forma pequeña. Las bibliotecas eligieron valores predeterminados distintos, y eso rompe las importaciones. OpenSSL 3.5 escribe both por defecto:
0:d=0 hl=4 l=4094 cons: SEQUENCE
4:d=1 hl=2 l= 1 prim: INTEGER :00
7:d=1 hl=2 l= 11 cons: SEQUENCE
9:d=2 hl=2 l= 9 prim: OBJECT :ML-DSA-65
20:d=1 hl=4 l=4074 prim: OCTET STRING [HEX DUMP]:30820FE60420A58A79B197C3...
Go 1.27 rechaza esa forma y te dice qué hacer. Solo acepta la semilla:
both.pem (4098 bytes): 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
seed-only.pem (54 bytes): *mldsa.PrivateKey
expanded-only.pem (4060 bytes): x509: semi-expanded ML-DSA private keys without seed are not supported
En nuestras pruebas, Java 25 (JEP 497), .NET 10 y Go exportaron claves PKCS#8 de 54 bytes solo con la semilla. Elige la forma de semilla en el origen:
openssl genpkey -algorithm ML-DSA-65 -provparam ml-dsa.output_formats=seed-only -out key.pem
openssl pkey -in both.pem -provparam ml-dsa.output_formats=seed-only -out key.pem
La misma decisión se aplica a los HSM y a los productos KMS. Antes de generar una clave de larga duración, comprueba que puedes exportar la semilla o hacer una copia de seguridad de ella. Una clave solo expandida te ata a las implementaciones que aceptan claves expandidas. @noble/post-quantum es un buen ejemplo: su secretKey es la codificación expandida (4032 bytes para ML-DSA-65), y su keygen(seed) recibe la semilla de 32 bytes. Guarda tú mismo la semilla.
Para comprobar qué contiene realmente una clave o un certificado, el inspector de certificados poscuánticos decodifica claves y certificados ML-DSA y SLH-DSA, y el explorador de ASN.1 muestra cuál de las tres formas de clave privada tienes.
Código que funciona hoy
Cada fragmento de código de abajo se ejecutó el 20 de octubre de 2026 en el contenedor indicado en el párrafo que lo precede. Cada uno firma "hello" con ML-DSA-65 y verifica el resultado. Cuando la biblioteca lo permite, se establece una cadena de contexto. Para probar el esquema sin instalar nada, el laboratorio de firmas poscuánticas firma y verifica con ML-DSA, SLH-DSA y Falcon en el navegador.
OpenSSL 3.5 (node:24.22.0-trixie, OpenSSL 3.5.7). ML-DSA necesita -rawin, porque si no pkeyutl esperaría un resumen precalculado:
openssl genpkey -algorithm ML-DSA-65 -provparam ml-dsa.output_formats=seed-only -out key.pem
openssl pkey -in key.pem -pubout -out pub.pem
openssl pkeyutl -sign -rawin -inkey key.pem -in msg.txt -out sig.bin
openssl pkeyutl -verify -rawin -pubin -inkey pub.pem -in msg.txt -sigfile sig.bin
# Signature Verified Successfully
Go 1.27 (golang:1.27.3-trixie). El nuevo paquete crypto/mldsa. crypto/x509 analiza y serializa las claves, y crypto/tls acepta certificados ML-DSA en TLS 1.3. La documentación del paquete advierte que no está disponible con el módulo criptográfico de Go v1.0.0 para FIPS 140-3.
sk, err := mldsa.GenerateKey(mldsa.MLDSA65())
if err != nil {
log.Fatal(err)
}
opts := &mldsa.Options{Context: "invoice-v1"}
sig, err := sk.Sign(nil, []byte("hello"), opts) // 3309 bytes; the io.Reader is ignored
if err != nil {
log.Fatal(err)
}
fmt.Println(mldsa.Verify(sk.PublicKey(), []byte("hello"), sig, opts)) // <nil>
fmt.Println(mldsa.Verify(sk.PublicKey(), []byte("hello"), sig, nil)) // mldsa: invalid signature
sk.SignDeterministic da la variante determinista. sk.Bytes() devuelve la semilla de 32 bytes.
Java, JDK 24 y posteriores (eclipse-temurin:25.0.4.1_1-jdk-noble). JEP 497 añadió ML-DSA a los proveedores estándar. Las cadenas de contexto y HashML-DSA figuran expresamente como no objetivos (non-goals) de ese JEP, así que el JDK solo firma con el contexto vacío.
KeyPair kp = KeyPairGenerator.getInstance("ML-DSA-65").generateKeyPair();
Signature signer = Signature.getInstance("ML-DSA");
signer.initSign(kp.getPrivate());
signer.update("hello".getBytes(StandardCharsets.UTF_8));
byte[] sig = signer.sign(); // 3309 bytes
// kp.getPublic().getEncoded().length == 1974, kp.getPrivate().getEncoded().length == 54
BouncyCastle 1.86 (la misma imagen del JDK). Admite el contexto mediante ContextParameterSpec y admite HashML-DSA con su propio nombre de algoritmo. Nuestra prueba confirma que una firma HashML-DSA no supera la verificación de ML-DSA puro:
Signature signer = Signature.getInstance("ML-DSA", "BC");
signer.initSign(kp.getPrivate());
signer.setParameter(new ContextParameterSpec("invoice-v1".getBytes(StandardCharsets.UTF_8)));
signer.update(msg);
byte[] sig = signer.sign(); // verifies only with the same context
Signature pre = Signature.getInstance("HASH-ML-DSA", "BC"); // SHA-512 pre-hash
pre.initSign(kp.getPrivate());
pre.update(msg);
byte[] preSig = pre.sign(); // "ML-DSA".verify(preSig) == false
.NET 10 (mcr.microsoft.com/dotnet/sdk:10.0.402-alpine3.24). System.Security.Cryptography.MLDsa usa la biblioteca criptográfica de la plataforma, que en Linux significa OpenSSL 3.5 o posterior y en Windows, CNG con compatibilidad con PQC. El mismo programa informó MLDsa.IsSupported: False en la imagen del SDK de Ubuntu 24.04 (10.0.402-noble, OpenSSL 3.0.13) y True en Alpine 3.24 (OpenSSL 3.5.9). Comprueba IsSupported y tu imagen base antes de pasar a producción. En el SDK 10.0.402, la generación de claves, la firma y la verificación compilan sin problemas, pero los métodos de exportación de claves siguen haciendo fallar la compilación con error SYSLIB5006: ... is for evaluation purposes only a menos que suprimas el diagnóstico.
using MLDsa key = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa65);
byte[] data = Encoding.UTF8.GetBytes("hello");
byte[] context = Encoding.UTF8.GetBytes("invoice-v1");
byte[] signature = key.SignData(data, context); // 3309 bytes
Console.WriteLine(key.VerifyData(data, signature, context)); // True
Console.WriteLine(key.VerifyData(data, signature)); // False
#pragma warning disable SYSLIB5006 // the key export methods are still [Experimental] in .NET 10
byte[] pkcs8 = key.ExportPkcs8PrivateKey(); // 54 bytes: the seed form
JavaScript y TypeScript. La compatibilidad nativa está llegando. Node.js 24.22 crea claves con crypto.generateKeyPairSync("ml-dsa-65") y firma con crypto.sign(null, data, privateKey) a través de su OpenSSL 3.5 integrado, y su WebCrypto acepta { name: "ML-DSA-65" } con un ExperimentalWarning. Chromium 156 (la compilación incluida en Playwright 1.64.1) generó una clave y una firma de 3309 bytes mediante crypto.subtle en una página HTTPS. Comprueba los demás motores de navegador antes de depender de ello. Para código que tiene que funcionar en todas partes, @noble/post-quantum 0.7.0 es una implementación en JS puro que funciona en Node.js y en cualquier navegador (el ejemplo se ejecutó en node:24.22.0-trixie):
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
import { randomBytes } from "node:crypto";
const { secretKey, publicKey } = ml_dsa65.keygen(randomBytes(32)); // keep the 32-byte seed, not secretKey
const msg = new TextEncoder().encode("hello");
const context = new TextEncoder().encode("invoice-v1");
const sig = ml_dsa65.sign(msg, secretKey, { context });
ml_dsa65.verify(sig, msg, publicKey, { context }); // true
ml_dsa65.verify(sig, msg, publicKey); // false
ml_dsa65.sign(msg, secretKey, { extraEntropy: false }); // deterministic variant
Certificados ML-DSA y TLS
OpenSSL 3.5 ya puede emitir certificados ML-DSA y usarlos en TLS 1.3. Esta prueba crea una raíz y un certificado hoja ML-DSA-65 y se conecta con s_client. Se rechaza a un cliente que solo ofrece algoritmos de firma clásicos:
leaf certificate: 5514 bytes DER
Protocol version: TLSv1.3
Signature type: mldsa65
Verification: OK
Negotiated TLS1.3 group: X25519MLKEM768
--- a classic-only client
error:0A000410:SSL routines:ssl3_read_bytes:ssl/tls alert handshake failure
Los puntos de código de TLS (mldsa44 0x0904, mldsa65 0x0905, mldsa87 0x0906) proceden de draft-ietf-tls-mldsa. En octubre de 2026 estaba en la cola del RFC Editor. Go 1.27 admite los mismos tres esquemas.
En la web pública esto todavía no se puede usar. Los navegadores no aceptan certificados ML-DSA de CA públicas. Firefox los mantiene detrás de la preferencia security.tls.enable_mldsa, que por defecto vale false. Chrome declaró en febrero de 2026 que no tiene planes inmediatos de añadir a su almacén raíz certificados X.509 con claves poscuánticas. En su lugar, trabaja en los Merkle Tree Certificates, que evitan enviar varias firmas del orden del kilobyte en cada handshake. Hoy los certificados ML-DSA son realistas en una PKI privada: mallas de servicios, identidad de dispositivos, mTLS interno, firma de código y de documentos donde controlas ambos extremos.
Errores frecuentes
- Esperar una firma estable. La firma hedged es la opción predeterminada en todas las bibliotecas anteriores (lo comprobamos en OpenSSL, Java y noble firmando dos veces), así que la misma entrada da bytes distintos cada vez. Compara resultados de verificación, no bytes de firma.
- Confundir ML-DSA puro con HashML-DSA. Son algoritmos distintos, con OID distintos. Un verificador configurado para uno rechaza el otro, y FIPS 204 recomienda usar un par de claves solo para uno de ellos.
- Establecer el contexto en un solo lado. El fallo es idéntico al de una firma incorrecta. Registra el contexto que usa tu protocolo junto al nombre del algoritmo.
- Descartar la semilla. Exportar solo la clave expandida funciona hasta que necesitas cargarla en Go o en otra implementación que solo acepta semillas. Convierte con
-provparam ml-dsa.output_formats=seed-onlymientras aún puedas. - Dar por hecho que el entorno de ejecución lo admite. .NET y otras capas sobre la biblioteca del sistema heredan su versión. Una aplicación que funciona en el equipo de un desarrollador con OpenSSL 3.5 puede fallar en un contenedor construido sobre una imagen base más antigua.
- Olvidar los límites de tamaño. Encabezados de JWT, cookies, registros DNS, extensiones X.509, columnas de base de datos, códigos QR, búferes de HSM: todo lo dimensionado para una firma ECDSA de 64 bytes tiene que dar cabida a 3309 bytes o más.
Fuentes
- FIPS 204, Module-Lattice-Based Digital Signature Standard, el estándar de firma digital basado en retículos de módulo (agosto de 2024): conjuntos de parámetros (tabla 1), tamaños (tabla 2), firma hedged y determinista (§3.4), contexto y HashML-DSA (§5.2–5.4).
- RFC 9881: identificadores de algoritmo de ML-DSA para X.509, formatos de clave privada, μ externo.
- RFC 9964: ML-DSA para JOSE y COSE (
ML-DSA-44/65/87, tipo de claveAKP). - draft-ietf-tls-mldsa: ML-DSA en TLS 1.3.
- NIST IR 8547 (borrador público inicial): calendario de transición.
- Documentación de las bibliotecas: OpenSSL 3.5 NEWS, notas de la versión de Go 1.27, JEP 497, Novedades de las bibliotecas de .NET 10.
La salida de los comandos se generó el 20 de octubre de 2026 en node:24.22.0-trixie (OpenSSL 3.5.7, Node.js 24.22.0), golang:1.27.3-trixie, eclipse-temurin:25.0.4.1_1-jdk-noble (con bcprov-jdk18on 1.86), mcr.microsoft.com/dotnet/sdk:10.0.402-alpine3.24, mcr.microsoft.com/dotnet/sdk:10.0.402-noble y mcr.microsoft.com/playwright:v1.64.1-noble (Chromium 156).