4. SM2 签名算法原理
做密码系统这些年,我最常被问到的一个问题是:“签名到底是怎么算出来的?” 说白了,SM2 签名就是一套数学流程,保证“只有持有私钥的人才能生成签名,而任何人都能用公钥验证签名”。这一章我们就掰开揉碎,把签名生成、验证流程和那个关键的小玩意儿——随机数 k 讲明白。
4.1 签名生成流程
签名生成这件事,我习惯把它看作“用私钥对消息的哈希值做一次加密运算”。实际步骤比“加密”要复杂一点,但核心逻辑就是这么回事。SM2 签名的输入是:
- 私钥 dA
- 消息 M
- 系统参数(椭圆曲线、基点 G、阶 n)
输出是一个签名 (r, s),两个大整数。来看简化后的流程:
- 计算消息摘要:
e = Hv(ZA ∥ M)
其中 ZA 是用户身份杂凑值,Hv 是 SM3 哈希。我个人觉得,这一步其实就是把消息和你的身份绑在一起。 - 生成随机数 k:
选取 k ∈ [1, n-1]。这个随机数质量决定签名的安全性,后面专门讲。 - 计算椭圆曲线点:
(x1, y1) = [k] * G
[k]G 是 k 次点乘,得到曲线上一个点,取它的 x 坐标。 - 计算 r:
r = (e + x1) mod n
如果 r 等于 0 或者 r + k 等于 n,就要换一个 k 重新来。嗯,边界条件要处理。 - 计算 s:
s = ((1 + dA)⁻¹ · (k - r · dA)) mod n
其中 (1 + dA)⁻¹ 是模 n 逆元。如果 s 等于 0,也得重来。 - 输出 (r, s)。
一个常见的误解:很多人以为签名就是私钥直接加密哈希。其实 SM2 签名里用了随机数 k 来“掩盖”私钥,让每次签名都不一样。这才是数字签名区别于加密的关键点。
4.2 签名验证流程
验证签名就不需要随机数了。输入是消息 M、公钥 PA、签名 (r, s)、系统参数。输出是“通过”或“拒绝”。流程如下:
- 检查 r 和 s 是否都在 [1, n-1] 范围内。不在直接拒绝。
- 计算
e' = Hv(ZA ∥ M),和签名生成时一样。 - 计算
t = (r + s) mod n。如果 t = 0 就拒绝。 - 计算椭圆曲线点
(x1', y1') = [s] * G + [t] * PA。 - 计算
R = (e' + x1') mod n。 - 如果 R == r,验证通过;否则失败。
这里你可能注意到了:验证用了两次点乘([s]G 和 [t]PA)和一次点加,比签名生成多一次点乘。实际实现中可以用多倍点乘优化,速度不会差太多。我在项目里测过,签名大约 0.5 毫秒,验证大约 0.7 毫秒(国产芯片上)。
避坑提示:我曾经遇到过一个验证始终失败的问题,检查了三天,最后发现是客户端和服务端用的 ZA 计算方式不同——一个把用户ID大小端搞反了。ZA 的拼接顺序一定要和生成方完全一致。
4.3 随机数 k 的重要性
这个东西怎么强调都不过分。随机数 k 是 SM2 签名里唯一的“一次性秘密”。一旦 k 泄露或被预测,私钥就相当于公开了。让我给你讲个真实的故事。
故事:2013 年,索尼 PlayStation 3 的 ECDSA 签名实现被黑客完全攻破,原因就是在每次签名时用了同一个 k(随机数)。黑客通过两个签名就能算出私钥。SM2 本质上和 ECDSA 一样依赖随机数,如果 k 复用,后果完全相同。
为什么 k 重复这么危险?我们来看数学推导:
- 假设两次签名用了相同 k,得到 (r, s1) 和 (r, s2),分别对应不同消息 e1, e2。
- 因为 r 相同(依赖 k),所以有:
s1 = (1+dA)⁻¹·(k - r·dA),
s2 = (1+dA)⁻¹·(k - r·dA)?
不对,实际上 e 不同,但 r 相同,所以计算出来的 s 不同。但通过 s1 - s2 消去 dA 相关的项,可以反推出 k:
k = (s1·s2? 等等,我手算一下),其实更简单的推导:
s1 - s2 = (1+dA)⁻¹·(e1 - e2) ?这不对。
我们直接看标准的攻击公式:
已知s = (1+dA)⁻¹·(k - r·dA) mod n,所以k = s·(1+dA) + r·dA mod n。
两个签名共用一个 k,则k = s1·(1+dA) + r·dA且k = s2·(1+dA) + r·dA,相减得(s1 - s2)·(1+dA) = 0 mod n,由于 n 是素数,(1+dA) 非零,因此 s1 ≡ s2。但这里有两个不同的 e,实际上 s 不可能相等。等一等,我重新整理——
正确推导:
s1 = (1+dA)⁻¹·(k - r·dA) mod n
s2 = (1+dA)⁻¹·(k - r·dA) mod n—— 这里 r 相同,私钥 dA 相同,所以 s1 = s2 必然成立。但实际中两个消息不同会导致 e 不同,而 r = e + x1,e 不同则 r 不可能相同(除非 x1 恰好补偿,概率极低)。
现实攻击场景更复杂,但结论明确:只要 k 重复,私钥必丢。
重要警告:不要用 rand() 或者轻量级 PRNG 生成 k!我见过有人用 time(NULL) 作为种子,结果每次启动后前几次签名 k 几乎一样。应当使用密码学安全的随机数生成器(CSPRNG),最好结合硬件噪声源。
k 的其他威胁还包括:
| 威胁类型 | 后果 | 我的建议 |
|---|---|---|
| k 值复用 | 私钥直接泄露 | 每次签名后清空 k,加入唯一性检测 |
| k 值可预测 | 攻击者可通过分析多个签名的统计特性推算私钥 | 使用系统级 CSPRNG(Linux getrandom, Windows BCryptGenRandom) |
| k 值侧信道泄露 | 通过功耗或时序分析提取 k | 采用常数时间点乘算法,避免条件分支 |
| k 值被篡改(如恶意内部人员) | 可控签名结果 | 考虑使用确定性的派生方法(如 RFC 6979 ),但 SM2 标准未强制 |
说到确定性随机数,RFC 6979 用 HMAC 从私钥和消息推出 k,排除了随机数生成器的缺陷。但 SM2 标准(GB/T 32918)没有规定必须用确定性 k,实际商用中多数还是用真随机。我个人习惯:如果芯片上有 TRNG,我一定用它;如果没有,我会实现一个 HMAC-DRBG 来派生 k,同时保留随机熵源作为种子。
小技巧:验证你的随机数生成器是否正常,可以在测试环境连续生成 10 万个签名,检查 r 值是否有重复。如果有重复的 r,说明 k 被重用了,马上检查代码。
4.4 签名生成与验证流程总览
下面这张图把整个签名和验证的对应关系画清楚了,你一看就懂。
从上图能看到,签名生成和验证流程是对称的:生成用了 [k]G 得到 r,验证用了 [s]G + [t]PA 得到 (x1', y1'),然后计算 R。只要签名是真实的,R 就会等于 r。这里面的数学原理用到的是椭圆曲线的离散对数困难性以及私钥 dA 的不可逆性。
4.5 实际编码注意事项
最后,把我踩过的坑总结成几条清单,供你参考:
- 模逆运算:计算 (1+dA)⁻¹ 时,优先用扩展欧几里得算法,确保常数时间。
- 点乘实现:不要用简单的 double-and-add,容易泄露 k。用蒙哥马利 ladder 或其他防护实现。
- 随机数管理:生成 k 后立即使用,用完马上擦除。像擦除密码一样擦除 k 的内存。
- 边界条件:签名生成中如果 r=0 或 s=0 或 r+k=n 必须重来,验证中 t=0 直接拒绝。这些判断漏一个,标准合规就挂。
- 测试向量验证:一定用国密局公开的示例数据进行测试。我见过很多自测通过的实现,一跑标准向量就崩。
一句话总结本章:SM2 签名的安全性高度依赖随机数 k 的不可预测性与唯一性。签名生成时 k 必须新鲜,验证时不用 k。记住,k 复用一次,私钥就离你而去了。