跳到主要内容
ecdsa.com
← 全部文章

面向开发者的 ML-DSA:FIPS 204 密钥、签名与代码

你真正会部署的后量子签名。三个参数集各要多少字节、哪些选项会改变签名、为什么一个库的密钥在另一个库中无法加载,以及针对六种技术栈的实测代码。

简短回答:ML-DSA 是 FIPS 204(2024年8月)标准化的基于格的签名方案,前身是 CRYSTALS-Dilithium。它有三个参数集:ML-DSA-44、ML-DSA-65 和 ML-DSA-87,签名分别为 2420、3309 和 4627 字节,而 Ed25519 或 ECDSA P-256 的签名只有 64 字节。签名默认采用对冲(hedged,即随机化)方式;确定性模式只影响签名者的输出。最长 255 字节的可选上下文字符串和独立的预哈希变体(HashML-DSA)会改变验证者必须执行的操作,因此双方必须就这两项达成一致。OpenSSL 3.5、Go 1.27、Java 24+、.NET 10、BouncyCastle 和 @noble/post-quantum 都实现了 ML-DSA。各实现分歧最大的地方是私钥。请保存 32 字节的种子。

为什么是 ML-DSA,为什么是现在

任何拥有足够大的量子计算机的人,都能伪造 ECDSA、EdDSA 和 RSA 签名。与加密流量不同,签名即使现在被收集、以后被破解,也不会对过去造成损害:伪造的签名只在有人依赖它的那一刻才产生影响。正因如此,最紧迫的迁移是密钥交换,而签名的时间更充裕。但“更充裕”也有限度:

  • 长期有效的信任根,例如固件签名密钥、CA 根证书、代码签名和文档签名密钥,在创建后的十年或二十年里都必须保持可信。
  • NIST 的过渡计划草案 IR 8547 拟在2030年后弃用安全强度为 112 位的经典签名,并在2035年后禁止所有易受量子攻击的签名。具体截止日期以及如何据此做规划,参见2026年的后量子迁移。

ML-DSA 是 NIST 给出的通用方案,同系列的其他方案则各自填补细分需求。FIPS 205 SLH-DSA 是基于哈希的方案,安全假设保守,密钥很小,但签名要么非常大,要么生成很慢。有状态的基于哈希的方案 LMS 和 XMSS(SP 800-208)用于固件。基于 Falcon 的 FN-DSA 计划作为 FIPS 206 发布。截至2026年10月,NIST 尚未在 csrc.nist.gov 上发布它的草案。

三个参数集

ML-DSA-44ML-DSA-65ML-DSA-87
NIST 安全类别235
公钥1312 B1952 B2592 B
签名2420 B3309 B4627 B
展开私钥2560 B4032 B4896 B
私钥种子32 B32 B32 B
SPKI(DER)格式的公钥1334 B1974 B2614 B
OID(RFC 9881)2.16.840.1.101.3.4.3.17…3.18…3.19

原始大小取自 FIPS 204 的表 2。SPKI 大小是 OpenSSL 3.5.7 为新生成的密钥写出的结果。作为对比,Ed25519 公钥为 32 字节,签名为 64 字节。RSA-2048 公钥的 SPKI 编码为 294 字节,签名为 256 字节。

如何选择参数集:

  • ML-DSA-65 是通常的默认选择。按照 JEP 497,未指定参数集时 Java 默认使用它;大多数指南也把类别 3 视为稳妥的折中。
  • ML-DSA-87 是 NSA 的 CNSA 2.0 对美国国家安全系统的要求(所有密级都使用“Level V 参数”)。
  • ML-DSA-44 最小,适合每个字节都很宝贵、数据生命周期又短的场合。Go 的文档甚至写道:“大多数应用应使用 MLDSA44。”

实践中,字节数比速度更重要。在撰写本文所用的 arm64 Linux 虚拟机上,用 openssl speed 单核运行 2 秒(OpenSSL 3.5.7),得到如下速率:

