4. SM2 签名算法:从数学到工程实现

SM2 签名算法,说白了就是一套「谁签的、谁验的」的密码学协议。我做了这么多年嵌入式安全,接触过 RSA、ECDSA,但 SM2 给我的感觉更「紧凑」——它的密钥短、计算快,特别适合资源受限的启动校验场景。

这一节,我们不讲虚的。直接看签名怎么生成、怎么验证,以及背后的数学原理到底是什么。你如果能把这部分吃透,后面做启动校验的代码实现,基本就是水到渠成的事。

4.1 数字签名生成流程

SM2 签名生成,我习惯把它拆成三步:预处理、核心计算、输出。每一步都有坑,咱们一个一个说。

4.1.1 预处理:消息摘要与 Z 值

签名不是直接对原始消息做的。SM2 标准要求先算一个 Z 值,它包含了签名者的身份信息、椭圆曲线参数和公钥。为什么要加这个?

嗯,这里要注意——如果不加 Z 值,攻击者可以用同样的签名去伪造不同身份下的消息。我在项目中遇到过有人偷懒没算 Z 值,结果被第三方攻击利用,后来全部重做。

Z 值的计算流程:

  1. 拼接身份标识 ID、曲线参数 a、b、G 点坐标、公钥坐标
  2. 用 SM3 哈希算出 Z 值(256 位)
  3. 将 Z 值与原始消息 M 拼接,再算一次 SM3,得到摘要 e

核心公式:

Z = SM3(ENTLA || IDA || a || b || xG || yG || xA || yA)

e = SM3(Z || M)

4.1.2 核心签名计算

拿到摘要 e 之后,签名者用自己的私钥 dA 生成签名 (r, s)。流程如下:

  1. 生成随机数 k ∈ [1, n-1](n 是基点 G 的阶)
  2. 计算椭圆曲线点 (x1, y1) = [k]G
  3. 计算 r = (e + x1) mod n,如果 r = 0 或 r + k = n,回到步骤 1
  4. 计算 s = ((1 + dA)^(-1) * (k - r * dA)) mod n,如果 s = 0,回到步骤 1
  5. 输出签名 (r, s),每个 256 位

避坑指南:我曾经在某个项目中,随机数生成器出了问题,每次签名用的 k 都一样。结果攻击者通过两次签名直接反推出了私钥。记住:随机数 k 必须真随机,且每次不同。这是签名安全的第一道防线。

4.1.3 签名输出格式

SM2 签名的标准输出是 (r, s),各 32 字节。实际工程中,我建议按 DER 编码或固定 64 字节打包。启动校验场景下,固定 64 字节更省空间,也更容易解析。

字段 长度 说明
r 32 字节 签名第一部分
s 32 字节 签名第二部分

4.2 签名验证流程

验证签名,说白了就是检查 (r, s) 是否真的由私钥 dA 对应的公钥 PA 生成。验证方不需要私钥,只需要公钥和签名。

4.2.1 验证前置检查

拿到签名后,先做几个快速检查:

  • r 和 s 是否在 [1, n-1] 范围内?不在就直接拒绝
  • 公钥 PA 是否在椭圆曲线上?不在就说明公钥无效

这些检查虽然简单,但能过滤掉大量无效签名。我习惯在代码里把这一步放在最前面,避免无效计算浪费 CPU。

4.2.2 核心验证计算

