4、SM2数字签名:签名算法流程、签名生成与验证、SM2签名与SM3哈希结合
数字签名,说白了就是给电子文档盖一个「防伪章」。SM2的签名算法基于椭圆曲线,跟RSA那种大数分解的思路完全不同。我刚开始接触国密时,总觉得椭圆曲线签名很玄乎,后来亲手调过几版代码才发现,它的核心逻辑其实很清晰——用私钥签名,用公钥验签,中间全靠SM3哈希把消息「浓缩」成固定长度的摘要。
这一节,咱们就把它拆开揉碎了讲。我会把签名流程、生成验证的步骤,以及为什么必须绑SM3哈希,都掰扯清楚。
4.1 签名算法整体流程
SM2签名的流程,可以概括为三步:预处理 → 签名生成 → 签名验证。预处理阶段,SM3哈希把任意长度的消息变成256位的摘要;签名生成阶段,用私钥对摘要做椭圆曲线运算,输出(r, s)两个大数;签名验证阶段,用公钥反算,确认签名是否合法。
我画了一张流程图,帮你把整体脉络理清楚:
核心要点:SM2签名不是直接对消息签名,而是对消息的SM3哈希值签名。这样做有两个好处:一是固定签名长度(64字节),二是提高效率——椭圆曲线运算只处理256位数据,而不是整个消息。
4.2 签名生成:一步一步来
签名生成的过程,我习惯把它分成五个步骤。你想想看,每一步都有明确的数学含义,但咱们不用纠结公式,理解逻辑就行。
- 计算消息摘要:用SM3对原始消息M做哈希,得到256位的摘要e。
- 生成随机数k:在[1, n-1]范围内生成一个随机数k。这个k必须每次都不一样,而且用完就丢。
- 计算椭圆曲线点:用k乘以基点G,得到点(x1, y1)。然后计算r = (e + x1) mod n。如果r等于0或r + k等于n,就重新选k。
- 计算签名分量s:s = ((1 + dA)⁻¹ · (k - r · dA)) mod n。如果s等于0,也重新选k。
- 输出签名值:最终签名就是(r, s)这个有序对,总共64字节。
我的经验:我在项目中遇到过随机数k生成不当导致签名泄露的问题。有一次测试环境用了伪随机数生成器,结果两次签名用了相同的k,攻击者直接算出了私钥。所以,k必须用真随机数或符合GM/T 0005标准的随机数发生器,千万别偷懒。
签名生成的代码实现,我贴一段核心逻辑给你看。注意,这里省略了椭圆曲线点乘的底层实现,重点看流程:
// SM2签名生成核心逻辑(伪代码)
function sm2_sign(message, privateKey) {
// 1. 计算SM3哈希
let e = sm3_hash(message);
// 2. 生成随机数k
let k = generate_random_k();
// 3. 计算椭圆曲线点
let (x1, y1) = point_mul(k, G);
let r = (e + x1) % n;
if (r === 0 || r + k === n) {
k = generate_random_k(); // 重新生成
// 重新计算...
}
// 4. 计算s
let s = ((1 + privateKey)⁻¹ * (k - r * privateKey)) % n;
if (s === 0) {
k = generate_random_k(); // 重新生成
// 重新计算...
}
// 5. 返回签名
return { r: r, s: s };
}
4.3 签名验证:反向推导
签名验证是签名生成的逆过程。验证方拿到消息M和签名(r, s)后,用公钥PA做以下计算:
- 计算消息摘要:同样用SM3对M做哈希,得到e'。
- 检查r和s的范围:r和s都必须在[1, n-1]范围内。如果不在,直接返回验证失败。
- 计算t:t = (r + s) mod n。如果t等于0,验证失败。
- 计算椭圆曲线点:计算点(x1', y1') = s·G + t·PA。
- 验证r:计算R = (e' + x1') mod n。如果R等于r,验证通过;否则失败。
注意:验证时一定要检查r和s的范围。我曾经见过一个案例,攻击者把s设成0,导致验证算法直接崩溃。所以,范围检查是安全底线,不能省略。
验证的代码实现也很直观:
// SM2签名验证核心逻辑(伪代码)
function sm2_verify(message, signature, publicKey) {
let r = signature.r;
let s = signature.s;
// 1. 范围检查
if (r < 1 || r >= n || s < 1 || s >= n) {
return false;
}
// 2. 计算消息摘要
let e = sm3_hash(message);
// 3. 计算t
let t = (r + s) % n;
if (t === 0) {
return false;
}
// 4. 计算椭圆曲线点
let (x1, y1) = point_add(point_mul(s, G), point_mul(t, publicKey));
// 5. 验证r
let R = (e + x1) % n;
return R === r;
}
4.4 SM2签名与SM3哈希的结合
SM2签名必须绑定SM3哈希,这是国密标准GM/T 0003.2-2012明确规定的。为什么非要绑?我总结了几点:
| 特性 | 说明 | 实际影响 |
|---|---|---|
| 固定签名长度 | 无论消息多大,签名都是64字节 | 适合证书链、PKI等固定格式场景 |
| 抗碰撞性 | SM3输出256位,碰撞概率极低 | 防止攻击者伪造相同摘要的不同消息 |
| 效率优化 | 椭圆曲线运算只处理256位数据 | 签名速度比直接对消息签名快10倍以上 |
| 标准合规 | GM/T 0003.2强制要求 | 不绑SM3的签名不被国密算法库认可 |
说白了,SM3哈希就是SM2签名的「前置过滤器」。消息再长再乱,经过SM3一滤,都变成256位的规整数据。我在做证书链配置时,经常遇到有人问:「能不能用SHA256代替SM3?」答案是不行——国密标准要求全套国产算法,SM2必须配SM3,这是合规红线。
避坑指南:我曾经在对接某厂商的加密机时,发现他们的SM2签名实现没有对消息做SM3预处理,而是直接对原始消息做椭圆曲线运算。结果签名长度不固定,而且验签时跟标准算法库对不上。后来查了三天才找到原因——他们自己魔改了签名流程。所以,一定要严格按照GM/T 0003.2实现,别自己发明创造。
4.5 签名与验证的常见问题
在实际开发中,我遇到过不少跟签名相关的问题。这里列几个典型的:
- 随机数k重用:两次签名用了相同的k,攻击者可以通过两个签名值算出私钥。解决方案是每次签名都重新生成k,并且用真随机数源。
- 签名值编码错误:SM2签名(r, s)在传输时通常用ASN.1 DER编码或简单的拼接。如果编码方式不一致,验签方会解析失败。我建议统一用DER编码,兼容性最好。
- 哈希计算不一致:签名方和验签方用的SM3实现不同,或者对消息的预处理方式不同(比如是否包含用户ID),会导致验签失败。标准要求签名前必须对消息做SM3哈希,并且要包含Z值(用户标识、公钥等信息的哈希)。
- 性能瓶颈:椭圆曲线点乘是签名中最耗时的操作。如果每秒需要签名上万次,建议用硬件加速或预计算优化。
我的建议:在开发阶段,先用标准测试向量验证签名和验签的正确性。国密局发布过SM2的测试数据,包括消息、私钥、公钥和期望的签名值。拿这些数据跑一遍,能快速发现实现中的bug。我每次换平台移植签名代码,都会先跑测试向量,确保万无一失。