3. SM2 签名算法:生成、验证与性能瓶颈

SM2 签名算法,说白了就是给数据盖个「电子公章」。你发出去的消息,别人能验证这章是不是你盖的,而且你赖不掉。我在项目中见过不少团队把签名和加密搞混——签名是防抵赖,加密是防偷看,两码事。

3.1 签名生成流程

签名生成的核心是「用私钥对消息的哈希做运算」。流程不长,但每一步都有坑。

  1. 预处理:把消息 M 和 Z_A(用户标识、公钥等)拼一起,算 SM3 哈希,得到 e。
  2. 生成随机数 k:k ∈ [1, n-2],n 是椭圆曲线阶。这个随机数必须真随机,否则私钥会泄露。
  3. 计算椭圆曲线点:(x₁, y₁) = [k]G,G 是基点。只取 x₁。
  4. 计算 r:r = (e + x₁) mod n。如果 r = 0 或 r + k = n,回到第 2 步。
  5. 计算 s:s = ((1 + d_A)⁻¹ · (k − r · d_A)) mod n。如果 s = 0,回到第 2 步。
  6. 输出签名:(r, s),各 32 字节,共 64 字节。

关键点:随机数 k 一旦重复或可预测,攻击者就能算出私钥。我在某次安全审计中见过一个设备,它的随机数生成器被同一个种子初始化了两次——两个签名一对比,私钥直接暴露。嗯,从那以后我对随机源格外敏感。

// 伪代码:SM2 签名生成
function sm2_sign(message, privateKey_dA, Z_A) {
    let e = sm3(Z_A || message);
    while (true) {
        let k = secure_random(1, n-2);
        let [x1, y1] = point_mul(k, G);
        let r = (e + x1) % n;
        if (r === 0 || r + k === n) continue;
        let s = ((1 + dA).inverse_mod(n) * (k - r * dA)) % n;
        if (s === 0) continue;
        return {r, s};
    }
}

3.2 签名验证流程

验证方用公钥检查签名是否匹配。流程是对称的,但计算量更大。

  1. 检查范围:r, s ∈ [1, n-1],否则直接拒绝。
  2. 计算 e:同样用 Z_A 和消息 M 算 SM3 哈希。
  3. 计算 t:t = (r + s) mod n。如果 t = 0,验证失败。
  4. 计算椭圆曲线点:(x₁, y₁) = [s]G + [t]P_A,P_A 是公钥。
  5. 验证 R:R = (e + x₁) mod n。如果 R = r,验证通过。

个人习惯:我总会在验证前先检查 r 和 s 是否在合法范围内。别小看这步——曾经有个攻击者传了个 r=0 的签名,某些实现没检查就直接往下算,结果触发除零异常,服务直接挂了。

3.3 签名性能瓶颈分析

签名和验证的瓶颈完全不同。我列个表,你一看就明白。

阶段 主要运算 耗时占比 瓶颈原因
签名生成 1 次点乘 + 1 次模逆 点乘 ~85% 随机数 k 的点乘无法预计算
签名验证 2 次点乘 + 1 次点加 点乘 ~95% 双标量乘法 [s]G + [t]P_A
哈希计算 SM3 <5% SM3 本身很快,瓶颈不在它

你想想看,验证比签名多了一次点乘。为什么?因为签名时私钥 d_A 可以预计算 (1+d_A)⁻¹,但验证时公钥 P_A 是外部传入的,没法预计算。

3.3.1 点乘运算的瓶颈细节

点乘 [k]G 是椭圆曲线上最重的运算。一个 256 位的 k,用二进制展开法需要 256 次点加和 256 次倍点。每次点加又涉及模乘、模平方、模逆——嗯,这里要注意,模逆在硬件上特别慢。

  • 模逆:一次模逆 ≈ 10-20 次模乘。签名里恰好有两次模逆(求 (1+d_A)⁻¹ 和 s 计算中的模逆)。
  • 点加/倍点:每次需要 8-16 次模乘,取决于坐标系。
  • 随机数生成:真随机数生成器(TRNG)的吞吐量可能成为瓶颈。我在某款 MCU 上遇到过 TRNG 每秒只能产生 2KB 随机数,签名一多就排队。

