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)。密钥生成的核心步骤如下:

  1. 种子扩展:输入 32 字节随机种子 ξ,计算 (ρ, ρ′, K) = SHAKE256(ξ ‖ k ‖ l, 128),得到矩阵种子 ρ(32 字节)、私钥种子 ρ′(64 字节)和 K(32 字节)。
  2. 展开矩阵 A:对每个 (i, j),用 SHAKE128(ρ ‖ j ‖ i) 采样出多项式 Â[i][j] 的系数,系数在 Z_q 上(q = 8380417)。
  3. 采样秘密向量:用 SHAKE256(ρ′ ‖ nonce) 以拒绝采样方式生成 s₁(l 个多项式)和 s₂(k 个多项式),系数范围 [−η, η]。
  4. 计算公钥向量:t = A·s₁ + s₂(在 R_q 上做 NTT 域乘法),然后做 Power2Round 分解 t = t₁·2^d + t₀,丢弃 t₀,只保留高位 t₁。
  5. 打包:公钥 pk = ρ ‖ t₁;私钥 sk = ρ ‖ K ‖ tr ‖ s₁ ‖ s₂,其中 tr = SHAKE256(pk, 64) 是公钥哈希,用于签名时绑定公钥。
注意:t₀ 被丢弃是 ML-DSA 的设计要点——验签方只需 t₁ 即可重构足够精度的高位,这使公钥体积显著小于完整 t。私钥中的 tr 不是冗余字段,签名时它参与 μ 的计算,缺失或错误会导致验签失败。
随机种子 ξ 32 字节 SHAKE256(ξ‖k‖l) 输出 128 字节 ρ 矩阵种子 32B ρ′ 私钥种子 64B K 签名随机化 32B 展开矩阵  (k×l) SHAKE128(ρ‖j‖i) 拒绝采样 s₁(l), s₂(k) 系数 ∈ [−η, η] t = ·s₁ + s₂ Power2Round → t₁, t₀ t₀ 丢弃 公钥 pk ρ (32B) t₁ (k·320B) 私钥 sk ρ K tr(64B) s₁ (l·η) s₂ (k·η) tr = SHAKE256(pk, 64):公钥哈希,签名时参与 μ 计算,绑定公钥与签名上下文。 私钥总长(ML-DSA-65):32 + 32 + 64 + 5·(η 编码) + 6·(η 编码) = 4032 字节;公钥 1952 字节。

二、使用参考实现生成密钥对

教学与联调阶段最稳妥的做法是使用官方参考实现(如 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₁1920k=6 个多项式,每个 320 字节(10 位系数打包)
私钥 skρ32与公钥中的 ρ 相同
K32签名随机化种子
tr64SHAKE256(pk, 64)
s₁480l=5 个多项式,η=4 时每系数 4 位,每多项式 96 字节
s₂576k=6 个多项式,每多项式 96 字节

存储时有三个工程要点。第一,私钥必须加密保存:sk 中包含 s₁、s₂ 和 K,泄露即意味着可伪造签名;建议用 AES-256-GCM 封装,并把 ρ、tr 等非敏感字段与密文分开存放以便快速读取公钥信息。第二,不要截断或重排字段:某些实现会把 tr 放在 sk 末尾,这与标准顺序不同,跨库导入时必须按标准重新排列。第三,做好长度断言:加载时先校验 len(pk)==1952、len(sk)==4032(ML-DSA-65),长度不符直接拒绝,避免把错误数据送入签名流程。

常见错误:把 ML-DSA-44/65/87 的密钥混用。三者长度不同(44 的公钥 1312 字节、私钥 2560 字节;87 的公钥 2592 字节、私钥 4896 字节),若只按“非空”判断而不校验长度,会在验签阶段才暴露问题,且错误信息往往指向签名而非密钥,排查成本高。

最后给出一个可执行的密钥自检清单,用于开发阶段快速定位问题:

  • 用同一颗种子调用确定性密钥生成两次,pk 与 sk 字节完全一致;
  • 从 sk 中按偏移取出 ρ,与 pk 前 32 字节比对相等;
  • 重新计算 tr = SHAKE256(pk, 64),与 sk 中偏移 64 处的 64 字节比对相等;
  • 用生成的密钥对做一次签名—验签往返,确认返回成功;
  • 把 pk 或 sk 任意翻转 1 位后验签,确认返回失败而非崩溃。

这五项检查覆盖了种子确定性、字段一致性、哈希绑定和基本功能正确性,能在集成早期拦住绝大多数密钥层面的问题。