第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 |
EC_KEY_new_by_curve_name(NID_sm2) 就能拿到完整的曲线上下文。我早期做移植时手写过参数表,结果把b参数抄错了一位,调试了整整两天——所以能用API就别手写。
2.2 私钥生成
SM2私钥就是一个随机的大整数,取值范围在 [1, n-2] 之间。n是基点阶,大约2^256量级。生成过程其实很简单:
- 从安全随机数发生器获取256位随机字节
- 将字节转为大整数
- 检查是否在有效范围内,不在则重新生成
代码实现如下(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密钥
- 没有校验机制,数据损坏无法检测
- 私钥必须配合曲线参数使用
2.5 密钥对生成全流程
下面这张图把整个密钥对生成流程串起来了。你想想看,从曲线参数到最终的文件,每一步都有明确的输入输出。
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实例,或者加锁保护。
嗯,密钥对生成这部分就讲到这里。你把这些概念和代码吃透了,后面签名验签的章节就会轻松很多。记住:密钥安全是整个密码系统的基石,生成这一步马虎不得。