3. SM2 密钥对生成:从随机数到编码格式

这一章我们聊聊 SM2 密钥对的生成。说白了就是两件事:怎么安全地产生私钥,以及怎么从私钥算出公钥。最后还得看懂密钥的三种“长相”——16 进制、DER、PEM。我面试别人时最爱问的一个细节就是:“公钥计算用椭圆曲线乘法,你知道具体怎么算的吗?” 很多人能答出公式,但一到编码格式就懵了。

SM2密钥对生成流程 SM2 密钥对生成流程 随机数生成器 (CSRNG / 真随机) 私钥 k 1 ~ n-1 整数 椭圆曲线参数 (a, b, G, n, p) 公钥 K = k × G 椭圆曲线点乘 16 进制串 Hex: 04||x||y (130 字符) DER 编码 ASN.1 二进制结构 PEM 编码 Base64 封装 DER 注意:私钥本身也有三种编码,但面试中更常问公钥格式

3.1 私钥的随机生成

SM2 私钥就是一个 位于 [1, n-1] 范围内的随机整数。其中 n 是 SM2 椭圆曲线的阶(一个约 2^256 的素数)。我当年第一次看标准时觉得这太简单了——不就是 random() 吗?后来吃了一次亏才明白:这里的“随机”必须用密码学安全随机数生成器(CSRNG),不能拿 Python 的 random.randint 来搞。

千万注意:Linux 上优先用 /dev/urandom 或 OpenSSL 的 RAND_bytes。我曾经见过有人用 rand()%n 生成私钥,结果因为模数偏差导致私钥分布不均匀,被攻击者用数学方法猜出了密钥。这在生产环境不能出现。

生成私钥的步骤很简单:

  1. 通过 CSRNG 产生 32 字节(256 位)的随机字节序列。
  2. 将字节序列转为大整数 k。
  3. 检查 k 是否在 [1, n-1] 范围内。如果不在,重新生成。
  4. 如果 k==0 或 k>=n,丢弃重来。

这里有个效率窍门:SM2 的 n 非常接近 2^256,所以随机 32 字节落在有效区间的概率极高(约 99.9%),基本一次就过。我习惯在代码里加一个循环上限,防止随机数生成器出 Bug 死循环。

看一段 C++ 示例:

#include <openssl/obj_mac.h>
#include <openssl/ec.h>
#include <openssl/bn.h>

// 生成 SM2 私钥(OpenSSL 3.x)
EC_KEY *key = EC_KEY_new_by_curve_name(NID_sm2);
if (!key) {
    // 错误处理
    return nullptr;
}
if (1 != EC_KEY_generate_key(key)) {
    // 生成失败
    EC_KEY_free(key);
    return nullptr;
}
const BIGNUM *priv = EC_KEY_get0_private_key(key);
// priv 就是私钥,可直接用 BN_bn2hex 转为 16 进制
个人习惯:在生成私钥后,我会立即把 hex 串输出到安全日志用于调试,但上线前会关掉这个输出。另外,SM2 私钥千万要显式擦除,不能直接 free 就完事——我建议用 OPENSSL_cleanse 清零后再释放内存。

3.2 公钥的计算过程

公钥 K 就是椭圆曲线上的一个点,公式为 K = k × G。k 是私钥,G 是 SM2 标准定义的基点(一个固定坐标点)。这里的乘法不是普通整数乘法,而是椭圆曲线上的标量乘法。

说白了,就是“把 G 点自己加 k 次”。但 k 是 256 位的大数,不能真加 2^256 次——计算复杂度必须用双倍-加算法(Double-and-Add)降到 O(log k)。

我举一个简单例子让你快速理解算法流程:

步骤二进制表示 k 的每一位操作
初始化—结果 Q = O(无穷远点)
从最高位到最低位1Q = 2Q(双倍); Q = Q + G(加)
下一位0Q = 2Q(只双倍,不加)
下一位1Q = 2Q; Q = Q + G

实际实现时还要考虑椭圆曲线点加法中的模运算和逆元计算。OpenSSL 已经封装好了,你直接调 EC_POINT_mul 就行:

