3. SM2 密钥对生成:私钥与公钥的数学关系、生成步骤与随机数安全性

密钥对生成,是每个密码算法最基础也最关键的一步。SM2 的密钥对生成,说白了就是「选一个随机数当私钥,然后算一个点当公钥」。听起来简单,但这里面的坑,我踩过不少。

咱们先看数学关系,再看具体步骤,最后聊聊随机数——这个最容易出问题的地方。

3.1 私钥与公钥的数学关系

SM2 基于椭圆曲线密码学。它的核心数学关系就一句话:

公钥 = 私钥 × 基点 G

其中,私钥是一个大整数 d,公钥是椭圆曲线上的一个点 P = dG。

这里的「乘法」不是普通整数乘法,而是椭圆曲线上的标量乘法。G 是 SM2 标准中固定的基点,它的坐标 (Gx, Gy) 在 GB/T 32918.1-2016 里有明确定义。

为什么这个关系安全?因为从公钥 P 反推私钥 d,需要求解椭圆曲线离散对数问题。目前已知的最好算法,对于 SM2 使用的 256 位素数域,计算量大约是 2^128 次操作——嗯,这个量级,用现在的计算机算到宇宙毁灭也算不出来。

我个人习惯把这个关系画成一张图,方便理解:

SM2 密钥对生成核心逻辑 私钥 d 随机整数 ∈ [1, n-2] 基点 G SM2 标准固定参数 标量乘法 P = dG 公钥 P 椭圆曲线上的点 (x, y) 离散对数难题保证 P → d 不可行

3.2 密钥对生成步骤

SM2 密钥对生成,国家标准 GB/T 32918.1 里写得清清楚楚。我把它拆成 5 步,你照着做就行:

  1. 生成随机数 d:在 [1, n-2] 范围内生成一个随机整数。n 是基点 G 的阶,SM2 的 n = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123。
  2. 计算公钥点 P:执行标量乘法 P = dG。这个计算用双倍-加算法,256 位的标量乘法在普通 CPU 上大概几毫秒。
  3. 验证 P 不是无穷远点:如果 P 是无穷远点 O,说明 d 是 n 的倍数——概率极低,但标准要求检查。
  4. 输出密钥对:私钥为 d(256 位整数),公钥为 P = (x, y)(两个 256 位坐标,通常压缩或不压缩编码)。
  5. 销毁临时数据:生成过程中产生的中间变量,尤其是随机数种子,必须安全清除。

代码实现大概是这样的:

// SM2 密钥对生成(伪代码)
function generateSM2KeyPair(): KeyPair {
    // 1. 生成随机私钥
    let d: BigInt
    do {
        d = secureRandomBigInt(256)  // 256 位随机数
    } while (d <= 0 || d >= n - 1)   // 确保在 [1, n-2] 范围内

    // 2. 计算公钥
    let P: Point = scalarMultiply(d, G)  // 标量乘法

    // 3. 验证非无穷远点
    if (P.isInfinity()) {
        throw new Error("生成的公钥为无穷远点,需重新生成")
    }

    // 4. 返回密钥对
    return { privateKey: d, publicKey: P }
}

我的经验:实际项目中,我建议把私钥的合法性检查做完整。除了范围检查,最好再检查 d 是否等于 n-1——虽然概率极低,但我在一次安全审计中见过某实现因为边界条件没处理好,生成了 d = n-1,导致后续签名计算出现特殊分支,被侧信道攻击利用了。

3.3 随机数安全性——最容易翻车的地方

密钥对生成,算法本身没难度。真正的难点在随机数。你想想看,私钥就是一个随机数,如果随机数能被预测,那整个密码体系就形同虚设。

我曾经参与过一个项目的安全复盘。某硬件设备生成的 SM2 私钥,经过分析发现只有 32 位有效熵——也就是说,暴力搜索 40 亿次就能试出私钥。原因是什么?随机数生成器用了未初始化的内存,每次上电产生的「随机数」几乎一样。

