3. ML-DSA密钥生成实现
ML-DSA(基于模格的数字签名算法,FIPS 204 标准)的密钥生成并不是“随机取两个大数”那么简单。它需要从一个 32 字节的随机种子出发,经过可扩展哈希(SHAKE)的确定性扩展,生成矩阵 A、秘密向量 s₁ 和 s₂,再通过拒绝采样保证系数落在合法范围,最终把公钥 t 与私钥元素打包成标准格式。理解这条链路,是后续签名、验签和互操作测试的前提。
一、密钥生成算法的内部结构
以 ML-DSA-65 参数集为例(k=6,l=5,η=4,τ=49,γ₁=2¹⁹,γ₂=2¹⁹−256)。密钥生成的核心步骤如下:
- 种子扩展:输入 32 字节随机种子 ξ,计算 (ρ, ρ′, K) = SHAKE256(ξ ‖ k ‖ l, 128),得到矩阵种子 ρ(32 字节)、私钥种子 ρ′(64 字节)和 K(32 字节)。
- 展开矩阵 A:对每个 (i, j),用 SHAKE128(ρ ‖ j ‖ i) 采样出多项式 Â[i][j] 的系数,系数在 Z_q 上(q = 8380417)。
- 采样秘密向量:用 SHAKE256(ρ′ ‖ nonce) 以拒绝采样方式生成 s₁(l 个多项式)和 s₂(k 个多项式),系数范围 [−η, η]。
- 计算公钥向量:t = A·s₁ + s₂(在 R_q 上做 NTT 域乘法),然后做 Power2Round 分解 t = t₁·2^d + t₀,丢弃 t₀,只保留高位 t₁。
- 打包:公钥 pk = ρ ‖ t₁;私钥 sk = ρ ‖ K ‖ tr ‖ s₁ ‖ s₂,其中 tr = SHAKE256(pk, 64) 是公钥哈希,用于签名时绑定公钥。
二、使用参考实现生成密钥对
教学与联调阶段最稳妥的做法是使用官方参考实现(如 PQClean 中的 crypto_sign/ml-dsa-65)或 liboqs。下面以 liboqs 的 C 接口为例,展示一次完整的密钥生成与长度校验:
#include <oqs/oqs.h>
#include <stdio.h>
int main(void) {
OQS_SIG *sig = OQS_SIG_new(OQS_SIG_alg_ml_dsa_65);
if (sig == NULL) { printf("算法不可用\n"); return 1; }
uint8_t pk[OQS_SIG_ml_dsa_65_length_public_key]; // 1952
uint8_t sk[OQS_SIG_ml_dsa_65_length_secret_key]; // 4032
uint8_t seed[32];
OQS_randombytes(seed, sizeof(seed)); // 系统 CSPRNG
if (OQS_SIG_keypair_derand(sig, pk, sk, seed) != OQS_SUCCESS) {
printf("密钥生成失败\n"); return 1;
}
printf("pk=%zu sk=%zu\n", sig->length_public_key, sig->length_secret_key);
OQS_SIG_free(sig);
return 0;
}
这里刻意使用 keypair_derand(确定性版本)并显式传入种子,而不是直接调用 keypair。原因是:确定性接口让同一颗种子必然产生同一对密钥,便于在测试中复现问题、比对不同实现的输出是否一致。生产环境则应使用系统 CSPRNG 生成种子,并确保种子在使用后立即从内存中清除。
如果使用 Python 的 dilithium-py 或 pqcrypto 包,调用更简洁,但要注意不同库对“种子”与“完整私钥”的暴露程度不同。例如某些封装只返回打包后的 sk 字节串,不提供内部 ρ′,这时就无法做种子级复现,只能做字节级比对。
三、密钥格式解析与存储
ML-DSA 的密钥是纯字节串,没有 ASN.1 包装。解析时必须严格按 FIPS 204 的字段顺序和位宽进行,任何偏移错误都会导致后续签名失败。以 ML-DSA-65 为例:
| 对象 | 字段 | 长度(字节) | 说明 |
|---|---|---|---|
| 公钥 pk | ρ | 32 | 矩阵种子 |
| t₁ | 1920 | k=6 个多项式,每个 320 字节(10 位系数打包) | |
| 私钥 sk | ρ | 32 | 与公钥中的 ρ 相同 |
| K | 32 | 签名随机化种子 | |
| tr | 64 | SHAKE256(pk, 64) | |
| s₁ | 480 | l=5 个多项式,η=4 时每系数 4 位,每多项式 96 字节 | |
| s₂ | 576 | k=6 个多项式,每多项式 96 字节 |
存储时有三个工程要点。第一,私钥必须加密保存:sk 中包含 s₁、s₂ 和 K,泄露即意味着可伪造签名;建议用 AES-256-GCM 封装,并把 ρ、tr 等非敏感字段与密文分开存放以便快速读取公钥信息。第二,不要截断或重排字段:某些实现会把 tr 放在 sk 末尾,这与标准顺序不同,跨库导入时必须按标准重新排列。第三,做好长度断言:加载时先校验 len(pk)==1952、len(sk)==4032(ML-DSA-65),长度不符直接拒绝,避免把错误数据送入签名流程。
最后给出一个可执行的密钥自检清单,用于开发阶段快速定位问题:
- 用同一颗种子调用确定性密钥生成两次,pk 与 sk 字节完全一致;
- 从 sk 中按偏移取出 ρ,与 pk 前 32 字节比对相等;
- 重新计算 tr = SHAKE256(pk, 64),与 sk 中偏移 64 处的 64 字节比对相等;
- 用生成的密钥对做一次签名—验签往返,确认返回成功;
- 把 pk 或 sk 任意翻转 1 位后验签,确认返回失败而非崩溃。
这五项检查覆盖了种子确定性、字段一致性、哈希绑定和基本功能正确性,能在集成早期拦住绝大多数密钥层面的问题。