算法每秒签名次数每秒验证次数
ML-DSA-651,4888,019
RSA-20481,82574,119
ECDSA P-25665,46524,446

ML-DSA-65 的签名速度与 RSA-2048 相当,验证速度比 RSA 慢一个数量级,但对大多数服务来说已经够快。更大的变化在于大小:证书链、JWT 或带签名的日志条目都会增大数千字节。在我们的测试中,由 ML-DSA-65 CA 签发的 ML-DSA-65 叶子证书,DER 编码为 5514 字节。

对冲、确定性、上下文:哪些选项会改变签名

FIPS 204 中有三个选项,它们决定 Sign 输出哪些字节,以及必须告诉 Verify 什么。

对冲与确定性:在 ML-DSA 内部,签名过程会从私钥、消息和一个 32 字节的值 rnd 派生出每次签名专用的秘密值。默认情况下(“对冲”模式),rnd 是新鲜的随机数;在确定性变体中,它是 32 个零字节。FIPS 204 §3.4 解释了其中的取舍:预先计算的部分可以防范失效的随机数生成器,新鲜的部分则让侧信道攻击和故障攻击更难实施。它还指出,确定性变体“不应在需要担心侧信道攻击的平台上使用”。两种方式的验证过程完全相同,因此选择哪一种只与签名者有关。OpenSSL 可以直观地展示两者的区别:

OpenSSL 3.5.7:对冲与确定性 ML-DSA-65
$ 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  -

除非使用确定性签名,否则不要编写把新生成的签名与已保存签名做比较的测试。按照设计,对冲签名每次调用的结果都不相同。

上下文字符串:Sign 和 Verify 都接受一个最长 255 字节的上下文字符串 ctx,默认为空。ML-DSA 签名的是 0x00 ‖ len(ctx) ‖ ctx ‖ M,而不只是 M,因此在一个上下文下生成的签名,换成另一个上下文就无法通过验证。上下文是一种低成本的域分离工具:共用同一个密钥的两个协议,如果各自使用自己的上下文,就不会被诱骗去接受对方的签名。但如果一方设置了上下文而另一方没有,验证就会以令人困惑的方式失败:

OpenSSL 3.5.7:带上下文签名,不带上下文验证
$ 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

上下文由协议固定,所以你很少需要自己选择。X.509 中的 ML-DSA(RFC 9881)以及 JOSE 和 COSE 中的 ML-DSA(RFC 9964,2026年5月)都使用空上下文。RFC 9964 规定:“ctx 参数必须(MUST)是空字符串”。

HashML-DSA:FIPS 204 §5.4 还定义了一种预哈希变体。消息先用 SHA-512、SHA-256、SHAKE128 或其他获批准的函数计算哈希,签名覆盖的是摘要、哈希函数的 OID 以及另一个域分离字节(1 而不是 0)。它是一个独立的签名方案,有自己的 OID。HashML-DSA 签名并不是对同一条消息的有效 ML-DSA 签名。FIPS 204 写道“首选‘纯’ML-DSA 版本”,RFC 9881 也不允许在证书中使用 HashML-DSA。RFC 9964 同样没有为 JOSE 或 COSE 定义它。只有在协议要求时才使用它。

对于非常大的消息,还有第三条路:外部 μ。签名者在签名模块之外计算 64 字节的消息代表值 μ,只把它传入模块。RFC 9881 描述了这种方式,Go 1.27(crypto.MLDSAMu)和 .NET 10(SignMu)提供了相应的接口。它生成的是普通的纯 ML-DSA 签名,因此验证者无需做任何特殊处理。

私钥格式:保存种子

ML-DSA 密钥对由 32 字节的种子 ξ 生成。2560~4896 字节的“展开”私钥由该种子计算得出,公钥也是如此。RFC 9881 定义了三种 PKCS#8 编码:

  • seed:只有 32 字节的种子,[0] IMPLICIT OCTET STRING。这是推荐的形式,编码为 PKCS#8 后为 54 字节。
  • expandedKey:只有展开密钥。
  • both:同时包含种子和展开密钥的序列。