随机数安全性,我总结为三个层次:

层次 要求 常见实现 翻车案例
真随机性 不可预测,不可重现 硬件随机数生成器(TRNG) 某路由器用系统时间做种子,私钥可预测
密码学伪随机 统计随机 + 不可反向推导 /dev/urandom、BCryptGenRandom 某 Android App 用 Random() 生成密钥,被暴力破解
熵源管理 足够的熵积累,及时补充 多熵源混合(硬件+系统+用户输入) 嵌入式设备启动初期熵池为空,生成弱密钥

⚠️ 重要警告:永远不要用语言标准库的普通随机数生成器(如 C 的 rand()、Java 的 java.util.Random)生成密钥。这些是伪随机数生成器,种子空间通常只有 32 或 48 位,完全不够安全。必须使用密码学安全的随机数生成器(CSPRNG)。

具体到 SM2 密钥生成,我建议的做法是:

  • 服务端:用操作系统的 CSPRNG,Linux 下读 /dev/urandom,Windows 下用 BCryptGenRandom。别用 /dev/random——它可能阻塞,而且阻塞不代表更安全。
  • 嵌入式设备:如果硬件 TRNG 可用,优先用。但要注意,有些低端 MCU 的 TRNG 质量堪忧,我见过输出有明显周期性的。建议做在线自检,比如连续生成 1000 个 256 位随机数,检查是否有重复。
  • 移动端:iOS 用 SecRandomCopyBytes,Android 用 java.security.SecureRandom。别自己实现随机数生成器——嗯,这个坑我见过太多次了。

3.4 密钥对编码与存储

生成完密钥对,得存起来。SM2 公钥有两种常见编码方式:

编码方式 长度 说明
未压缩 65 字节 0x04 || x || y,x 和 y 各 32 字节
压缩 33 字节 0x02/0x03 || x,根据 y 的奇偶性选择前缀

私钥就是 32 字节的大整数,通常直接存。但要注意,私钥不能明文存储——至少要用硬件安全模块(HSM)或安全飞地保护。如果实在没有硬件支持,也要用口令加密后存储,加密密钥通过 PBKDF2 或 Argon2 派生。

我个人习惯在项目中用 PEM 格式封装密钥对,兼容性好,调试也方便。SM2 的 PEM 格式没有官方标准,但业界通常沿用 ECC 的格式,只是把 OID 换成 SM2 的 OID(1.2.156.10197.1.301)。

3.5 避坑指南

最后,把我这些年踩过的坑列出来,你遇到了能少走弯路:

  • 随机数重复:我曾经在一个多线程服务里,发现两个线程同时调用密钥生成,因为随机数种子共享,生成了相同的私钥。解决方案是每个线程独立初始化 CSPRNG,或者用线程安全的随机数接口。
  • 私钥范围检查遗漏:标准要求 d ∈ [1, n-2],但有些实现只检查 d > 0,漏了 d < n-1。如果 d = n-1,计算出的公钥 P = (n-1)G = -G,虽然不直接泄露私钥,但会导致后续签名算法出现可预测的 r 值。
  • 公钥点验证缺失:生成公钥后,最好验证它确实在曲线上。虽然标量乘法保证结果在曲线上,但如果你的标量乘法实现有 bug(比如我见过一个实现把坐标算溢出导致点不在曲线上),这一步能兜底。
  • 临时变量未清除:私钥在内存中存在过,如果语言有 GC(如 Java、Go),私钥对象可能被移动到内存其他位置,无法彻底清除。建议用 ByteBuffer.allocateDirect() 或 unsafe 手动管理内存,用完后覆写。

密钥对生成,说难不难,说简单也不简单。核心就三点:随机数要真随机、私钥要保护好、实现要按标准来。做到这三点,你的 SM2 密钥对生成就不会出大问题。