第2章 SM2密钥对生成:从曲线到密钥文件

密钥对生成,是所有密码学操作的第一步。SM2的密钥对生成,说白了就是三个问题:椭圆曲线长什么样、私钥怎么来、公钥怎么算。我见过不少开发者在密钥格式上栽跟头,所以这一章咱们把每个环节都拆开讲透。

2.1 SM2推荐椭圆曲线参数

国密SM2使用一条特定的256位椭圆曲线,参数由国家密码管理局定义。你不需要自己生成曲线,直接用标准参数就行。我个人习惯把这些参数称为“SM2的基因”——它们决定了整个密码系统的数学基础。

SM2推荐曲线的参数如下:

参数 含义 值(十六进制)
p 素数模数 FFFFFFFE FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF 00000000 FFFFFFFF FFFFFFFF
a 曲线系数 FFFFFFFE FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF 00000000 FFFFFFFF FFFFFFFC
b 曲线系数 28E9FA9E 9D9F5E34 4D5A9E4B CF6509A7 F39789F5 15AB8F92 DDBCBD41 4D940E93
Gx 基点x坐标 32C4AE2C 1F198119 5F990446 6A39C994 8FE30BBF F2660BE1 715A4589 334C74C7
Gy 基点y坐标 BC3736A2 F4F6779C 59BDCEE3 6B692153 D0A9877C C62A4740 02DF32E5 2139F0A0
n 基点阶 FFFFFFFE FFFFFFFF FFFFFFFF FFFFFFFF 7203DF6B 21C6052B 53BBF409 39D54123
小提示: 这些参数在OpenSSL 1.1.1+和GmSSL中已经内置。你只需要调用 EC_KEY_new_by_curve_name(NID_sm2) 就能拿到完整的曲线上下文。我早期做移植时手写过参数表,结果把b参数抄错了一位,调试了整整两天——所以能用API就别手写。

2.2 私钥生成

SM2私钥就是一个随机的大整数,取值范围在 [1, n-2] 之间。n是基点阶,大约2^256量级。生成过程其实很简单:

  1. 从安全随机数发生器获取256位随机字节
  2. 将字节转为大整数
  3. 检查是否在有效范围内,不在则重新生成

代码实现如下(C语言,基于OpenSSL):

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

EC_KEY *generate_sm2_keypair(void) {
    EC_KEY *key = EC_KEY_new_by_curve_name(NID_sm2);
    if (!key) return NULL;

    // 生成密钥对(内部包含私钥生成和公钥推导)
    if (!EC_KEY_generate_key(key)) {
        EC_KEY_free(key);
        return NULL;
    }

    return key;
}
⚠ 安全警告: 千万不要用 rand() 或 random() 生成私钥!我曾经审计过一个项目,他们用C标准库的rand()生成密钥,结果私钥只有32位的熵——用普通笔记本跑几分钟就能暴力破解。必须使用 /dev/urandom、RAND_bytes() 或硬件安全模块。

2.3 公钥推导

公钥 = 私钥 × 基点G。这是椭圆曲线上的标量乘法,说白了就是私钥个G点相加。虽然概念简单,但计算过程涉及模逆、点加、倍点等复杂运算。

SM2公钥有两种表示形式:

  • 非压缩格式: 04 || x坐标(32字节) || y坐标(32字节),共65字节
  • 压缩格式: 02/03 || x坐标(32字节),共33字节。y坐标通过曲线方程恢复

我个人建议在存储和传输时使用非压缩格式。虽然多32字节,但省去了恢复y坐标的计算,而且兼容性更好。压缩格式在带宽受限的场景下才有优势。

公钥推导的底层实现:

// 手动推导公钥(仅用于理解原理,实际请用EC_KEY_generate_key)
int derive_public_key(const BIGNUM *private_key, EC_POINT *public_key,
                      const EC_GROUP *group, BN_CTX *ctx) {
    // 公钥 = 私钥 * G
    return EC_POINT_mul(group, public_key, private_key, 
                        NULL, NULL, ctx);
}

2.4 密钥格式:PEM / DER / RAW

密钥生成出来是一堆内存中的结构体,要持久化就必须序列化。SM2密钥有三种常见格式,我按使用频率排序讲:

格式 特点 典型场景 文件大小
PEM Base64编码,可读,带头部标识 证书、配置文件、交换密钥 ~300字节(私钥)
DER 二进制ASN.1编码,紧凑 嵌入式系统、硬件安全模块 ~140字节(私钥)
RAW 纯字节,无结构 自定义协议、高性能场景 32字节(私钥)

