3. ML-KEM参数集与数据结构
ML-KEM(FIPS 203)定义了三个参数集:ML-KEM-512、ML-KEM-768 和 ML-KEM-1024。三者的算法流程完全一致,差异集中在多项式环的维度 k、模数 q、噪声参数以及由此决定的密钥与密文长度上。理解这些差异,是正确选择参数集、正确分配缓冲区、正确实现序列化的前提。本节先梳理参数差异,再深入密钥与密文的字节级编码格式,最后给出序列化与反序列化的实现要点。
三个参数集的核心差异
ML-KEM 工作在环 R_q = Z_q[X]/(X^256+1) 上,模数固定为 q = 3329,多项式次数固定为 256。参数集之间的区别在于向量维度 k:ML-KEM-512 取 k=2,ML-KEM-768 取 k=3,ML-KEM-1024 取 k=4。维度直接决定了公钥向量和密文向量的分量个数,从而决定了所有编码长度。噪声参数 η₁ 与 η₂ 也随参数集变化:512 使用 η₁=3, η₂=2,768 与 1024 使用 η₁=2, η₂=2。压缩参数 d_u 与 d_v 则分别为 10 和 4,三个参数集相同。
下表给出三个参数集的关键数值,这些数值直接对应实现中的常量定义:
| 参数 | ML-KEM-512 | ML-KEM-768 | ML-KEM-1024 |
|---|---|---|---|
| k(向量维度) | 2 | 3 | 4 |
| η₁ / η₂ | 3 / 2 | 2 / 2 | 2 / 2 |
| d_u / d_v | 10 / 4 | 10 / 4 | 10 / 4 |
| 公钥 ek 长度(字节) | 800 | 1184 | 1568 |
| 私钥 dk 长度(字节) | 1632 | 2400 | 3168 |
| 密文 ct 长度(字节) | 768 | 1088 | 1568 |
| 共享密钥长度(字节) | 32 | 32 | 32 |
这些长度不是随意设定的,而是由编码规则精确推导出来的。下面逐一说明。
公钥 ek 的编码格式
公钥 ek 由两部分拼接而成:向量 t̂ 的编码,后接种子 ρ。向量 t̂ 含 k 个多项式,每个多项式有 256 个系数,每个系数是 Z_q 中的元素(0 到 3328)。编码时每个系数占 12 位,即 1.5 字节,因此单个多项式占 256 × 12 / 8 = 384 字节。整个向量占 384k 字节。种子 ρ 固定 32 字节。于是:
ek 长度 = 384k + 32
k=2 → 384×2 + 32 = 800
k=3 → 384×3 + 32 = 1184
k=4 → 384×4 + 32 = 1568
12 位系数的打包规则是:每两个系数(共 24 位)写入 3 个字节。设两个系数为 a 和 b,则三个字节依次为 a & 0xFF、(a >> 8) | ((b & 0x0F) << 4)、b >> 4。反序列化时按相反顺序拆解。实现中必须严格按此位序,否则与标准测试向量不一致。
私钥 dk 的编码格式
私钥 dk 的结构更复杂,由四段拼接:dk = dkPKE ‖ ek ‖ H(ek) ‖ z。其中 dkPKE 是底层 PKE 的私钥,即向量 ŝ 的编码,长度同样为 384k 字节;ek 是公钥,长度 384k+32;H(ek) 是公钥的 SHA3-256 哈希,32 字节;z 是隐式拒绝用的随机种子,32 字节。因此:
dk 长度 = 384k + (384k + 32) + 32 + 32 = 768k + 96
k=2 → 768×2 + 96 = 1632
k=3 → 768×3 + 96 = 2400
k=4 → 768×4 + 96 = 3168
注意 dk 中内嵌了完整的 ek 和 H(ek),这是为了在解封装时能够重新计算并校验,也是隐式拒绝机制的数据基础。实现时不能只存 dkPKE,否则解封装会失败。
密文 ct 的编码格式
密文由压缩后的向量 u 和压缩后的多项式 v 拼接而成。压缩参数 d_u=10 表示 u 的每个系数压缩到 10 位,d_v=4 表示 v 的每个系数压缩到 4 位。向量 u 含 k 个多项式,每个多项式 256 个系数,每个系数 10 位,故单个多项式占 256 × 10 / 8 = 320 字节,向量占 320k 字节。多项式 v 的每个系数 4 位,256 个系数占 256 × 4 / 8 = 128 字节。于是:
ct 长度 = 320k + 128
k=2 → 640 + 128 = 768
k=3 → 960 + 128 = 1088
k=4 → 1280 + 128 = 1568
10 位压缩的打包规则是:每 4 个系数(共 40 位)写入 5 个字节。4 位压缩则是每 2 个系数(共 8 位)写入 1 个字节,高 4 位放第一个系数,低 4 位放第二个系数。压缩本身是 Compress_d(x) = round((2^d / q) · x) mod 2^d,解压缩是 Decompress_d(y) = round((q / 2^d) · y)。这两步的舍入方向必须与标准一致,否则解封装得到的共享密钥会不匹配。
序列化与反序列化的实现要点
实现中最容易出错的地方有三处。第一是 12 位打包的位序。标准规定按小端序将系数写入字节流,两个系数共享 3 个字节,若实现时把高低位弄反,公钥虽然能生成,但对方解析出的 t̂ 会完全错误,导致封装失败。第二是压缩与解压缩的舍入。压缩时用 round 而非截断,解压缩时同样用 round,且中间计算要用足够宽的整数类型避免溢出。第三是私钥中 H(ek) 的位置。解封装时需要从 dk 中取出 ek 重新计算哈希并与存储值比对,若偏移量算错,校验必然失败。
下面给出一个参数化的长度计算与偏移定义示例,可直接作为实现中的常量表使用:
// 以 ML-KEM-768 为例(k=3)
const int K = 3;
const int EK_LEN = 384 * K + 32; // 1184
const int DK_LEN = 768 * K + 96; // 2400
const int CT_LEN = 320 * K + 128; // 1088
// dk 内部偏移
const int OFF_DK_PKE = 0; // ŝ 起点
const int OFF_EK = 384 * K; // ek 起点 = 1152
const int OFF_H_EK = OFF_EK + EK_LEN; // H(ek) 起点 = 2336
const int OFF_Z = OFF_H_EK + 32; // z 起点 = 2368
// ct 内部偏移
const int OFF_U = 0; // u 起点
const int OFF_V = 320 * K; // v 起点 = 960
验证序列化是否正确,最直接的方法是用标准测试向量做往返测试:将给定的 ek、dk、ct 字节串反序列化为内部多项式表示,再重新序列化,比较字节是否完全一致。若往返一致,说明打包位序和偏移都正确;若不一致,应优先检查 12 位打包和 10 位打包的边界处理,尤其是最后一个系数的填充位是否被正确置零。
k 变化,因此实现时应把长度全部写成 k 的函数,避免为每个参数集维护三套硬编码。总结来说,ML-KEM 的数据结构可以归纳为一条清晰的长度公式链:公钥 384k+32,私钥 768k+96,密文 320k+128,共享密钥恒为 32 字节。掌握这些公式与对应的打包位序,就掌握了 ML-KEM 字节级实现的骨架,后续的封装、解封装与测试向量验证都建立在这一基础之上。