编程 Go 1.27 的 crypto/mldsa:签名 2420 字节,落到 TLS、JWT 和数据库列宽时的取舍

2026-11-10 21:04:58

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 或更新版本时可用。

三组参数与确切字节数

参数集公钥签名
MLDSA441312 字节2420 字节
MLDSA651952 字节3309 字节
MLDSA872592 字节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 后长度
MLDSA442420约 3227 字符
MLDSA653309约 4411 字符
MLDSA874627约 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)时才往上走。

来源

复制全文 生成海报 Go 密码学 TLS JWT

推荐文章

程序员茄子在线接单