// 基于上面生成的 key,公钥已经在 key 对象中
const EC_POINT *pub = EC_KEY_get0_public_key(key);
// 或者自己算:EC_POINT_mul(group, pub, priv, NULL, NULL, ctx);
面试高频考点:SM2 公钥通常使用“压缩/非压缩”两种格式。非压缩格式就是前缀 04 加上 x 坐标(32 字节)和 y 坐标(32 字节)。压缩格式用 02(y 为偶数)或 03(y 为奇数)开头,只存储 x 坐标,y 坐标通过椭圆曲线方程还原。你面试时如果能说出压缩格式的优缺点(节省带宽但需要计算),会加分不少。

3.3 密钥格式:16进制、DER、PEM

生成完私钥和公钥后,我们要把它存到文件里或者传给对方。常见的三种格式我分别讲下。

3.3.1 16 进制串(Hex)

最直观的表示方式。私钥 Hex:直接把 256 位的整数转为 64 个十六进制字符。公钥 Hex:对于非压缩公钥,就是 04 + 64 字节 x 坐标 + 64 字节 y 坐标,总共 130 个十六进制字符。

私钥 Hex: c9a7b3d8f1e6...(64 字符)
公钥 Hex(非压缩): 04b1c2d3e4f5...(130 字符)

我一般在开发和调试阶段用 Hex 格式,因为人眼可读,直接粘贴方便。但不能在生产配置文件里用纯文本存私钥——除非你真的很想被安全审计师“问候”。

3.3.2 DER 编码

DER 是 ASN.1 的一种二进制编码,更紧凑、更标准。私钥 DER 包含版本号、私钥值、曲线标识、公钥值等字段。公钥 DER 通常是一个 SEQUENCE 包含算法标识和 BIT STRING。

举个例子,用 OpenSSL 导出 DER 格式的命令:

# 导出私钥 DER
openssl ec -in sm2_private.pem -outform DER -out sm2_private.der

# 导出公钥 DER
openssl ec -in sm2_private.pem -pubout -outform DER -out sm2_public.der
踩坑经验:不同厂商的 SM2 DER 实现可能在 OID 上不一致。国标规定的 SM2 曲线 OID 是 1.2.156.10197.1.301,但有些旧库用的是 1.2.156.10197.1.101。我曾因为这个问题导致两个系统间证书验证失败,排查了半天。建议生成后先用 openssl asn1parse 看一眼结构。

3.3.3 PEM 编码

PEM 就是 DER 的 Base64 封装,加上头尾标记行。这是最通用的交换格式,兼容 OpenSSL、Java、Python 等几乎所有密码库。

-----BEGIN EC PRIVATE KEY-----
MIGkAgEBBDD...
-----END EC PRIVATE KEY-----

-----BEGIN PUBLIC KEY-----
MFYwEAYHKoZIzj0CAQYFK4EE...
-----END PUBLIC KEY-----

我自己的习惯是:开发环境用 Hex,测试环境用 DER,生产环境只用 PEM。PEM 文件可以加密码保护,而且绝大多数工具都能解析。

最后总结三种格式的对比:

格式可读性标准程度文件大小适用场景
16 进制高非标准(自己约定)最大(字符翻倍)调试、日志
DER低(二进制)ASN.1 国际标准最小(约 120 字节)嵌入式、硬件模块
PEM中(Base64)RFC 7468 标准中等(比 DER 大 33%)证书、密钥文件、网络传输
小技巧:面试时被问“SM2 公钥的 PEM 头和 RSA 有什么不同?”你可以回答——RSA 公钥头是 -----BEGIN PUBLIC KEY-----,SM2 也是同样的头(因为公钥算法标识在 OID 里区分),但私钥头不同:SM2 用 -----BEGIN EC PRIVATE KEY-----,RSA 用 -----BEGIN RSA PRIVATE KEY-----。手写一行命令就能验证:openssl ec -pubin -in sm2_pub.pem -text -noout。