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 次操作——嗯,这个量级,用现在的计算机算到宇宙毁灭也算不出来。
我个人习惯把这个关系画成一张图,方便理解:
3.2 密钥对生成步骤
SM2 密钥对生成,国家标准 GB/T 32918.1 里写得清清楚楚。我把它拆成 5 步,你照着做就行:
- 生成随机数 d:在 [1, n-2] 范围内生成一个随机整数。n 是基点 G 的阶,SM2 的 n = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123。
- 计算公钥点 P:执行标量乘法 P = dG。这个计算用双倍-加算法,256 位的标量乘法在普通 CPU 上大概几毫秒。
- 验证 P 不是无穷远点:如果 P 是无穷远点 O,说明 d 是 n 的倍数——概率极低,但标准要求检查。
- 输出密钥对:私钥为 d(256 位整数),公钥为 P = (x, y)(两个 256 位坐标,通常压缩或不压缩编码)。
- 销毁临时数据:生成过程中产生的中间变量,尤其是随机数种子,必须安全清除。
代码实现大概是这样的:
// 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 密钥对生成就不会出大问题。