4. SM2 数字签名算法:签名、验签与随机数 k 的秘密

数字签名,说白了就是给一段数据盖个「电子印章」。你盖了章,别人就能确认这数据是你发的,而且中途没人动过手脚。SM2 的签名算法,就是国标里规定的盖章方式。

我个人习惯把签名和验签看作一对「锁与钥匙」的操作。签名是用私钥上锁,验签是用公钥开锁。但这里有个关键点——每次上锁,都得换一把新锁芯。这个锁芯,就是随机数 k。

4.1 签名流程:三步走,步步有坑

SM2 签名算法基于椭圆曲线离散对数难题。流程不复杂,但细节决定成败。我直接上流程,然后拆开讲。

签名输入:待签名消息 M、签名者私钥 dA、椭圆曲线参数(G、n 等)

签名输出:签名值 (r, s),其中 r 和 s 都是大整数

  1. 预处理消息:计算消息 M 的哈希值 e = H256(ZA || M)。ZA 是签名者的身份标识和公钥的杂凑值。
  2. 生成随机数:产生一个随机数 k ∈ [1, n-1]。
  3. 计算椭圆曲线点:计算点 (x1, y1) = [k]G。
  4. 计算 r:r = (e + x1) mod n。如果 r = 0 或 r + k = n,回到步骤 2。
  5. 计算 s:s = ((1 + dA)-1 · (k - r · dA)) mod n。如果 s = 0,回到步骤 2。
  6. 输出签名:(r, s)。

嗯,这里要注意步骤 4 和 5 里的「回到步骤 2」。这不是为了凑字数,而是安全需要。我曾经在项目里见过有人偷懒,把 r=0 的情况直接跳过不处理,结果被攻击者利用,伪造了签名。所以,这些检查一个都不能省。

4.2 验签流程:反向操作,验证真伪

验签是签名的逆过程。接收方拿到消息 M 和签名 (r, s),用签名者的公钥 PA 来验证。

验签输入:消息 M、签名 (r, s)、签名者公钥 PA、椭圆曲线参数

验签输出:是否通过验证(True/False)

  1. 检查范围:验证 r, s ∈ [1, n-1]。不在范围内直接返回 False。
  2. 计算哈希:同样计算 e = H256(ZA || M)。
  3. 计算 t:t = (r + s) mod n。如果 t = 0,返回 False。
  4. 计算椭圆曲线点:计算点 (x1, y1) = [s]G + [t]PA。
  5. 验证 R:计算 R = (e + x1) mod n。如果 R = r,验证通过;否则失败。

你想想看,验签过程里没有用到私钥,只用公钥就能完成。这就是非对称密码的魅力——私钥签名,公钥验签,各司其职。

4.3 为什么需要随机数 k?—— 没有它,签名就废了

这个问题我当年刚学的时候也困惑过。为什么每次签名都要生成一个新的随机数 k?直接用固定的不行吗?

答案是:绝对不行。

随机数 k 是 SM2 签名算法的安全基石。它的作用有三点:

作用 说明 如果缺失会怎样
防止私钥泄露 k 参与 s 的计算,如果 k 固定或可预测,攻击者可以通过两个签名反推出私钥 dA 私钥被直接算出,签名体系崩溃
保证签名唯一性 同一消息每次签名得到不同的 (r, s),防止重放攻击 签名可被复制,失去防伪能力
抵抗侧信道攻击 随机化计算过程,让攻击者无法通过功耗、时间等侧信道信息推断私钥 攻击者可通过物理手段逐步窃取私钥

我曾经踩过的坑:有一次在嵌入式设备上实现 SM2 签名,为了省时间,我用了伪随机数生成器(PRNG)且种子固定。结果测试时发现,两次签名同一消息,得到的 (r, s) 完全一样。这等于把私钥拱手送人。后来我换成了硬件真随机数发生器(TRNG),问题才解决。

教训:随机数 k 必须使用密码学安全的随机数生成器(CSPRNG),且每次签名独立生成。不要复用,不要预测,不要偷懒。

4.4 签名与验签的数学原理(快速回顾)

如果你对椭圆曲线点乘不熟,这里简单提一句。签名里的 [k]G 就是点 G 自加 k 次。椭圆曲线上的点加和倍点运算,构成了整个签名算法的基础。

验签时为什么 [s]G + [t]PA 能还原出 [k]G?因为:

[s]G + [t]PA = [s]G + [t·dA]G = [(s + t·dA)]G

把 s 和 t 的表达式代入,经过化简,最终得到 [k]G。这就是验签能成功的数学保证。

4.5 核心流程图:签名与验签的对照

下面这张 SVG 图,把签名和验签的流程放在一起对比。左边是签名,右边是验签,中间是共享的预处理步骤。你看一眼就能明白整个逻辑。

SM2 数字签名与验签流程对照图 SM2 数字签名与验签流程对照 签名流程(私钥 dA) ① 计算 e = H(ZA || M) ② 生成随机数 k ∈ [1, n-1] ③ 计算 (x₁, y₁) = [k]G ④ 计算 r = (e + x₁) mod n ⑤ 计算 s = (1+dA)⁻¹·(k - r·dA) ⑥ 输出签名 (r, s) 验签流程(公钥 PA) ① 检查 r, s ∈ [1, n-1] ② 计算 e = H(ZA || M) ③ 计算 t = (r + s) mod n ④ 计算 (x₁, y₁) = [s]G + [t]PA ⑤ 计算 R = (e + x₁) mod n ⑥ 验证 R == r ? 共享 e 注意:签名和验签都依赖相同的哈希函数和椭圆曲线参数,且随机数 k 必须每次独立生成

4.6 避坑指南:签名实现中的常见错误

我在多个项目中 review 过 SM2 签名代码,发现几个高频错误。列出来,你写代码时注意避开。

  • 随机数 k 的生成质量不够:用 rand() 或 Math.random() 生成 k。这些不是密码学安全的。必须用 /dev/urandom、CryptGenRandom 或硬件 TRNG。
  • 忘记检查 r 和 s 是否为 0:如果 r=0 或 s=0,签名无效,必须重新生成 k。有些实现偷懒跳过,导致签名可被伪造。
  • ZA 计算错误:ZA 包含签名者的 ID、曲线参数和公钥。不同实现如果 ID 编码方式不一致,会导致验签失败。国标规定 ID 默认是 "1234567812345678",但很多项目会自定义,必须提前约定好。
  • 模逆运算实现不当:计算 (1+dA)-1 mod n 时,要用扩展欧几里得算法或费马小定理。不要用浮点数,不要用近似值。

我的建议:如果你是在生产环境中实现 SM2 签名,优先使用经过国密局认证的密码库(如 GmSSL、OpenSSL 1.1.1+ 的国密支持)。自己手写签名算法,很容易在细节上出错。除非你是为了学习原理,否则别重复造轮子。

4.7 小结

SM2 数字签名算法,核心就三件事:私钥签名、公钥验签、随机数 k 保命。签名流程里,k 的随机性直接决定了私钥的安全;验签流程里,椭圆曲线点乘的正确性决定了验签能否通过。两者配合,构成了国标级的数字签名方案。

下次你写签名代码时,记得多检查一遍随机数生成器。我曾经因为这个问题吃过亏,希望你不用再踩一遍。