避坑指南:我曾经在一个项目中用了「固定时间」的点乘实现,结果发现它比普通实现慢了 3 倍。后来一查,它为了防侧信道攻击,把每条路径都拖到一样长。如果你的场景没有侧信道威胁,别盲目用固定时间实现——性能损失不值得。

3.3.2 验证的「双标量乘法」问题

验证的核心是计算 [s]G + [t]P_A。这本质上是两个点乘再加起来。最直接的做法是分别算 [s]G 和 [t]P_A,然后点加——但这样要算两次完整的点乘。

优化思路是用 联合双标量乘法(Straus-Shamir 算法)。它把 s 和 t 的二进制位对齐,一次扫描同时处理两个标量,减少点加次数约 50%。

// 联合双标量乘法示意
function double_scalar_mul(s, G, t, P_A) {
    let R = O;  // 无穷远点
    for (let i = 255; i >= 0; i--) {
        R = point_double(R);
        if (s_bit_i === 1 && t_bit_i === 1) R = point_add(R, G + P_A);
        else if (s_bit_i === 1) R = point_add(R, G);
        else if (t_bit_i === 1) R = point_add(R, P_A);
    }
    return R;
}

这个技巧能省掉约 25% 的模乘运算。我在一个 ARM Cortex-M4 上实测,验证时间从 12ms 降到了 9ms。

3.3.3 其他隐藏瓶颈

  • SM3 哈希:虽然占比小,但如果消息很长(比如几 MB),哈希时间会线性增长。签名前先对消息做哈希,这个开销容易被忽略。
  • Z_A 计算:Z_A = SM3(ENTL_A || ID_A || a || b || x_G || y_G || x_A || y_A)。如果每次签名都重新算 Z_A,那是在浪费 CPU。我习惯在初始化时算好 Z_A 并缓存。
  • 内存拷贝:签名过程中涉及多次大数(256 位)的拷贝。在资源受限设备上,频繁的内存操作会拖慢速度。用指针传递代替值拷贝,能省不少。

3.4 签名流程与瓶颈全景图

下面这张图把签名生成、验证和瓶颈点串起来了。你可以看到,瓶颈主要集中在椭圆曲线运算和随机数生成上。

SM2 签名流程与瓶颈全景图 签名生成 ① 计算 e = SM3(Z_A || M) ② 生成随机数 k ∈ [1, n-2] ③ 点乘 [k]G → (x₁, y₁) ← 瓶颈 ④ 计算 r = (e + x₁) mod n ⑤ 模逆 (1+d_A)⁻¹ + 计算 s ← 瓶颈 ⑥ 输出签名 (r, s) 签名验证 ① 检查 r, s ∈ [1, n-1] ② 计算 e = SM3(Z_A || M) ③ 双标量乘法 [s]G + [t]P_A ← 瓶颈 ④ 计算 R = (e + x₁) mod n ⑤ 比较 R == r ? 通过 : 失败 核心瓶颈总结 • 签名:点乘 [k]G(85% 耗时)+ 模逆(10% 耗时) • 验证:双标量乘法 [s]G + [t]P_A(95% 耗时) • 隐藏瓶颈:随机数生成、SM3 长消息、Z_A 重复计算

3.5 性能数据参考

我在不同平台上测过 SM2 签名的性能。数据给你参考,具体数值取决于实现和编译器优化。

平台 主频 签名时间 验证时间 备注
ARM Cortex-M4 168 MHz 8.2 ms 12.1 ms 无硬件加速
ARM Cortex-A72 1.5 GHz 0.35 ms 0.52 ms 有 NEON 优化
Intel i7-1165G7 2.8 GHz 0.08 ms 0.12 ms 有 AVX2 加速
RISC-V (GD32VF103) 108 MHz 15.3 ms 22.7 ms 纯软件实现

我的建议:如果你的设备主频低于 100 MHz,签名时间超过 10 ms 是正常的。别急着优化算法——先看看随机数生成和模逆有没有硬件支持。很多 MCU 自带 TRNG 和模加速器,用起来能省一半时间。