Go 1.27 的 crypto/mldsa:2420 字节签名落到 TLS、JWT 和数据库列宽时
crypto/mldsa 实现 FIPS 204 规定的 ML-DSA 签名方案。API 本身很直白,真正需要改动的是体积——一个签名 2420 字节起步,一个公钥 1312 字节起步,这两组数字会直接决定握手包大小、证书链长度、token 头部大小和表的列定义。
可用性:FIPS 模块下这些函数直接报错
在 FIPS 140-3 Go Cryptographic Module v1.0.0 下,这个包不可用:GenerateKey、NewPrivateKey、NewPublicKey、Verify 都会返回 error。使用 v1.26.0 或更新版本时可用。
三组参数与确切字节数
| 参数集 | 公钥 | 签名 |
|---|---|---|
| MLDSA44 | 1312 字节 | 2420 字节 |
| MLDSA65 | 1952 字节 | 3309 字节 |
| MLDSA87 | 2592 字节 | 4627 字节 |
常量一一对应:MLDSA44PublicKeySize、MLDSA65PublicKeySize、MLDSA87PublicKeySize,以及 MLDSA44SignatureSize、MLDSA65SignatureSize、MLDSA87SignatureSize。私钥的 seed 长度固定为 PrivateKeySize = 32。
Parameters 代表 FIPS 204 的固定参数集。MLDSA44()、MLDSA65()、MLDSA87() 多次调用返回同一个值,可以直接用于相等比较和 switch,返回值可并发使用;它上面还有 PublicKeySize()、SignatureSize()、String() 方法。
文档的建议很明确:多数应用用 MLDSA44。
最小签名验签
package main
import (
"crypto/mldsa"
"fmt"
"log"
)
func main() {
// 生成 ML-DSA-44 密钥对
sk, err := mldsa.GenerateKey(mldsa.MLDSA44())
if err != nil {
log.Fatal(err)
}
// 发布公钥编码
publicKey := sk.PublicKey().Bytes()
fmt.Printf("public key: %d bytes\n", len(publicKey))
// 签名
msg := []byte("hello, world")
sig, err := sk.Sign(nil, msg, &mldsa.Options{Context: "example"})
if err != nil {
log.Fatal(err)
}
fmt.Printf("signature: %d bytes\n", len(sig))
// 验签:context 必须与签名端一致
pk, err := mldsa.NewPublicKey(mldsa.MLDSA44(), publicKey)
if err != nil {
log.Fatal(err)
}
if err := mldsa.Verify(pk, msg, sig, &mldsa.Options{Context: "example"}); err != nil {
log.Fatal("invalid signature: ", err)
}
}
输出:
public key: 1312 bytes
signature: 2420 bytes
Context、确定性签名与接口实现
Options 只有一个字段:
type Options struct {
// Context 用于区分不同用途的签名。
// 最长 255 字节,默认为空。
// 签名和验签必须使用同一个 context。
Context string
}
(*Options).HashFunc() 返回零,用来满足 crypto.SignerOpts。Verify 的 opts 传 nil 等价于 Options 零值。
Sign 的行为分两种:opts 为 nil 或其 HashFunc() 返回零时,直接对 message 签名,此时如果想要 context,opts 传 *mldsa.Options;HashFunc() 返回 crypto.MLDSAMu 时,传入的 message 必须是预哈希后的 μ 代表。io.Reader 参数被忽略。
SignDeterministic 与 Sign 相同,但签名是确定性的。
PrivateKey 实现 crypto.Signer 和非正式的扩展 crypto.PrivateKey 接口,可并发使用:
func (sk *PrivateKey) Sign(_ io.Reader, message []byte, opts crypto.SignerOpts) (signature []byte, err error)
其余方法包括:GenerateKey 生成随机私钥;NewPrivateKey 从 seed 解码,seed 必须恰好 PrivateKeySize 字节;Bytes() 返回的是私钥 seed;Equal 判断两个私钥是否由同一 seed 派生(x 不是 *PrivateKey 时返回 false);PublicKey() 和 Public() 取出公钥。
PublicKey 同样可并发使用:NewPublicKey 从编码构造,Bytes() 返回编码,Equal 比较编码是否相同,Parameters() 返回该公钥对应的参数集。
预哈希 μ 路径
上层如果已经算好了 FIPS 204 的 μ(例如自己做域分离,或与别的库对接),可以走 crypto.MLDSAMu。这时 message 是 μ 代表本身,不能再传原始消息:
type muOpts struct{}
func (muOpts) HashFunc() crypto.Hash { return crypto.MLDSAMu }
sig, err := sk.Sign(nil, mu, muOpts{})
*mldsa.Options 的 HashFunc() 恒为零,它对应“直接签名 + context”这条路径;预哈希路径需要自己实现 crypto.SignerOpts,同时要把该有的上下文信息放进 μ 里,两种路径产出的签名语义不同,不能混用。
体积带来的取舍
TLS 1.3 握手
Go 1.27 的 crypto/tls 在 TLS 1.3 中支持 ML-DSA 签名,新增 MLDSA44、MLDSA65、MLDSA87 三个 SignatureScheme 值。服务端证书使用 ML-DSA 密钥时,握手中的 CertificateVerify 就要带一个 2420 字节起的签名。
再叠加证书链:叶子证书里公钥 1312 字节,CA 对它的签名 2420 字节,仅这两项就约 3.7KB。一条三级链把中间 CA 的公钥和签名都算上,很容易到 7KB 以上,具体取决于链上有几张证书、每张用哪个参数集。
TLS 1.3 服务端的证书 flight 通常能塞进初始拥塞窗口(十几个 MSS 的量级),但证书链变大后,加上 ClientHello/ServerHello 和各类扩展,整个 flight 会被推到需要额外往返的位置,高延迟链路上直接表现为握手变慢。选 65 或 87 时更明显。
证书链
链上每张证书的成本大致是“本张公钥 + 上一级签名”。全用 MLDSA44 时约 3.7KB/张;根或中间 CA 换用更高参数集,这一项同步变大:MLDSA65 是 1952 + 3309,MLDSA87 是 2592 + 4627。算成本时要把链上每一跳都算进去,而不是只看叶子。
crypto/x509 已支持 ML-DSA 私钥、公钥和签名,签发和解析都走现有入口。
JWT 与 HTTP 头
签名进 JWT 之前要 base64url 编码,体积按 4/3 膨胀:
| 参数集 | 签名原始字节 | base64url 后长度 |
|---|---|---|
| MLDSA44 | 2420 | 约 3227 字符 |
| MLDSA65 | 3309 | 约 4411 字符 |
| MLDSA87 | 4627 | 约 6170 字符 |
原本 ES256 的 token 是几百字节,换成 ML-DSA-44 后签名段就有 3.2KB 以上。Authorization 头在不少网关和 Ingress 上有 8KB 上限,单个 token 还能过,但塞进 Cookie、和别的头叠加、或者经过会重写 header 的链路时要实测。
估算各参数集的开销:
for _, p := range []mldsa.Parameters{mldsa.MLDSA44(), mldsa.MLDSA65(), mldsa.MLDSA87()} {
raw := p.SignatureSize()
enc := base64.RawURLEncoding.EncodedLen(raw)
fmt.Printf("%s: sig %d bytes, base64url %d chars\n", p, raw, enc)
}
数据库列宽
- 私钥只存 seed。
Bytes()返回的就是 32 字节 seed,列用BYTEA或VARBINARY(32),不必另存“完整私钥”。 - 公钥 1312 字节;存 base64 约 1750 字符,列宽留到
VARCHAR(2048)或直接用BYTEA。 - 签名 2420 字节,base64 后约 3227 字符;如果列里存 base64,
VARCHAR(4096)起步,或者直接BYTEA/TEXT存原始字节。 - 不要给签名列建索引。MySQL InnoDB 索引键上限 3072 字节,utf8mb4 下
VARCHAR(768)就到顶,签名列放进去会建不出来或被截断。 - 长度不匹配在不同数据库上的表现不一样:PostgreSQL 的
varchar(n)超长直接报错,MySQL 严格模式报错、非严格模式截断。迁移时先用最大参数集的长度(4627 字节 / base64 约 6170 字符)压一遍。
注意事项
Options.Context最长 255 字节,签名和验签必须完全一致;改动 context 等于换了签名域,旧签名全部验不过。NewPrivateKey的 seed 必须恰好PrivateKeySize(32)字节,多一个字节少一个字节都会失败。Equal的语义是“同一 seed”或“同一编码”,不是逐字节比较整个私钥结构。- 预哈希路径下 message 是 μ 代表,不是原始消息。
- 在 FIPS 140-3 Go Cryptographic Module v1.0.0 下,
GenerateKey、NewPrivateKey、NewPublicKey、Verify返回 error;v1.26.0 起可用。 - 新系统从 MLDSA44 起步;只有当确实需要更高参数集、并且能接受签名和公钥再大一圈(65:1952/3309,87:2592/4627)时才往上走。