# 第四章 SM2签名算法:签名流程与哈希配合

SM2签名算法:从流程到实战

SM2签名算法,说白了就是给数据打上一个「防伪标签」。谁签的名,数据有没有被篡改,一验便知。跟我前几年做过的CA认证系统一样,这套流程在密钥分层管理里至关重要——上层CA签下级证书,全指着它。

签名流程看着公式多,但拆开来看,核心就三步:预处理、签名生成、签名验证。每一步都有讲究,尤其是哈希函数的配合,稍有偏差,整个机制就垮了。

预处理阶段:Z值与哈希的第一次配合

签名之前,SM2不像RSA那样直接哈希消息完事。它多了一个步骤——生成用户标识的哈希摘要ZA。我刚开始接触SM2时觉得这步多余,后来才明白,这是为了防止「密钥替换攻击」。嗯,设计者考虑得很周全。

ZA的计算涉及以下参数拼接:

  • ENTLA:用户标识的长度(2字节)
  • IDA:用户标识(比如身份证号或设备序列号)
  • a, b, xG, yG:椭圆曲线参数(SM2标准曲线固定)
  • xA, yB:签名者的公钥坐标

把这些串成一个比特串,然后用SM3哈希一次,就得到ZA。我个人习惯是在代码里用结构体封装这段逻辑,清晰一点,不容易出错。

// 伪代码:计算Z值
function deriveZ(IDA, publicKey, curveParams):
    entla = len(IDA) * 8; // 转换为比特长度
    a = curveParams.a;
    b = curveParams.b;
    xG = curveParams.xG;
    yG = curveParams.yG;
    xA = publicKey.x;
    yA = publicKey.y;
    
    buffer = concat(entla, IDA, a, b, xG, yG, xA, yA);
    return SM3(buffer);
我的习惯:Z值只依赖用户标识和公钥,只要用户注册一次,Z值就可以缓存起来。每次签名都重新算一遍纯属浪费CPU。

签名生成:两个随机数一个签名

签名过程会生成一对签名值(r, s)。这里最关键的是那个临时随机数k——千万不能重复,也千万不能泄漏。我记得有一次研发同事把随机数生成器写错了,导致k值固定,结果私钥直接被恢复出来了……那场面,啧啧。