验证流程如下:

  1. 计算摘要 e = SM3(Z || M),与签名生成时一致
  2. 计算 t = (r + s) mod n,如果 t = 0,验证失败
  3. 计算椭圆曲线点 (x1', y1') = [s]G + [t]PA
  4. 计算 R = (e + x1') mod n
  5. 如果 R == r,验证通过;否则失败

个人经验:验证时最容易被忽略的是 Z 值的计算。签名方和验证方必须用完全相同的 ID、曲线参数和公钥。我见过一个案例,验证方用了默认 ID "1234567812345678",而签名方用了自定义 ID,结果签名死活验不过。排查了整整两天。

4.3 签名与验签的数学原理

数学原理听起来吓人,但核心就一句话:椭圆曲线上的离散对数问题。你想想看,已知 G 和 [k]G,想反推出 k,目前没有多项式时间算法。这就是 SM2 安全性的根基。

4.3.1 为什么签名能验证通过?

我们来推导一下:

签名时:s = (1 + dA)^(-1) * (k - r * dA) mod n

验证时:

[s]G + [t]PA = [s]G + [(r + s)]PA

= [s]G + [(r + s) * dA]G

= [s + (r + s) * dA]G

= [s * (1 + dA) + r * dA]G

代入 s = (1 + dA)^(-1) * (k - r * dA):

= [k - r * dA + r * dA]G = [k]G

所以 (x1', y1') = (x1, y1),R = (e + x1') mod n = (e + x1) mod n = r。验证通过。

关键点:这个推导成立的前提是椭圆曲线上的点加法和标量乘法满足交换律和结合律。SM2 选用的素数域 256 位曲线,正好保证了这些性质。

4.3.2 安全性依赖

SM2 的安全性依赖两个数学难题:

  • 椭圆曲线离散对数问题(ECDLP):已知 G 和 [k]G,求 k 不可行
  • 哈希碰撞抵抗:SM3 哈希函数,找到两个不同消息产生相同摘要不可行

这两个问题只要有一个被攻破,SM2 签名就不安全了。目前来看,256 位的 SM2 曲线,安全强度相当于 3072 位 RSA。用在启动校验上,绰绰有余。

4.4 签名与验签流程总览

下面这张图,把签名和验签的完整流程画出来了。我建议你把它存下来,后面写代码时对照着看。

SM2 签名与验签流程 签名方(私钥 dA) 1. 计算 Z = SM3(IDA || 曲线参数 || 公钥) 2. 计算 e = SM3(Z || M) 3. 生成随机数 k,计算 [k]G = (x1, y1) 4. 计算 r = (e + x1) mod n,s = ... 5. 输出签名 (r, s) (r, s) + M 验证方(公钥 PA) 1. 检查 r, s ∈ [1, n-1] 2. 计算 e = SM3(Z || M) 3. 计算 t = (r + s) mod n 4. 计算 [s]G + [t]PA = (x1', y1') 5. 验证 R = (e + x1') mod n == r ? 通过 / 拒绝 说明: 签名方和验证方必须使用相同的 Z 值(相同的 ID、曲线参数、公钥)。 随机数 k 必须真随机且每次不同,否则私钥可能被反推。

4.5 工程实现要点

最后,我总结几个工程实现中容易踩的坑:

  • 随机数质量:嵌入式设备上,硬件随机数生成器(TRNG)是首选。如果没有,至少要用熵池 + 软件 PRNG 混合方案。别用 rand(),那是伪随机,签名会死得很惨。
  • 模逆运算:计算 (1 + dA)^(-1) mod n 时,用扩展欧几里得算法或费马小定理。我习惯用费马小定理,因为 SM2 的 n 是素数,直接算 n-2 次幂就行,代码更简洁。
  • 点乘优化:标量乘法 [k]G 是性能瓶颈。建议用 Montgomery 阶梯或窗口法,能快 3-5 倍。启动校验场景下,时间窗口通常只有几百毫秒,优化点乘很关键。
  • 侧信道防护:签名过程中,k 和 dA 都是敏感数据。代码里要用常数时间实现,避免分支依赖秘密值。我见过一个产品,因为点乘用了 if-else 判断 k 的比特位,被简单功耗分析(SPA)攻破。

我的习惯:每次写完签名代码,我都会做三件事:1)用已知测试向量验证正确性;2)跑 1000 次签名,检查 r 和 s 的分布是否均匀;3)用示波器看功耗曲线,确认没有明显的数据依赖。这三步做完,基本可以放心上线。

SM2 签名算法,说难不难,说简单也不简单。关键是把数学原理和工程实现结合起来,每一步都搞清楚「为什么这么做」。后面我们做启动校验时,签名和验签就是核心组件。你现在把基础打牢,后面写代码就是顺理成章的事。