第4章 SM2密钥对生成:私钥的随机生成、公钥的计算与密钥存储

密钥对生成,是所有密码学操作的起点。你想想看,如果私钥生成得不够随机,或者公钥计算出了偏差,那后面所有的签名、加密都是空中楼阁。我在做国密合规项目时,第一步就是检查密钥生成模块的实现,这里出问题,后面全白搭。

SM2的密钥对包含两部分:一个随机生成的私钥,以及通过椭圆曲线标量乘法计算出的公钥。说白了,私钥就是一个大整数,公钥就是椭圆曲线上的一个点。今天我们就把它彻底讲透。

4.1 私钥的随机生成

私钥本质上是一个满足 1 ≤ d ≤ n-2 的随机整数,其中 n 是SM2椭圆曲线的阶(一个约 2^256 的大素数)。

核心要求:私钥必须由高质量的随机数生成器产生,不能使用伪随机或可预测的种子。

我个人习惯使用操作系统提供的 CSPRNG(密码学安全伪随机数生成器)。在 Linux 上读 /dev/urandom,在 Windows 上用 CryptGenRandom。千万别用 rand() 或 Math.random(),那是给自己挖坑。

生成步骤其实很简单:

  1. 从随机源获取 32 字节(256 位)的随机字节序列
  2. 将其转换为大整数 k
  3. 检查 k 是否在有效范围 [1, n-2] 内
  4. 如果不在范围内,重新生成

小技巧:SM2 的私钥范围是 [1, n-2],而不是 [1, n-1]。这是因为公钥计算中有一个特殊情况需要排除。虽然概率极低(约 2^-256),但标准就是这么规定的。

我曾经遇到过一个案例:某厂商直接用 SHA256("password") 作为私钥。嗯,这相当于把家门钥匙挂在门口。私钥必须真随机,任何确定性推导都是不安全的。

4.2 公钥的计算:标量乘法

公钥的计算公式很简单:

P = d × G

其中 d 是私钥,G 是 SM2 椭圆曲线的基点,× 是椭圆曲线上的标量乘法。说白了,就是把基点 G 自己加自己 d 次。

但你不能真的加 d 次——d 是个 256 位的数,循环 2^256 次,宇宙毁灭了都算不完。实际用的是「双倍-加」算法(Double-and-Add),复杂度只有 O(log d)。

4.2.1 双倍-加算法原理

这个算法的思路很简单:把私钥 d 写成二进制,然后从左到右扫描每一位。

  • 遇到 0:只做点倍(Double)
  • 遇到 1:先做点倍,再做点加(Add)

举个例子,假设 d = 13(二进制 1101):

步骤 位 操作 中间结果
1 1(最高位) 初始化 Q = G Q = G
2 1 Q = 2Q + G Q = 3G
3 0 Q = 2Q Q = 6G
4 1 Q = 2Q + G Q = 13G ✓

你看,只用了 4 步就完成了。对于 256 位的私钥,最多只需要 256 次点倍和 256 次点加,现代 CPU 上毫秒级完成。

4.2.2 代码实现示例

下面是一个简化的 Python 实现,展示核心逻辑:

def sm2_pubkey_generate(private_key: int, Gx: int, Gy: int, p: int, a: int) -> tuple:
    """
    使用双倍-加算法计算公钥 P = d * G
    返回公钥坐标 (Px, Py)
    """
    def point_add(P, Q):
        """椭圆曲线点加法"""
        if P is None:
            return Q
        if Q is None:
            return P
        x1, y1 = P
        x2, y2 = Q
        if x1 == x2 and y1 != y2:
            return None  # 无穷远点
        if x1 == x2:
            # 点倍
            lam = (3 * x1 * x1 + a) * pow(2 * y1, -1, p) % p
        else:
            # 点加
            lam = (y2 - y1) * pow(x2 - x1, -1, p) % p
        x3 = (lam * lam - x1 - x2) % p
        y3 = (lam * (x1 - x3) - y1) % p
        return (x3, y3)

    # 双倍-加算法
    Q = None
    G = (Gx, Gy)
    d = private_key
    while d > 0:
        if d & 1:
            Q = point_add(Q, G)
        G = point_add(G, G)
        d >>= 1
    return Q

注意:上面的代码是教学演示,生产环境请使用经过验证的密码学库(如 GmSSL、OpenSSL 1.1.1+)。自己实现椭圆曲线运算,很容易踩到侧信道攻击的坑。

4.3 密钥的格式与存储

密钥生成出来,总得有个地方放。SM2 密钥的格式,我建议直接遵循国家标准 GM/T 0009-2012。

4.3.1 私钥格式

SM2 私钥就是一个 32 字节的大整数。存储时通常有两种格式:

  • 原始格式:32 字节的大端序整数,直接存二进制文件
  • DER 编码:使用 ASN.1 结构封装,包含算法标识和密钥数据

我个人更推荐 DER 编码,因为它自描述,不会搞混算法类型。PKCS#8 格式就是典型代表:

PrivateKeyInfo ::= SEQUENCE {
  version         INTEGER,
  algorithm       AlgorithmIdentifier,
  privateKey      OCTET STRING
}

AlgorithmIdentifier ::= SEQUENCE {
  algorithm       OBJECT IDENTIFIER,  -- SM2 的 OID: 1.2.156.10197.1.301
  parameters      ANY DEFINED BY algorithm OPTIONAL
}

4.3.2 公钥格式

公钥是椭圆曲线上的一个点,包含 x 和 y 坐标,各 32 字节。常见格式:

格式 长度 说明
未压缩 65 字节 0x04 || x || y(最常用)
压缩 33 字节 0x02/0x03 || x(通过 x 恢复 y)
混合 65 字节 0x06/0x07 || x || y(带压缩标识)

实际项目中,我几乎只用未压缩格式。虽然多 32 字节,但省去了恢复 y 坐标的计算,而且兼容性最好。

4.3.3 安全存储建议

避坑指南:我曾经见过一个项目,把私钥明文存在数据库的 "config" 表里,字段名叫 "sm2_private_key",连个加密都没有。这相当于把保险柜密码贴在保险柜门上。

安全存储的几个原则:

  • 私钥必须加密存储:使用对称加密(如 SM4)加密后再持久化
  • 密钥与数据分离:加密密钥和密文不要放在同一个地方
  • 使用 HSM 或 TEE:高安全场景,私钥永远不离开硬件安全模块
  • 内存中及时擦除:用完后主动清零,防止内存转储泄露

4.4 本章知识体系

下面这张图,把密钥对生成的整个流程串起来了:

SM2 密钥对生成流程 CSPRNG 随机数生成器 私钥范围检查:1 ≤ d ≤ n-2 标量乘法:P = d × G 公钥坐标 (x, y) 输出 密钥格式编码(DER / PEM / 原始) 私钥:32字节随机整数 → 公钥:椭圆曲线点(65字节未压缩)

从随机数生成开始,经过范围检查、标量乘法,最后编码存储。每一步都有坑,每一步都要小心。

核心总结:SM2 密钥对生成,本质上就是「随机数 + 标量乘法」。随机数决定安全性,标量乘法决定正确性,格式存储决定可用性。三者缺一不可。