该 RFC 建议各实现“保留并导出种子”。展开过程不可逆,因此不带种子保存的密钥永远无法转换回小体积的形式。各个库选择了不同的默认格式,这会导致导入出错。OpenSSL 3.5 默认写出 both:

openssl asn1parse:OpenSSL 默认格式的 ML-DSA-65 密钥,4098 字节(十六进制已截短)
    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 拒绝这种形式,并会提示你该怎么做。它只接受种子:

Go 1.27.3:对 OpenSSL 的输出调用 x509.ParsePKCS8PrivateKey
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

在我们的测试中,Java 25(JEP 497)、.NET 10 和 Go 导出的都是 54 字节、仅含种子的 PKCS#8 密钥。应当从源头上选择种子形式:

OpenSSL 3.5:生成仅含种子的私钥,或转换为这种形式
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

HSM 和 KMS 产品也面临同样的选择。生成长期使用的密钥之前,先确认能否导出或备份种子。仅含展开形式的密钥会把你绑定在接受展开密钥的实现上。@noble/post-quantum 就是一个例子:它的 secretKey 是展开编码(ML-DSA-65 为 4032 字节),而它的 keygen(seed) 接受的是 32 字节的种子。种子需要你自己保存。

要查看密钥或证书里实际包含什么,可以用后量子证书查看器解码 ML-DSA 和 SLH-DSA 密钥与证书,用 ASN.1 查看器确认你手上的私钥属于三种形式中的哪一种。

现在就能运行的代码

下面的每个代码片段都于2026年10月20日在其前一段注明的容器中运行过。每段代码都用 ML-DSA-65 对 "hello" 签名并验证结果;在库支持的情况下,还设置了上下文字符串。如果想不安装任何软件就试用该方案,后量子签名实验室可以在浏览器中用 ML-DSA、SLH-DSA 和 Falcon 签名和验证。

OpenSSL 3.5(node:24.22.0-trixie,OpenSSL 3.5.7)。ML-DSA 需要 -rawin,否则 pkeyutl 会把输入当作预先计算好的摘要:

OpenSSL 3.5:密钥、签名与验证
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)。使用新增的 crypto/mldsa 包。crypto/x509 负责解析和序列化密钥,crypto/tls 在 TLS 1.3 中接受 ML-DSA 证书。该包的文档提醒:在 FIPS 140-3 Go 密码模块 v1.0.0 下无法使用该包。

Go 1.27:带上下文的 crypto/mldsa
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 提供确定性变体,sk.Bytes() 返回 32 字节的种子。

Java,JDK 24 及更高版本(eclipse-temurin:25.0.4.1_1-jdk-noble)。JEP 497 把 ML-DSA 加入了标准提供者。上下文字符串和 HashML-DSA 被该 JEP 明确列为非目标,因此 JDK 只能使用空上下文签名。

Java 25:使用内置提供者的 ML-DSA
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(同一个 JDK 镜像)。它通过 ContextParameterSpec 支持上下文,并以单独的算法名称支持 HashML-DSA。我们的测试证实,HashML-DSA 签名无法通过纯 ML-DSA 验证:

BouncyCastle 1.86:上下文字符串与 HashML-DSA
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 使用平台的密码库:在 Linux 上是 OpenSSL 3.5 或更高版本,在 Windows 上是支持 PQC 的 CNG。同一个程序在 Ubuntu 24.04 SDK 镜像(10.0.402-noble,OpenSSL 3.0.13)中报告 MLDsa.IsSupported: False,在 Alpine 3.24(OpenSSL 3.5.9)上则报告 True。发布之前,先检查 IsSupported 和你的基础镜像。在 SDK 10.0.402 中,密钥生成、签名和验证都能顺利编译,但除非抑制这条诊断,密钥导出方法仍会报 error SYSLIB5006: ... is for evaluation purposes only 错误,导致构建失败。