2.4.1 PEM格式

PEM是最常用的格式。它把DER二进制数据用Base64编码,加上头尾标识行。SM2私钥的PEM文件长这样:

-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqBHM9VAYItBG0wawIBAQQgF8e7G0v3XgL4G5pZ
...(省略中间行)...
-----END PRIVATE KEY-----

生成PEM格式的代码:

// 私钥写入PEM文件
int write_private_key_pem(EC_KEY *key, const char *filename) {
    FILE *fp = fopen(filename, "wb");
    if (!fp) return 0;

    int ret = PEM_write_ECPrivateKey(fp, key, NULL, NULL, 0, NULL, NULL);
    fclose(fp);
    return ret;
}

// 公钥写入PEM文件
int write_public_key_pem(EC_KEY *key, const char *filename) {
    FILE *fp = fopen(filename, "wb");
    if (!fp) return 0;

    int ret = PEM_write_EC_PUBKEY(fp, key);
    fclose(fp);
    return ret;
}

2.4.2 DER格式

DER是PEM的二进制前身。如果你需要最小化存储空间,或者对接硬件安全模块,DER是更好的选择。我记得有个项目要求密钥必须存储在SE芯片中,DER格式正好满足128字节的存储限制。

// 私钥转DER
int private_key_to_der(EC_KEY *key, unsigned char **out, int *out_len) {
    *out_len = i2d_ECPrivateKey(key, out);
    return (*out_len > 0) ? 1 : 0;
}

// 公钥转DER
int public_key_to_der(EC_KEY *key, unsigned char **out, int *out_len) {
    *out_len = i2d_EC_PUBKEY(key, out);
    return (*out_len > 0) ? 1 : 0;
}

2.4.3 RAW格式

RAW格式就是纯字节,没有任何包装。私钥直接取32字节的大整数,公钥取65字节的非压缩点。这种格式在自定义协议中很常见,但要注意:

  • 没有类型标识,接收方必须知道这是SM2密钥
  • 没有校验机制,数据损坏无法检测
  • 私钥必须配合曲线参数使用
实战建议: 我一般这样选格式——对外接口用PEM,方便调试和交换;内部存储用DER,节省空间;高性能路径用RAW,减少解析开销。但无论哪种格式,私钥的存储介质必须加密,至少也要做文件权限控制。

2.5 密钥对生成全流程

下面这张图把整个密钥对生成流程串起来了。你想想看,从曲线参数到最终的文件,每一步都有明确的输入输出。

SM2密钥对生成全流程 SM2推荐曲线参数 安全随机数发生器 私钥 d ∈ [1, n-2] 公钥 P = d × G 选择输出格式 PEM 格式 DER 格式 RAW 格式 关键检查点 私钥范围校验 公钥在曲线上验证
我的经验: 生成密钥对后,一定要做一次自检——用私钥签名一段数据,再用公钥验签。这一步能发现90%的密钥生成问题。我曾经在移植国密算法到ARM平台时,发现公钥推导结果总是不对,最后定位到是BN_CTX没有正确初始化。自检流程帮我省下了至少一周的调试时间。

2.6 避坑指南

最后,我把自己踩过的坑和见过的问题列出来,你写代码时对照着检查:

  • 随机数质量: 嵌入式设备上/dev/random可能阻塞,用/dev/urandom或硬件TRNG。我见过某路由器用芯片内置的伪随机数生成器,私钥熵不足导致被远程破解。
  • 大端小端: SM2参数是大端序。如果你从单片机读取私钥,注意字节序转换。有一次合作方发来的私钥全是反的,就是因为小端序没处理。
  • 密钥版本: PEM文件头部包含OID信息。SM2的OID是1.2.156.10197.1.301,如果解析出来不对,检查OpenSSL版本是否支持SM2。
  • 内存清理: 私钥使用完毕后,用OPENSSL_cleanse()或memset_s()清零。别用free()了事,私钥数据可能残留在堆内存中。
  • 并发安全: EC_KEY结构体不是线程安全的。多线程环境下,每个线程使用独立的EC_KEY实例,或者加锁保护。

嗯,密钥对生成这部分就讲到这里。你把这些概念和代码吃透了,后面签名验签的章节就会轻松很多。记住:密钥安全是整个密码系统的基石,生成这一步马虎不得。