3. SM2 数字签名算法:签名流程、预处理、签名生成与验证

好,咱们进入第三章。这一章是整个 SM2 签名验签开发的核心,也是我工作中踩坑最多的地方。说白了,签名流程就是「先算 Z 值,再算摘要,最后生成 (r, s)」这三步。验证就是反过来,用公钥把签名解开,比对摘要。

嗯,咱们一步步拆开讲。我会把每个环节的细节、我踩过的坑、以及为什么这么设计,都交代清楚。

3.1 签名流程总览

先看整体流程。SM2 签名算法基于椭圆曲线,核心是 ECDSA 的变体,但加入了国密特有的 Z 值预处理。我习惯把签名流程画成下面这张图,每次开发前都看一眼,防止漏步骤。

核心流程: 用户身份标识 → Z 值计算 → 消息摘要 → 签名生成 (r, s) → 输出签名值

SM2 签名流程总览 SM2 数字签名流程 用户身份标识 ID Z 值计算 消息摘要 e 签名生成 (r, s) 输出签名 (r, s) 注意:Z 值计算是 SM2 区别于 ECDSA 的关键步骤,不可省略

这张图我贴在公司白板上贴了两年。每次新同事问签名流程,我就指着它说:「按这个顺序来,别跳步。」

3.2 预处理:Z 值计算

Z 值是 SM2 签名算法的「国密特色」。它把用户身份标识、椭圆曲线参数、公钥等信息,全部杂凑成一个 256 位的值。为什么要这么做?

我举个例子。假设两个用户用同一对密钥签名同一份消息,如果没有 Z 值,签名结果是一样的。但加上 Z 值后,因为身份标识不同,签名结果就不同了。这能防止「签名重用攻击」——说白了,就是让签名和用户身份绑定。

Z 值计算步骤

  1. 准备数据: 用户身份标识 ID(默认 "1234567812345678")、椭圆曲线参数 a、b、Gx、Gy、公钥 Px、Py
  2. 数据拼接: 按固定顺序拼接所有数据
  3. 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。

验证步骤:

  1. 计算 Z 值: 和签名方一样,用 ID 和公钥算出 Z 值
  2. 计算摘要 e: e = SM3(Z || M)
  3. 验证 r 和 s 范围: 1 ≤ r ≤ n-1, 1 ≤ s ≤ n-1
  4. 计算 t = (r + s) mod n: 如果 t = 0,验证失败
  5. 计算椭圆曲线点 (x1, y1) = s * G + t * P: 这是核心运算
  6. 验证 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 值计算单独封装成一个函数,因为它会被签名和验证两方共用。然后签名和验证分别实现,最后用官方测试向量做验证——这一步千万别省。

好,这一章就到这里。记住:签名是「私钥签名,公钥验证」,私钥永远不要泄露,随机数永远不要重复。