3. SM2 数字签名算法:签名流程、预处理、签名生成与验证
好,咱们进入第三章。这一章是整个 SM2 签名验签开发的核心,也是我工作中踩坑最多的地方。说白了,签名流程就是「先算 Z 值,再算摘要,最后生成 (r, s)」这三步。验证就是反过来,用公钥把签名解开,比对摘要。
嗯,咱们一步步拆开讲。我会把每个环节的细节、我踩过的坑、以及为什么这么设计,都交代清楚。
3.1 签名流程总览
先看整体流程。SM2 签名算法基于椭圆曲线,核心是 ECDSA 的变体,但加入了国密特有的 Z 值预处理。我习惯把签名流程画成下面这张图,每次开发前都看一眼,防止漏步骤。
核心流程: 用户身份标识 → Z 值计算 → 消息摘要 → 签名生成 (r, s) → 输出签名值
这张图我贴在公司白板上贴了两年。每次新同事问签名流程,我就指着它说:「按这个顺序来,别跳步。」
3.2 预处理:Z 值计算
Z 值是 SM2 签名算法的「国密特色」。它把用户身份标识、椭圆曲线参数、公钥等信息,全部杂凑成一个 256 位的值。为什么要这么做?
我举个例子。假设两个用户用同一对密钥签名同一份消息,如果没有 Z 值,签名结果是一样的。但加上 Z 值后,因为身份标识不同,签名结果就不同了。这能防止「签名重用攻击」——说白了,就是让签名和用户身份绑定。
Z 值计算步骤
- 准备数据: 用户身份标识 ID(默认 "1234567812345678")、椭圆曲线参数 a、b、Gx、Gy、公钥 Px、Py
- 数据拼接: 按固定顺序拼接所有数据
- SM3 杂凑: 对拼接结果计算 SM3 摘要,得到 Z 值
我的经验: 身份标识 ID 的长度需要编码为 2 字节,放在 ID 数据前面。这个细节很容易漏,我曾经因为这个原因,签名验证一直失败,排查了整整一天。
具体的数据拼接顺序如下表:
| 序号 | 数据项 | 长度 | 说明 |
|---|---|---|---|
| 1 | ENTL | 2 字节 | ID 的比特长度,大端序 |
| 2 | ID | 可变 | 用户身份标识,默认 16 字节 |
| 3 | a | 32 字节 | 椭圆曲线参数 a |
| 4 | b | 32 字节 | 椭圆曲线参数 b |
| 5 | Gx | 32 字节 | 基点 G 的 x 坐标 |
| 6 | Gy | 32 字节 | 基点 G 的 y 坐标 |
| 7 | Px | 32 字节 | 公钥的 x 坐标 |
| 8 | Py | 32 字节 | 公钥的 y 坐标 |
嗯,这里要注意:所有数据都是大端序,长度固定为 32 字节(除了 ID 和 ENTL)。
3.3 消息摘要计算
Z 值算完后,把它和待签名的消息 M 拼接,再做一次 SM3 杂凑,得到消息摘要 e。公式很简单:
e = SM3(Z || M)
为什么要把 Z 值和消息分开算两次 SM3?
我个人的理解是:Z 值相当于「签名上下文」,它不随消息变化。如果每次签名都重新算 Z 值,性能会受影响。所以先算好 Z 值缓存起来,签名时只需要算一次 SM3(Z || M)就行。
避坑指南: 我曾经在项目中遇到一个问题——消息 M 的长度不是 32 字节对齐的。SM3 算法内部会做填充,但如果你在拼接 Z 和 M 之前,对 M 做了额外的填充,那摘要结果就错了。记住:SM3 自己会处理填充,你只需要把原始字节拼上去就行。
3.4 签名生成 (r, s)
有了摘要 e 和私钥 d,就可以生成签名了。签名生成的核心是椭圆曲线上的标量乘法。我直接上代码,这样更直观:
// 伪代码:SM2 签名生成
function sm2_sign(e, d, k) {
// 1. 计算椭圆曲线点 (x1, y1) = k * G
let point = scalar_multiply(k, G);
let x1 = point.x;
// 2. 计算 r = (e + x1) mod n
let r = (e + x1) % n;
if (r === 0 || r + k === n) {
// 需要重新选择随机数 k
return sm2_sign(e, d, new_random_k());
}
// 3. 计算 s = (1 + d)^(-1) * (k - r * d) mod n
let s = ((1 + d).mod_inverse(n) * (k - r * d)) % n;
if (s === 0) {
return sm2_sign(e, d, new_random_k());
}
// 4. 返回签名 (r, s)
return { r: r, s: s };
}
这里有几个关键点:
- 随机数 k: 每次签名必须使用不同的随机数。如果 k 重复,私钥会被推导出来。我记得 2010 年索尼 PS3 的 ECDSA 签名就因为随机数固定,导致私钥泄露——这个教训太深刻了。
- r 和 s 都不能为 0: 如果为 0,必须重新选择 k 再算一次。
- 模逆运算: (1 + d)^(-1) mod n 需要用到扩展欧几里得算法,性能开销较大。
性能优化建议: 随机数 k 的生成建议使用硬件随机数发生器,或者至少用 CSPRNG(密码学安全伪随机数生成器)。不要用 rand() 或 Math.random(),那些不够安全。
3.5 签名验证
验证流程是签名的逆过程。验证方需要:签名 (r, s)、消息 M、公钥 P、用户身份标识 ID。
验证步骤:
- 计算 Z 值: 和签名方一样,用 ID 和公钥算出 Z 值
- 计算摘要 e: e = SM3(Z || M)
- 验证 r 和 s 范围: 1 ≤ r ≤ n-1, 1 ≤ s ≤ n-1
- 计算 t = (r + s) mod n: 如果 t = 0,验证失败
- 计算椭圆曲线点 (x1, y1) = s * G + t * P: 这是核心运算
- 验证 R = (e + x1) mod n: 如果 R = r,验证通过;否则失败
// 伪代码:SM2 签名验证
function sm2_verify(r, s, e, P) {
// 1. 检查范围
if (r < 1 || r > n-1 || s < 1 || s > n-1) {
return false;
}
// 2. 计算 t
let t = (r + s) % n;
if (t === 0) {
return false;
}
// 3. 计算点 (x1, y1) = s * G + t * P
let point1 = scalar_multiply(s, G);
let point2 = scalar_multiply(t, P);
let point = point_add(point1, point2);
let x1 = point.x;
// 4. 验证 R == r
let R = (e + x1) % n;
return R === r;
}
验证的核心是「双标量乘法」:s * G + t * P。这个运算可以用 Strauss 算法优化,比分别算两个标量乘法再相加要快 30% 左右。
我的经验: 验证时最容易出错的地方是「公钥 P 的合法性检查」。在计算 s * G + t * P 之前,一定要确认 P 在椭圆曲线上,并且不是无穷远点。否则,攻击者可以构造一个无效公钥,让验证通过——这属于「无效曲线攻击」的一种。
3.6 常见问题与避坑
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 签名验证失败 | Z 值计算时 ID 长度编码错误 | 检查 ENTL 是否为 ID 的比特长度,大端序 |
| 签名结果不稳定 | 随机数 k 生成有问题 | 使用安全的随机数生成器,每次签名重新生成 |
| 性能太慢 | 模逆运算和标量乘法未优化 | 使用预计算表、Montgomery 模乘、Strauss 算法 |
| 跨平台不兼容 | 字节序或数据格式不一致 | 统一使用大端序,ASN.1 DER 编码签名值 |
嗯,这张表是我在实际项目中总结的。尤其是第一条,ID 长度编码错误,我见过至少三个团队踩过这个坑。你想想看,Z 值算错了,后面所有步骤都白费,而且错误很难定位——因为 SM3 摘要看起来「像那么回事」,但就是验证不通过。
3.7 小结
SM2 签名算法的核心就这些:Z 值计算、消息摘要、签名生成、签名验证。每一步都有国标规定的细节,不能自己发挥。
我个人建议,在实现时先把 Z 值计算单独封装成一个函数,因为它会被签名和验证两方共用。然后签名和验证分别实现,最后用官方测试向量做验证——这一步千万别省。
好,这一章就到这里。记住:签名是「私钥签名,公钥验证」,私钥永远不要泄露,随机数永远不要重复。