.NET 10:带上下文的 MLDsa
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 和 TypeScript:原生支持正在陆续出现。Node.js 24.22 通过内置的 OpenSSL 3.5,用 crypto.generateKeyPairSync("ml-dsa-65") 生成密钥,用 crypto.sign(null, data, privateKey) 签名;它的 WebCrypto 接受 { name: "ML-DSA-65" },但会发出 ExperimentalWarning。Chromium 156(Playwright 1.64.1 中的构建版本)在 HTTPS 页面上通过 crypto.subtle 生成了密钥和 3309 字节的签名。在依赖它之前,先检查其他浏览器引擎。对于必须在所有环境中运行的代码,@noble/post-quantum 0.7.0 是一个纯 JS 实现,可在 Node.js 和任何浏览器中使用(测试环境为 node:24.22.0-trixie):

@noble/post-quantum 0.7.0
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

ML-DSA 证书与 TLS

OpenSSL 3.5 现在就能签发 ML-DSA 证书,并在 TLS 1.3 中使用。下面的测试创建了一张 ML-DSA-65 根证书和一张叶子证书,并用 s_client 连接。只提供经典签名算法的客户端会被拒绝:

OpenSSL 3.5.7:使用 ML-DSA-65 证书的 TLS 1.3
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

TLS 码点(mldsa44 0x0904、mldsa65 0x0905、mldsa87 0x0906)来自 draft-ietf-tls-mldsa。截至2026年10月,该草案处于 RFC Editor 队列中。Go 1.27 支持同样的三种方案。

在公共 Web 上,这还无法使用。浏览器不接受公共 CA 签发的 ML-DSA 证书。Firefox 只有在打开 security.tls.enable_mldsa 首选项后才接受它们,该项默认为 false。Chrome 在2026年2月表示,近期没有把带后量子密钥的 X.509 证书加入其根证书库的计划。Chrome 正转而研发 Merkle 树证书(Merkle Tree Certificates),这种方案可以避免在每次握手中发送好几个以 KB 计的签名。如今,ML-DSA 证书在私有 PKI 中是切实可行的:服务网格、设备身份、内部 mTLS,以及由你掌控两端的代码签名和文档签名。

常见错误

  • 以为签名固定不变:上面所有库默认都使用对冲签名(我们对 OpenSSL、Java 和 noble 各签名两次做了确认),因此同样的输入每次都会得到不同的字节。应比较验证结果,而不是签名字节。
  • 混淆纯 ML-DSA 与 HashML-DSA:它们是不同的算法,OID 也不同。为其中一种配置的验证者会拒绝另一种,FIPS 204 也建议一个密钥对只用于其中一种。
  • 只在一方设置上下文:这种失败看起来和签名错误一模一样。记录日志时,把协议使用的上下文写在算法名称旁边。
  • 丢弃种子:只导出展开密钥平时没有问题,但一旦需要把它加载到 Go 或其他只接受种子的实现中,就行不通了。趁还来得及,用 -provparam ml-dsa.output_formats=seed-only 转换。
  • 想当然地认为运行时支持它:.NET 以及其他基于系统库的封装层会继承系统库的版本。在装有 OpenSSL 3.5 的开发者笔记本上能运行的应用,到了基于旧基础镜像构建的容器中可能就会失败。
  • 忘记大小限制:JWT 头部、Cookie、DNS 记录、X.509 扩展、数据库列、QR 码、HSM 缓冲区——凡是按 64 字节 ECDSA 签名设计大小的地方,都必须能容纳 3309 字节或更多。

参考资料

命令输出于2026年10月20日在以下环境中生成: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(附带 bcprov-jdk18on 1.86)、mcr.microsoft.com/dotnet/sdk:10.0.402-alpine3.24、mcr.microsoft.com/dotnet/sdk:10.0.402-noble 和 mcr.microsoft.com/playwright:v1.64.1-noble(Chromium 156)。