2.2 crypto/mlkem 后量子密钥交换
「先存下来,等量子计算机成熟了再解密」——这叫 harvest now, decrypt later。它对今天的威胁不是危言耸听:如果一段密文需要保密 20 年,而 20 年后有量子计算机能破解它,那么现在传输的密钥协商就必须换成抗量子算法。ML-KEM(原名 Kyber,NIST 标准 FIPS 203)就是为此设计的密钥封装机制(KEM)。
Go 1.24 把 crypto/mlkem 收进了标准库。这意味着你不需要任何第三方依赖,就能在纯 Go 里做后量子密钥交换。
本节要回答:
crypto/mlkem的 API 长什么样、768 与 1024 两套参数各有多大、被篡改会怎样、哪个版本引入?结论:该包于 Go 1.24 引入,同时提供 ML-KEM-768 与 ML-KEM-1024(api/go1.24.txt与 go1.24.0 源码双重证据);密文被篡改时Decapsulate不报错,而是通过隐式拒绝返回一个不同的共享密钥。
2.2.1 KEM 的两个动作:封装与解封装
KEM 不是「加密」,它专门用来在双方之间协商出一个共享密钥。流程只有两步:
- 封装(Encapsulate):发送方拿到接收方的封装密钥(公钥),生成一对「共享密钥 + 密文」,把密文发给对方;
- 解封装(Decapsulate):接收方用自己的解封装密钥(私钥)和密文,恢复出同一个共享密钥。
得到共享密钥之后,双方再用对称算法(如 AES-GCM 或 ChaCha20-Poly1305)加密真正的数据。KEM 只负责协商密钥,不负责传输数据。
2.2.2 API 概览
crypto/mlkem 的 API 是「两套参数 × 四个类型」的对称结构:
| 参数集 | 密钥类型 | 生成函数 | 安全级别 |
|---|---|---|---|
| ML-KEM-768 | DecapsulationKey768 / EncapsulationKey768 | GenerateKey768 | NIST 类别 3 |
| ML-KEM-1024 | DecapsulationKey1024 / EncapsulationKey1024 | GenerateKey1024 | NIST 类别 5 |
每个密钥类型的方法也对称:
func (ek *EncapsulationKey768) Encapsulate() (sharedKey, ciphertext []byte)
func (ek *EncapsulationKey768) Bytes() []byte
func (dk *DecapsulationKey768) Decapsulate(ciphertext []byte) ([]byte, error)
func (dk *DecapsulationKey768) EncapsulationKey() *EncapsulationKey768
func (dk *DecapsulationKey768) Bytes() []byte
标准库源码的包注释给出了一条明确建议:大多数应用应该用 ML-KEM-768。
// Most applications should use the ML-KEM-768 parameter set, as implemented by
// [DecapsulationKey768] and [EncapsulationKey768].
每个参数集的完整方法清单(以 768 为例,1024 完全对称):
| 方法 | 作用 |
|---|---|
GenerateKey768() (*DecapsulationKey768, error) | 随机生成密钥对 |
NewDecapsulationKey768(seed []byte) (*DecapsulationKey768, error) | 从 64 字节种子重建私钥 |
NewEncapsulationKey768(raw []byte) (*EncapsulationKey768, error) | 从原始字节重建公钥 |
(*DecapsulationKey768).EncapsulationKey() *EncapsulationKey768 | 取出对应的公钥 |
(*DecapsulationKey768).Decapsulate(ct []byte) ([]byte, error) | 解封装,恢复共享密钥 |
(*DecapsulationKey768).Bytes() []byte | 导出 64 字节种子 |
(*EncapsulationKey768).Encapsulate() (shared, ct []byte) | 封装,生成共享密钥与密文 |
(*EncapsulationKey768).Bytes() []byte | 导出 1184 字节公钥 |
一个实现细节:crypto/mlkem 本身是薄壳,真正的算法实现在 crypto/internal/fips140/mlkem 里(go1.24.0 源码的 import 行可证)。这意味着它受 Go 的 FIPS 140-3 模块管辖——对需要合规的部署来说,这是「能用标准库就别用 x/crypto」的一个实际理由。
2.2.3 实测:往返、长度、种子重建
package main
import (
"bytes"
"crypto/mlkem"
"fmt"
)
func main() {
dk, _ := mlkem.GenerateKey768()
ek := dk.EncapsulationKey()
fmt.Println("768 ek bytes:", len(ek.Bytes()))
shared1, ct := ek.Encapsulate()
fmt.Println("768 ciphertext bytes:", len(ct))
shared2, err := dk.Decapsulate(ct)
fmt.Println("768 shared key match:", bytes.Equal(shared1, shared2), "err:", err)
fmt.Printf("768 shared key (hex): %x\n", shared1)
dk2, _ := mlkem.GenerateKey1024()
ek2 := dk2.EncapsulationKey()
fmt.Println("1024 ek bytes:", len(ek2.Bytes()))
s1, ct2 := ek2.Encapsulate()
s2, _ := dk2.Decapsulate(ct2)
fmt.Println("1024 shared key match:", bytes.Equal(s1, s2), "ct bytes:", len(ct2))
seed := dk.Bytes()
dk3, _ := mlkem.NewDecapsulationKey768(seed)
fmt.Println("rebuilt ek equals original:",
bytes.Equal(ek.Bytes(), dk3.EncapsulationKey().Bytes()))
}
实测输出(GOTOOLCHAIN=go1.27.0 go run .):
768 ek bytes: 1184
768 ciphertext bytes: 1088
768 shared key match: true err: <nil>
768 shared key (hex): ee23762d678250779bb3ed40016ffe4a939c718aabcbfe747dda7ae7cddc01e7
1024 ek bytes: 1568
1024 shared key match: true ct bytes: 1568
rebuilt ek equals original: true
把长度摊成表,方便对照协议实现:
| 参数集 | 封装密钥(公钥) | 密文 | 共享密钥 |
|---|---|---|---|
| ML-KEM-768 | 1184 字节 | 1088 字节 | 32 字节 |
| ML-KEM-1024 | 1568 字节 | 1568 字节 | 32 字节 |
几个可以直接从输出读出的结论:
- 共享密钥长度恒为 32 字节(
SharedKeySize),两套参数一致; - 768 的密文(1088)比公钥(1184)略短;1024 两者相等(1568);
shared key match: true证明封装与解封装恢复出的是同一个密钥;rebuilt ek equals original: true证明DecapsulationKey.Bytes()是一个可持久化的种子,用NewDecapsulationKey768(seed)能重建出完全相同的密钥对——这是把私钥存进密钥管理系统(KMS)的正确方式。
注意共享密钥的十六进制值每次运行都不同(Encapsulate 内部随机),上面那一行只是某一次运行的结果,不能当成固定测试向量。
2.2.4 反直觉的点:篡改密文不报错
这是 ML-KEM 最容易被误解的地方。把密文改掉一个字节,再拿去解封装:
ct[0] ^= 0xff
bad, err := dk.Decapsulate(ct)
fmt.Println("768 tampered: err =", err, "key changed:", !bytes.Equal(bad, shared1))
实测输出:
768 tampered: err = <nil> key changed: true
err 是 nil,但密钥变了。这不是 bug,而是 ML-KEM 规范要求的隐式拒绝(implicit rejection):解封装遇到无效密文时,不抛异常,而是用私钥里的一个随机种子确定性地派生出另一个「伪共享密钥」返回。攻击者拿到这个错误密钥去解密后续数据会失败,但从返回值上无法区分「密钥对了但数据坏」和「密钥根本是错的」。
这对工程实现的直接影响:不要用 err != nil 来判断 KEM 是否成功。正确做法是让后续的对称解密(AEAD)去验证——如果密钥错了,AEAD 的认证标签必然失败。KEM 层面没有「握手失败」这个信号。
三条常见误用,都可以在这条性质上找到根源:
| 误用 | 后果 | 正确做法 |
|---|---|---|
用 err != nil 判断 KEM 成败 | 篡改密文被当成成功 | 用后续 AEAD 认证来判定 |
| 把 KEM 当加密算法直接传数据 | 密文长度固定,装不下数据 | KEM 只协商密钥,数据用 AEAD 加密 |
| 共享密钥直接用,不做 KDF | 双方密钥相同但无上下文绑定 | 过一遍 HKDF(见 2.3)再分用途 |
2.2.5 常量与安全级别
包里的导出常量(来自 api/go1.24.txt)把各参数集的尺寸固定下来,写协议实现时应该引用它们而不是硬编码数字:
| 常量 | 值 | 含义 |
|---|---|---|
SharedKeySize | 32 | 共享密钥字节数(两套参数共用) |
SeedSize | 64 | 解封装密钥种子的字节数 |
EncapsulationKeySize768 | 1184 | ML-KEM-768 封装密钥长度 |
CiphertextSize768 | 1088 | ML-KEM-768 密文长度 |
EncapsulationKeySize1024 | 1568 | ML-KEM-1024 封装密钥长度 |
CiphertextSize1024 | 1568 | ML-KEM-1024 密文长度 |
SeedSize = 64 值得注意:DecapsulationKey.Bytes() 返回的正是这 64 字节种子,所以 NewDecapsulationKey768(seed) 才能重建出完全相同的密钥对。这也意味着私钥的持久化格式只有 64 字节,比 1184/1568 字节的公钥小得多——把私钥存进 KMS 时按 64 字节的种子存即可。
与经典密钥交换的尺寸对照:
| 方案 | 公钥长度 | 密文/共享部分 | 抗量子 |
|---|---|---|---|
| ECDH P-256 | 65 | 65 | 否 |
| X25519 | 32 | 32 | 否 |
| ML-KEM-768 | 1184 | 1088 | 是 |
| ML-KEM-1024 | 1568 | 1568 | 是 |
ML-KEM 的尺寸是经典方案的几十倍。这不是实现问题,而是后量子算法本身的代价:为了抗量子攻击,密钥和密文都必须大得多。这直接影响了 TLS 握手的字节数,也是为什么标准库推荐「混合模式」(X25519MLKEM768)——同时保留经典方案的效率与后量子的安全性。
2.2.6 版本归属(api + 源码双重证据)
这是本卷最需要小心的一节,因为网上关于「ML-KEM-1024 是哪个版本加的」说法不一。本机有两份独立证据,结论一致:
| 符号 | 引入版本 | 证据一(api 清单) | 证据二(源码) |
|---|---|---|---|
crypto/mlkem(768 与 1024) | Go 1.24 | api/go1.24.txt 含 GenerateKey1024 #70122 | go1.24.0 源码 mlkem.go:123 定义 GenerateKey1024 |
Encapsulator() 方法 | Go 1.26 | api/go1.26.txt #75300 | —— |
crypto/mldsa(签名,非本节) | Go 1.27 | go list std 差分(1.26 无、1.27 有) | —— |
核实命令:
$ grep -ln "^pkg crypto/mlkem," /usr/local/go/api/go1.*.txt
/usr/local/go/api/go1.24.txt
/usr/local/go/api/go1.26.txt
$ GOTOOLCHAIN=go1.24.0 go doc crypto/mlkem.GenerateKey1024
func GenerateKey1024() (*DecapsulationKey1024, error)
$ GOTOOLCHAIN=go1.24.0 go run . # 完整往返在 1.24.0 上同样通过
也就是说,768 与 1024 是同时在 Go 1.24 进入标准库的。go1.26.txt 里的新增只有 Encapsulator() 方法(把 mlkem 密钥适配到 crypto.Encapsulator 接口)。我特意用 go1.24.0 工具链实跑过完整往返,输出与 1.27 一致,排除了「只在 1.27 能用」的可能。
2.2.7 与 TLS 的集成
crypto/mlkem 主要不是给应用直接调用,而是给 crypto/tls 用的。相关的 GODEBUG 开关(来自 internal/godebugs 表):
| 开关 | 包 | 引入 | 说明 |
|---|---|---|---|
tlsmlkem | crypto/tls | Go 1.24(Changed: 24,旧值 0) | 是否启用 X25519MLKEM768 混合密钥交换 |
tlssecpmlkem | crypto/tls | Go 1.26(Changed: 26,旧值 0) | 是否启用 SecP256r1MLKEM768 混合组 |
实践含义:Go 1.24 起,crypto/tls 默认就会在 ClientHello 里带上 X25519MLKEM768 这个后量子混合组,与服务端协商时优先选它。也就是说,只要你升级了 Go 版本,TLS 连接就已经在向后量子迁移了,应用层不需要改代码。想关掉(例如排查兼容性问题)才需要显式设 GODEBUG=tlsmlkem=0。
2.2.8 小结
crypto/mlkem于 Go 1.24 引入,同时提供 ML-KEM-768 与 ML-KEM-1024(api 清单 + go1.24.0 源码双重证据)。- 实测:768 公钥 1184 字节、密文 1088 字节;1024 两者均 1568 字节;共享密钥恒 32 字节。
DecapsulationKey.Bytes()是可持久化种子,可用于密钥重建。- 篡改密文不会返回错误——隐式拒绝返回错误密钥,必须靠后续 AEAD 认证来发现。
crypto/tls自 1.24 起默认启用X25519MLKEM768,1.26 增加SecP256r1MLKEM768。
一句话总结本节最容易记错的三件事:ML-KEM-1024 是 1.24 就有了(不是 1.25)、篡改密文不报错(隐式拒绝)、共享密钥必须再过一层 KDF 才能分用途使用。
再补一条实操建议:如果你要自己实现基于 ML-KEM 的握手协议,优先用混合模式(经典 ECDH + ML-KEM),而不是纯后量子。原因有二:一是纯 ML-KEM 的密钥/密文尺寸是经典方案的几十倍,握手字节数会明显膨胀;二是混合模式在经典算法仍安全时保留其性能优势,在后量子攻击成为现实时又有兜底。标准库的 X25519MLKEM768 就是这个思路的现成实现。
下一节继续在密码学标准库,看同一批进入的 crypto/hkdf、crypto/pbkdf2 与 crypto/sha3——它们和 mlkem 一样,都在 Go 1.24 补齐了「原本只能靠 golang.org/x/crypto 提供」的能力。
阅读导航:上一节:2.1 os.Root 与受限文件系统 · 下一节:2.3 crypto/hkdf、pbkdf2、sha3 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。