3. SM2 密钥对生成:私钥与公钥的数学关系、密钥生成算法步骤、密钥格式(PEM/DER)与编码规范

好,我们直接进入正题。SM2 密钥对,说白了就是一对数学上有血缘关系的数字。一个藏起来自己用,叫私钥;另一个公开出去,叫公钥。它们之间不是随便猜的,背后是椭圆曲线上的点乘运算。我当年刚接触国密时,第一反应是「这不就是 ECDSA 换了个曲线吗?」——嗯,后来发现细节上坑不少,咱们今天一个个说清楚。

3.1 私钥与公钥的数学关系

SM2 的私钥是一个大整数,记作 d。公钥则是椭圆曲线上的一个点,记作 P。它们的关系非常简单:

P = d * G

其中 G 是 SM2 标准曲线上的基点,d 是私钥,* 表示椭圆曲线上的标量乘法。

这里有个关键点:从 d 算 P 很容易,但从 P 反推 d 几乎不可能——这就是椭圆曲线离散对数难题。我在项目中经常跟团队说:「私钥就是你的密码,公钥是你的账号,但账号不能反推密码,这就是安全根基。」

SM2 使用的曲线参数由国密标准 GM/T 0003-2012 定义,包括:

  • p:256 位素数,定义有限域
  • a, b:曲线方程 y² = x³ + ax + b 的系数
  • G:基点,坐标为 (Gx, Gy)
  • n:基点的阶,素数

私钥 d 的取值范围是 [1, n-2]。为什么不能取 0 或 n-1?嗯,你想想看,如果 d=0,公钥就是无穷远点,没法用;如果 d=n-1,其实等价于 -G,虽然数学上合法,但标准里明确要求避开边界值。我曾经在测试环境里踩过这个坑,生成了一批 d=1 的密钥对,结果被合规审计打回来了……

3.2 密钥生成算法步骤

SM2 密钥生成的流程,我习惯把它拆成 5 步。每一步都有它的道理,咱们过一遍:

  1. 获取随机数:使用密码学安全的随机数生成器(CSPRNG),产生一个 256 位的随机字节串。注意,不能用 rand() 或 Math.random(),那些不够安全。我在项目中见过有人用 Java 的 Random 类生成私钥,结果被安全扫描直接判了高危。
  2. 转换为整数:将随机字节串转为大整数,记为 k。
  3. 范围检查:检查 k 是否在 [1, n-2] 范围内。如果不在,回到第 1 步重新生成。
  4. 计算公钥:执行椭圆曲线标量乘法 P = k * G。这一步计算量最大,但现代 CPU 和密码库(如 OpenSSL、GmSSL)都做了优化,毫秒级完成。
  5. 输出密钥对:私钥为 d = k,公钥为点 P(包含 x 和 y 坐标,各 32 字节)。

整个流程可以用下面这张图来理解:

SM2 密钥对生成流程 ① CSPRNG 生成随机数 ② 字节串转大整数 k ③ 范围检查 k ∈ [1, n-2]? ④ P = k * G ⑤ 输出 (d, P) 不通过

我的建议:生产环境中不要自己实现标量乘法。直接用 GmSSL、OpenSSL 1.1.1+ 或 Bouncy Castle 的 SM2 实现。自己写?我试过,性能差不说,还容易引入侧信道漏洞。

3.3 密钥格式与编码规范

密钥生成出来是一堆二进制数据,但实际使用中需要标准化编码。SM2 密钥主要有两种格式:DER(二进制)和 PEM(文本)。

3.3.1 DER 编码

DER(Distinguished Encoding Rules)是 ASN.1 的一种二进制编码方式。SM2 私钥的 DER 结构长这样:

PrivateKeyInfo ::= SEQUENCE {
  version         INTEGER,        -- 固定为 1
  privateKey      OCTET STRING,   -- 32 字节私钥
  parameters      [0] EXPLICIT OID, -- SM2 曲线 OID: 1.2.156.10197.1.301
  publicKey       [1] EXPLICIT BIT STRING OPTIONAL -- 公钥(可选)
}

公钥的 DER 结构更简单:

