第3章 SM2签名过程:从摘要到签名值
签名这事儿,说白了就是给消息打个「电子指纹」再加个「防伪标签」。SM2的签名过程并不复杂,但细节非常容易踩坑。我以前刚接手国密项目时,就因为搞混了签名时用的是消息还是摘要,差点导致整个联调失败。嗯,今天咱们就把这个过程彻底讲清楚。
3.1 签名算法总览
SM2签名是基于椭圆曲线离散对数难题的。用户A用自己的私钥 dA 对消息 M 签名,生成一个二元组 (r, s)。验证方用公钥 PA 就能验。
整体流程如下:
- 用SM3哈希算法对消息 M 计算摘要 e
- 生成一个随机数 k ∈ [1, n-1](n是椭圆曲线阶)
- 计算椭圆曲线点 (x1, y1) = k·G(G是基点)
- 计算 r = (e + x1) mod n,若 r=0 或 r+k=n 则回到第2步
- 计算 s = ((1 + dA)-1 · (k − r·dA)) mod n,若 s=0 则回到第2步
- 输出签名 (r, s)
你想想看,这里每个步骤都有边界检查。我以前就见过一个实现,忘了在 r=0 时重新生成 k,结果签名直接废了。
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颠倒了)—— 这种低级错误真的让人崩溃。
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
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 如果截断处理不当可能溢出。
好了,这一章的内容就到这里。你理解了签名过程中每一步为什么存在,以后写代码就会少踩很多坑。我们下章见。