第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值本质上是对消息发送方身份和公钥的一次哈希混合,防止伪造身份。核心流程我整理成了下面几步:

  1. 拼接用户标识、曲线参数、公钥坐标,计算Z = SM3(ENTL || IDa || a || b || Gx || Gy || Pa.x || Pa.y)
  2. 计算M' = Z || M,然后e = SM3(M')(这一步相当于哈希了身份和消息)
  3. 生成随机数k,范围[1, n-1]
  4. 计算椭圆曲线点(x₁, y₁) = k * G
  5. r = (e + x₁) mod n,如果r == 0或r + k == n则重试
  6. s = ((1 + d)⁻¹ · (k - r · d)) mod n,如果s == 0则重试
  7. 输出签名(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 验签函数核心代码解读

验签是签名的逆过程,但不需要私钥。流程如下:

  1. 检查r,s是否在[1, n-1]内,有一项不在直接返回0
  2. 计算M' = Z || M,得到e' = SM3(M')
  3. 计算t = (r + s) mod n,若t == 0则验签失败
  4. 计算椭圆曲线点(x₁', y₁') = s * G + t * Pa(Pa为签名者的公钥)
  5. 计算r' = (e' + x₁') mod n
  6. 如果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哈希 + 大数模运算。我把它们的关系画了一张图,方便你对照代码看流程。

SM2签名验签核心流程 SM2签名验签核心流程 签名(Sign) 输入消息M、用户标识IDa Z = SM3(ENTL||IDa||曲线参数||公钥) e = SM3(Z || M) 生成随机k ∈ [1, n-1] R = k * G r = (e+x1) mod n, s = ... 输出签名 (r,s) 验签(Verify) 输入消息M、签名(r,s)、公钥 检查 r,s ∈ [1,n-1] e' = SM3(Z || M) t = (r + s) mod n R' = s*G + t*Pa r' = (e' + x1') mod n 比较 r == r' ?通过/失败 签名传给验签 注意:蓝色/绿色/橙色/紫色/红色节点依次对应流程步骤,节点颜色仅用于区分阶段
补充说明:SM2签名和验签的计算量主要花在椭圆曲线标量乘上。签名要算一次k * G,验签要算一次s * G + t * Pa。如果你要在大并发场景下优化,可以考虑预计算G的倍数表(比如窗口法),或者用固定点的Comb算法。我在一个支付核心上做过优化,把签名耗时从3ms压到了0.8ms。

好了,SM2的代码实现核心就这些。参数设置别搞反,点运算注意边界,签名验签流程记住Z值的魔法。你如果能把这些代码手写一遍,SM2基本就吃透了。