第3章 SM2代码实现解析(C语言版)
好,咱们直接切入正题。SM2的代码,说白了就是一堆大数运算和椭圆曲线点运算的堆砌。你如果看过OpenSSL或者GmSSL的源码,会发现核心函数并不多,但细节特别多。我记得自己第一次移植SM2时,被那个坐标映射坑了整整两天。今天我就把参数设置、点运算、签名验签这三块的核心逻辑拆开来讲,代码尽量精简,但关键地方我会补上注释。
3.1 参数设置:SM2域参数与密钥结构
SM2用的是一个固定的椭圆曲线,参数由国家标准规定。你不需要自己生成曲线,但必须把结构体定义清楚。我个人习惯把参数封装成一个全局上下文,避免到处复制。
typedef struct {
BIGNUM *p; // 素数域
BIGNUM *a; // 椭圆曲线参数
BIGNUM *b;
BIGNUM *Gx; // 基点G横坐标
BIGNUM *Gy; // 基点G纵坐标
BIGNUM *n; // 阶
int cofactor; // 余因子,SM2里固定为1
} SM2_CURVE;
typedef struct {
BIGNUM *d; // 私钥,大整数
BIGNUM *Px; // 公钥横坐标
BIGNUM *Py; // 公钥纵坐标
} SM2_KEY;
SM2_CURVE时,别忘了把cofactor设成1。我曾经见过一个代码直接写死cofactor = 1,但把p和n给搞反了,结果签名验签死活对不上。你如果从国密标准文档里复制十六进制字符串,记得用BN_hex2bn转换,别手敲。参数检查也很重要。公钥必须通过曲线方程验证:y² ≡ x³ + a x + b (mod p)。私钥范围必须在[1, n-2]之间。这些我在产线上做过强制校验,不然线上跑着跑着突然验签失败,那叫一个惨。
3.2 点运算:椭圆曲线上的加法与倍点
SM2的点运算用的是仿射坐标或雅可比坐标。为了性能,一般用雅可比坐标,但为了教学,我习惯先用仿射坐标讲清楚逻辑。下面是最核心的点加函数——注意它处理了两种特殊情况:相同点倍点,以及无穷远点。
int sm2_point_add(SM2_POINT *r, const SM2_POINT *p1, const SM2_POINT *p2, const SM2_CURVE *curve) {
if (BN_is_zero(p1->z) || BN_is_zero(p2->z)) {
// 处理无穷远点
...
return 1;
}
if (BN_cmp(p1->x, p2->x) == 0 && BN_cmp(p1->y, p2->y) == 0) {
return sm2_point_double(r, p1, curve);
}
// 常规点加:λ = (y₂ - y₁)/(x₂ - x₁) mod p
// 注意分母不能为零,否则说明两点互逆,结果为无穷远点
...
}
BN_mod_inverse,但没检查返回值,结果遇到分母和p不互质(虽然概率极低)程序就崩了。实际项目中一定要对模逆的返回值判NULL。标量乘(k * G或k * P)是签名的瓶颈。SM2推荐使用双基链(Comb方法)或者wNAF。我建议至少实现一个简单的从左到右的二进制展开,先跑通再优化。
3.3 签名函数核心代码解读
SM2签名比ECDSA多了一个用户标识Z值的计算。Z值本质上是对消息发送方身份和公钥的一次哈希混合,防止伪造身份。核心流程我整理成了下面几步:
- 拼接用户标识、曲线参数、公钥坐标,计算
Z = SM3(ENTL || IDa || a || b || Gx || Gy || Pa.x || Pa.y) - 计算
M' = Z || M,然后e = SM3(M')(这一步相当于哈希了身份和消息) - 生成随机数
k,范围[1, n-1] - 计算椭圆曲线点
(x₁, y₁) = k * G r = (e + x₁) mod n,如果r == 0或r + k == n则重试s = ((1 + d)⁻¹ · (k - r · d)) mod n,如果s == 0则重试- 输出签名
(r, s)
代码里最容易被忽视的是随机数生成。SM2要求用符合GM/T 0005的随机数发生器。我见过有项目直接用rand(),结果签名被秒破。嗯,这算是安全红线了。
int sm2_sign(const SM2_KEY *key, const unsigned char *msg, int msg_len,
unsigned char *sig, unsigned int *sig_len) {
// 1. 计算Z值
sm2_compute_z(z, key, id, id_len);
// 2. e = SM3(Z || msg)
SM3_CTX ctx;
sm3_init(&ctx);
sm3_update(&ctx, z, 32);
sm3_update(&ctx, msg, msg_len);
sm3_final(&ctx, e);
// 3. 生成随机数k
BIGNUM *k = BN_new();
do {
if (!sm2_generate_k(k, curve->n)) goto err;
sm2_point_mul(&R, k, curve->G, curve);
// 4. 计算r = (e + x1) mod n
...
} while (BN_is_zero(r) || ...);
// 5. 计算s
...
return 1;
err:
return -1;
}
3.4 验签函数核心代码解读
验签是签名的逆过程,但不需要私钥。流程如下:
- 检查
r,s是否在[1, n-1]内,有一项不在直接返回0 - 计算
M' = Z || M,得到e' = SM3(M') - 计算
t = (r + s) mod n,若t == 0则验签失败 - 计算椭圆曲线点
(x₁', y₁') = s * G + t * Pa(Pa为签名者的公钥) - 计算
r' = (e' + x₁') mod n - 如果
r' == r,验签通过;否则失败
Pa必须在验签前验证是否为曲线上的点,且不能被判定为无穷远点或阶为小因子的点。我曾在测试网遇到过有人传了一个伪公钥,差点导致验签漏洞,多亏校验卡住了。另外,验签中的多标量乘s * G + t * Pa建议用Shamir的Trick,用一个sm2_point_mul_add函数一次算完,比分别算再加要快一倍。代码实现里通常会把点加和倍点封装好,然后组合。
int sm2_verify(const SM2_KEY *pub, const unsigned char *msg, int msg_len,
const unsigned char *sig, int sig_len) {
// 检查r,s范围
BN_hex2bn(&r, sig); // 伪代码
BN_hex2bn(&s, sig + 32);
if (BN_is_zero(r) || BN_cmp(r, curve->n) >= 0) return 0;
...
// 计算e'
sm2_compute_z(z, pub, id, id_len);
e = sm3(z || msg);
// 计算t = (r + s) mod n
BN_mod_add(t, r, s, curve->n);
if (BN_is_zero(t)) return 0;
// 多点乘
sm2_point_mul_add(&R, s, curve->G, t, &pub_point, curve);
// 计算r'
BN_mod_add(r1, e, R->x, curve->n);
return (BN_cmp(r, r1) == 0);
}
写到这里,你会发现SM2签名和验签的核心其实就是曲线点运算 + SM3哈希 + 大数模运算。我把它们的关系画了一张图,方便你对照代码看流程。
k * G,验签要算一次s * G + t * Pa。如果你要在大并发场景下优化,可以考虑预计算G的倍数表(比如窗口法),或者用固定点的Comb算法。我在一个支付核心上做过优化,把签名耗时从3ms压到了0.8ms。好了,SM2的代码实现核心就这些。参数设置别搞反,点运算注意边界,签名验签流程记住Z值的魔法。你如果能把这些代码手写一遍,SM2基本就吃透了。