SubjectPublicKeyInfo ::= SEQUENCE {
  algorithm       AlgorithmIdentifier,  -- 包含 SM2 OID
  subjectPublicKey BIT STRING           -- 04 || x || y(65 字节)
}

公钥的 BIT STRING 部分,第一个字节是 04(表示未压缩格式),后面跟 32 字节的 x 坐标和 32 字节的 y 坐标。为什么是 65 字节?04 + 32 + 32 = 65。我见过有人把 04 漏掉,结果解析出来坐标全乱了。

3.3.2 PEM 编码

PEM 就是在 DER 基础上加了个 Base64 的外壳,再加头尾标识。私钥 PEM 示例:

-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqBHM9VAYItBG0wawIBAQQg...
-----END PRIVATE KEY-----

公钥 PEM 示例:

-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAE...
-----END PUBLIC KEY-----

这里有个容易混淆的地方:SM2 的 PEM 头是 BEGIN PRIVATE KEY 还是 BEGIN EC PRIVATE KEY?答案是 BEGIN PRIVATE KEY(PKCS#8 格式)。BEGIN EC PRIVATE KEY 是 SEC 1 格式,SM2 标准里不推荐。我曾经在对接某厂商的硬件加密机时,对方坚持要 SEC 1 格式,折腾了两天才发现是文档写错了……

3.3.3 格式对比

特性 DER PEM
编码方式 二进制 Base64 文本
可读性 差 好(可复制粘贴)
文件大小 小(私钥约 121 字节) 比 DER 大约 33%
传输方式 适合二进制协议 适合文本协议(JSON、YAML)
标准依据 X.690 RFC 7468

注意:SM2 的 OID 是 1.2.156.10197.1.301,不是 1.2.840.10045.2.1(那是 ECDSA 的)。如果你在解析 PEM 时发现 OID 不对,八成是库没配好国密参数。我遇到过好几次,OpenSSL 默认只认 NIST 曲线,需要手动加载 SM2 的 OID 映射。

3.4 实战:用 GmSSL 生成 SM2 密钥对

理论说完了,咱们动手试试。GmSSL 是国密官方推荐的开源库,用法跟 OpenSSL 很像:

# 生成 SM2 私钥(PEM 格式)
gmssl ecparam -genkey -name sm2p256v1 -out sm2_private.pem

# 从私钥导出公钥
gmssl ec -in sm2_private.pem -pubout -out sm2_public.pem

# 查看私钥详情
gmssl ec -in sm2_private.pem -text -noout

执行后你会看到类似这样的输出:

read EC key
Private Key: (256 bit)
priv:
    c9:8f:2a:3b:...(32 字节十六进制)
pub:
    04:1e:3a:5c:...(65 字节十六进制)
Field Type: prime-field
Prime:
    00:ff:ff:ff:fe:ff:ff:ff:...
A:
    ff:ff:ff:fe:ff:ff:ff:...
B:
    28:e9:fa:9e:9d:9f:5e:...
Generator (uncompressed):
    04:32:c4:ae:2c:1f:19:81:...
Order:
    00:ff:ff:ff:fe:ff:ff:ff:...
Cofactor: 1

注意看 priv 和 pub 的值。私钥是 32 字节,公钥是 65 字节(04 + 32 + 32)。这就是咱们前面讲的数学关系——公钥就是私钥乘以基点 G 的结果。

避坑指南:我曾经在生产环境遇到过一个问题——用 GmSSL 生成的密钥对,在 Java 的 Bouncy Castle 里解析失败。后来发现是 GmSSL 默认输出的私钥是 PKCS#8 格式,而 Bouncy Castle 的 SM2 解析器需要显式指定曲线参数。解决方案是在 Java 代码里手动设置 SM2ParameterSpec。细节咱们后面章节会展开。

3.5 小结

这一章咱们把 SM2 密钥对的核心概念捋了一遍。私钥是随机整数,公钥是椭圆曲线上的点,两者通过标量乘法关联。生成流程不复杂,但随机数质量和范围检查不能马虎。密钥格式方面,DER 是二进制标准,PEM 是文本包装,两者本质相同,只是应用场景不同。