第3章 SM2签名过程:从摘要到签名值

签名这事儿,说白了就是给消息打个「电子指纹」再加个「防伪标签」。SM2的签名过程并不复杂,但细节非常容易踩坑。我以前刚接手国密项目时,就因为搞混了签名时用的是消息还是摘要,差点导致整个联调失败。嗯,今天咱们就把这个过程彻底讲清楚。

3.1 签名算法总览

SM2签名是基于椭圆曲线离散对数难题的。用户A用自己的私钥 dA 对消息 M 签名,生成一个二元组 (r, s)。验证方用公钥 PA 就能验。

整体流程如下:

  1. 用SM3哈希算法对消息 M 计算摘要 e
  2. 生成一个随机数 k ∈ [1, n-1](n是椭圆曲线阶)
  3. 计算椭圆曲线点 (x1, y1) = k·G(G是基点)
  4. 计算 r = (e + x1) mod n,若 r=0 或 r+k=n 则回到第2步
  5. 计算 s = ((1 + dA)-1 · (k − r·dA)) mod n,若 s=0 则回到第2步
  6. 输出签名 (r, s)

你想想看,这里每个步骤都有边界检查。我以前就见过一个实现,忘了在 r=0 时重新生成 k,结果签名直接废了。

⚠ 注意:SM2标准中签名的输入是消息 M 而不是摘要,但实际计算前必须先用SM3算出 e。很多人会误以为直接签消息,导致验签时永远对不上。

3.2 消息摘要与哈希——SM3的用法

SM3是国密哈希算法,输出256位摘要。在签名时,SM3的输入不只是原始消息 M,还需要拼接一些参数。具体规则是:

步骤 操作 说明
1 计算 ZA = SM3(ENTLA || IDA || a || b || xG || yG || xA || yA) ENTLA:用户IDA的长度编码(2字节)
IDA:用户标识(默认 "1234567812345678")
a, b, G, PA:椭圆曲线参数
2 计算 e = SM3(ZA || M) 最终摘要 e 参与签名计算

为什么要先算一个 ZA?我个人的理解是:这相当于把用户身份绑进了摘要。即使两个人发同样的消息,因为 IDA 不同,摘要也不同,签名自然不同。这能防止「重放攻击」和「身份冒用」。我在项目中遇到过一种情况:用两个不同用户ID的私钥对同一消息签名,结果 r 值差很多,就是这个原因。

实际编码时,ZA 的计算顺序一定要跟标准一致:ENTLA 在前,IDA 在后,然后是椭圆曲线的 a、b、Gx、Gy,最后才是公钥坐标。我曾经调试一个联调失败,就是因为把公钥坐标写反了(x和y颠倒了)—— 这种低级错误真的让人崩溃。

💡 小技巧:如果你在做跨平台联调,务必确认双方使用的是相同的 IDA 和曲线参数。有一种很常见的踩坑:服务端用的是国密标准推荐的 "1234567812345678",但客户端写成了 "1234567812345679",结果摘要完全不一样。

3.3 签名值 (r, s) 生成详解

3.3.1 随机数 k 的生成

SM2签名必须使用合格的随机数发生器。k 的安全等级直接决定了私钥的安全。你想想看,如果 k 被猜出来,攻击者就能计算出私钥 dA。我亲眼见过一个嵌入式设备用 rand() 生成 k,结果签名被简单统计攻击就破解了——那是在一次安全审计中发现的,场面非常尴尬。

  • k 必须在 [1, n-2] 内均匀随机选取
  • 不能用固定的 k,这是经典漏洞
  • 每次签名必须生成新的 k,不能复用

3.3.2 计算椭圆曲线点 (x1, y1)

这一步就是做标量乘法 k·G。SM2推荐使用固定基点的预计算加速。实际项目中,我建议用标准库的倍点函数,别自己手写。我曾经看到有人把 Jacobian 坐标转换到仿射坐标时除零没处理,就崩了。

3.3.3 计算 r

r = (e + x1) mod n。注意这里的 e 是 SM3 摘要(256位整数),x1 是椭圆曲线点横坐标(也是整数)。

嗯,这里要注意:如果 r=0 或 r+k=n,必须重新生成 k 重来。为什么要检查 r+k=n?因为验签时要用到 r' + s' ≠ n 的条件,如果 r+k=n,验签算法里会出现特殊情况导致无法验证。

3.3.4 计算 s

s = ((1 + dA)-1 · (k − r·dA)) mod n

这里涉及两个关键操作:

  • (1 + dA)-1 是模 n 下的乘法逆元。可以用扩展欧几里得算法或费马小定理计算。
  • 模乘和模减一定要取模,防止溢出。
  • 如果 s=0,必须重新生成 k。

我个人习惯把 (k − r·dA) 这一项先算出来,再模 n,再乘以逆元。顺序对了,代码可读性也好些。

3.4 核心流程SVG

SM2签名算法流程 消息 M 计算 Z_A || 消息 SM3 哈希 → 摘要 e 生成随机数 k ∈ [1, n-1] 计算 k·G → (x₁, y₁) 计算 r = (e + x₁) mod n 计算 s = (1+dₐ)⁻¹·(k−r·dₐ) mod n 输出签名 (r, s) 若 r=0 或 r+k=n 则重新生成 k → 回到随机数步骤 若 s=0 则重新生成 k → 回到随机数步骤 边界条件检查

3.5 签名算法代码片段(Python参考)

import hashlib
from sm2 import SM2Curve, SM2Key

def sm2_sign(private_key: SM2Key, message: bytes, user_id: bytes = b"1234567812345678") -> tuple:
    # 1. 计算 ZA
    za = compute_za(private_key, user_id)
    # 2. 计算摘要 e
    e = int.from_bytes(hashlib.sm3(za + message).digest(), 'big')
    curve = SM2Curve()
    n = curve.n
    while True:
        # 3. 生成随机数 k
        k = generate_random_k(n)
        # 4. 计算 k*G
        x1, y1 = curve.multiply(curve.g, k)
        # 5. 计算 r
        r = (e + x1) % n
        if r == 0 or (r + k) == n:
            continue
        # 6. 计算 s
        inv = pow((1 + private_key.d) % n, n - 2, n)  # 费马小定理求逆
        s = (inv * (k - r * private_key.d)) % n
        if s == 0:
            continue
        break
    return (r, s)
关键提醒:上面代码中的 generate_random_k() 必须使用密码学安全随机数生成器(如 os.urandom + 模约减),不可用 random 模块。我在生产项目中曾因为用了 random.randint 被安全审查亮了红灯——后来改成了 secrets.randbelow 才过关。

3.6 签名过程中常见的坑

  • 忘记检查 r=0 或 s=0:这会导致签名无效,验签时直接失败。我以前遇到过某个封装库,内部没做这个检查,结果线上偶尔出现无法验签的情况,定位了两天才发现。
  • 摘要计算时缺少 ZA:直接用 SM3(M) 当 e,这样签名验签时公钥不同也能验证成功,但不符合国标。很多国内的物联网平台早期就犯过这个错误。
  • 逆元计算错误:(1+dA)-1 必须是模 n 下的,有人写成模 (2^256),导致签名结果完全不对。
  • 随机数 k 与 n 的边界:k 不能等于 0,也不能等于 n。很多实现用 k = randint(1, n-1) 没问题,但底层 lib 如果截断处理不当可能溢出。

好了,这一章的内容就到这里。你理解了签名过程中每一步为什么存在,以后写代码就会少踩很多坑。我们下章见。