签名的步骤如下:

  1. 把消息M和ZA拼接成M' = ZA || M
  2. 计算e = SM3(M'),得到哈希摘要
  3. 生成随机数k ∈ [1, n-1](n是椭圆曲线的阶)
  4. 计算椭圆曲线点(x1, y1) = [k]G
  5. 计算r = (e + x1) mod n,如果r == 0或r + k == n则返回第3步重试
  6. 计算s = ((1 + dA)⁻¹ × (k - r × dA)) mod n,如果s == 0则返回第3步重试
  7. 输出签名(r, s)

这里我提一个坑:SM2的签名计算用到了模逆运算(1 + dA)⁻¹。有些密码库的模逆实现没处理边界情况,当(1 + dA)与n不互质时会崩。好在SM2标准曲线参数已经保证了这个值肯定存在,但如果你换曲线,就得留个心眼。

避坑指南:我曾经在生产环境遇到过随机数k不是严格均匀分布的情况,导致签名值r出现了可预测的偏移。解决方法是硬性要求随机数生成器必须通过GM/T 0005的随机性检测,别图省事用系统自带的rand()。

签名验证:三步确认,一个不能少

验证流程比生成简单——不需要私钥,但需要你的公钥和Z值。整个过程其实就是把签名流程倒过来走一遍,检查最后得到的R'是否等于r。

  1. 检查r, s是否在[1, n-1]范围内,不在直接拒掉
  2. 计算e = SM3(ZA || M)
  3. 计算t = (r + s) mod n,如果t == 0,验证失败
  4. 计算椭圆曲线点(x1, y1) = [s]G + [t]PA(PA是签名者的公钥)
  5. 计算R = (e + x1) mod n
  6. 如果R == r,验证通过;否则失败

你想想看,这里有个精妙之处:验证过程中不需要私钥,也不需要解密密文。只要能拿到公钥,任何人都能验证签名。这也是PKI体系能运转起来的基础。

步骤 签名者操作 验证者操作
预处理 计算ZA,拼接M',计算e 使用同样的ZA,拼接M',计算e
核心计算 生成随机数k,标量乘点,模逆运算 标量乘点,点加,模运算
结果 输出(r, s) 比较R与r
依赖密钥 私钥dA 公钥PA

哈希算法的选择:为什么必须用SM3?

SM2标准明确规定了哈希算法使用SM3。但现实项目中总有各种妥协——比如跟国际系统对接,对方只支持SHA-256。我见过不少方案强行把SM2的哈希换成SHA-256,结果出了兼容性问题。

国密标准里的规定很死:SM2签名用的哈希函数输出长度必须是256位。SM3刚好是256位,SHA-256也是256位,理论上可以换。但要注意两件事:

  • SM3和SHA-256的填充方式不同:SM3采用Merkle–Damgård结构,SHA-256也是,但具体填充位有差异。直接换哈希可能导致Z值计算对不上。
  • 合规性:在等保三级或密评场景下,用SHA-256代替SM3属于「未使用国密算法」,评测直接不通过。

我的建议是:如果系统全栈国产化,老老实实全用SM3,别给自己找事。如果必须跟国际系统互通,那就得在协议层约定好哈希算法的标识字段,签名时带上算法ID,验证时根据ID选择对应的哈希函数。

哈希配合的核心原则:签名者和验证者使用的哈希算法必须完全一致,包括Z值计算阶段和签名阶段的哈希。任何一方的哈希实现有偏差,签名都验证不过。

一张图串起整个流程

下面这张流程图,我画了签名和验证两个轨道。你会发现它们像镜像一样——签名时用私钥,验证时用公钥;签名时算点和模逆,验证时算点和比较。理解了这张图,SM2签名就算真正拿下了。

SM2 签名与验证流程 签名方 输入:消息M、私钥dA、公钥PA 用户标识IDA、曲线参数 步骤1:计算Z值 Z = SM3(ENTL||IDA||a||b||xG||yG||xA||yA) 步骤2:计算消息摘要 e = SM3(Z || M) 步骤3:生成随机数k,计算点 (x1,y1)=[k]G,r=(e+x1) mod n 步骤4:计算s值 s = ((1+dA)⁻¹×(k - r×dA)) mod n 输出签名(r, s) 若r=0或s=0重新生成k 验证方 输入:消息M、签名(r,s)、公钥PA 用户标识IDA、曲线参数 步骤1:检查r,s范围 r∈[1,n-1]且s∈[1,n-1] 步骤2:计算消息摘要 e = SM3(Z || M) 步骤3:计算t值和点 t=(r+s) mod n,(x1,y1)=[s]G+[t]PA 步骤4:计算R值并比较 R=(e+x1) mod n,检查R==r 输出:验证通过/失败 若t=0直接失败 传递(r, s) 关键:相同Z值、相同哈希函数、相同参数

几个容易踩的坑

实战中我总结过几个高频问题,这里列出来,免得你重蹈覆辙:

  • Z值缓存不一致:签名方和验证方如果用了不同的IDA(比如一个用设备ID,一个用用户ID),Z值对不上,签名永远过不了。我曾经排查过整整一天的问题,最后发现是配置中心里IDA字段写错了。
  • 哈希调用时机不对:SM2的e值是在拼接Z和M之后才可哈希,不是直接哈希M。有些开源库的封装不一致,你得自己看清楚文档。
  • 随机数k的生存周期:签名完成后k必须立即销毁,不能存到数据库或日志里。如果k泄漏,攻击者可以反推出私钥。这不是理论威胁——2011年索尼PS3就是因为k固定被破解的。
我的日常:在代码里,我会把签名的随机数生成包装成一个独立模块,每次调用后强制清空内存。这个模块我专门写了一个单元测试,验证k是否每次都不一样且满足统计随机性。