《Go 语言高级编程》2.2 crypto/mlkem 后量子密钥交换

后量子密钥封装 ML-KEM(FIPS 203)在 Go 1.24 进了标准库。本节实测 768 与 1024 两套参数的完整往返、打印密钥与密文长度、验证从种子重建密钥,并揭示一个反直觉的点:密文被篡改时 Decapsulate 不报错、而是走隐式拒绝返回另一个密钥。附 api 证据与 tlsmlkem 开关状态。

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-768DecapsulationKey768 / EncapsulationKey768GenerateKey768NIST 类别 3
ML-KEM-1024DecapsulationKey1024 / EncapsulationKey1024GenerateKey1024NIST 类别 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-7681184 字节1088 字节32 字节
ML-KEM-10241568 字节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)把各参数集的尺寸固定下来,写协议实现时应该引用它们而不是硬编码数字:

常量值含义
SharedKeySize32共享密钥字节数(两套参数共用)
SeedSize64解封装密钥种子的字节数
EncapsulationKeySize7681184ML-KEM-768 封装密钥长度
CiphertextSize7681088ML-KEM-768 密文长度
EncapsulationKeySize10241568ML-KEM-1024 封装密钥长度
CiphertextSize10241568ML-KEM-1024 密文长度

SeedSize = 64 值得注意:DecapsulationKey.Bytes() 返回的正是这 64 字节种子,所以 NewDecapsulationKey768(seed) 才能重建出完全相同的密钥对。这也意味着私钥的持久化格式只有 64 字节,比 1184/1568 字节的公钥小得多——把私钥存进 KMS 时按 64 字节的种子存即可。

与经典密钥交换的尺寸对照:

方案公钥长度密文/共享部分抗量子
ECDH P-2566565否
X255193232否
ML-KEM-76811841088是
ML-KEM-102415681568是

ML-KEM 的尺寸是经典方案的几十倍。这不是实现问题,而是后量子算法本身的代价:为了抗量子攻击,密钥和密文都必须大得多。这直接影响了 TLS 握手的字节数,也是为什么标准库推荐「混合模式」(X25519MLKEM768)——同时保留经典方案的效率与后量子的安全性。

2.2.6 版本归属(api + 源码双重证据)

这是本卷最需要小心的一节,因为网上关于「ML-KEM-1024 是哪个版本加的」说法不一。本机有两份独立证据,结论一致:

符号引入版本证据一(api 清单)证据二(源码)
crypto/mlkem(768 与 1024)Go 1.24api/go1.24.txt 含 GenerateKey1024 #70122go1.24.0 源码 mlkem.go:123 定义 GenerateKey1024
Encapsulator() 方法Go 1.26api/go1.26.txt #75300——
crypto/mldsa(签名,非本节)Go 1.27go 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 表):

开关包引入说明
tlsmlkemcrypto/tlsGo 1.24(Changed: 24,旧值 0)是否启用 X25519MLKEM768 混合密钥交换
tlssecpmlkemcrypto/tlsGo 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练