4. SM2 签名与验签:数字签名原理、SM2 签名算法、验签流程、防重放攻击

数字签名,说白了就是电子世界的「手写签名」加「指纹」。你签了名,别人就能确认这东西是你写的,而且你赖不掉。SM2 的签名算法,我用了好几年,在多个项目中落地过。今天咱们就把它的原理、流程,还有那些容易踩的坑,一次性说清楚。

4.1 数字签名到底在解决什么问题?

你想想看,在网络里传输一条消息,对方怎么确认:

  • 消息是你发的?(身份认证)
  • 消息没被人改过?(完整性)
  • 你事后不能否认?(不可抵赖性)

数字签名就是干这三件事的。SM2 签名算法基于椭圆曲线密码学,它的安全性建立在「椭圆曲线离散对数问题」的数学难题上。嗯,这里要注意,SM2 的签名算法和 ECDSA 很像,但细节上有差异,千万别混用。

核心概念:SM2 签名 = 用户私钥对消息摘要进行加密变换,生成一个签名值 (r, s)。验签 = 用用户公钥验证 (r, s) 是否与消息匹配。

4.2 SM2 签名算法:我习惯这么理解

SM2 签名算法,我个人习惯把它拆成三步:

  1. 预处理:对原始消息做哈希(SM3),得到消息摘要 e。
  2. 生成随机数:产生一个临时随机数 k,计算椭圆曲线上的点 (x1, y1) = k * G。
  3. 计算签名:r = (e + x1) mod n,s = ((1 + dA)^-1 * (k - r * dA)) mod n。其中 dA 是私钥,n 是椭圆曲线的阶。

这里有个关键点:随机数 k 绝对不能重复使用。我在项目中遇到过,有人为了性能把 k 写死了,结果私钥直接被算出来。那场面,真是惨不忍睹。

⚠️ 致命陷阱:SM2 签名中的随机数 k 必须每次重新生成,且不可预测。重复使用 k 会导致私钥泄露。我曾经在审计一个老旧系统时,发现它的 k 是用时间戳生成的,攻击者完全可以预测。嗯,后来我建议他们改用硬件真随机数发生器。

4.3 验签流程:一步步拆给你看

验签是签名的逆过程。收到消息 M 和签名 (r, s) 后,验签方需要:

  1. 检查 r 和 s 是否在 [1, n-1] 范围内,不在就直接拒绝。
  2. 计算消息摘要 e = SM3(M)。
  3. 计算 t = (r + s) mod n,如果 t = 0,验签失败。
  4. 计算椭圆曲线点 (x1, y1) = s * G + t * PA,其中 PA 是公钥。
  5. 验证 r == (e + x1) mod n,相等则通过,否则失败。

你看,整个过程不需要私钥参与,所以公钥可以公开。但要注意,验签前一定要确认公钥是可信的,否则签名验证就失去了意义。

💡 我的经验:在实际项目中,我建议把验签逻辑封装成一个独立模块,输入是「消息、签名、公钥」,输出是「通过/失败」。这样方便单元测试,也便于后续升级。我曾经在一个金融项目中,因为验签模块和业务逻辑耦合太紧,导致换算法时改得头皮发麻。

4.4 防重放攻击:签名不是万能的

签名能保证消息没被篡改,但不能防止攻击者把签名过的消息再发一次。这就是重放攻击。比如你给银行发了一条「转账 100 元」的签名消息,攻击者截获后,重复发送 100 次,银行就会执行 100 次转账。

怎么防?我常用的方案有几种:

方案 原理 优缺点
时间戳 消息中带上当前时间,接收方检查时间差是否在允许范围内 简单,但依赖时钟同步,有窗口期
随机数(Nonce) 每次通信使用一个唯一的随机数,接收方记录已用过的 Nonce 安全,但需要维护状态
序列号 消息带上递增的序列号,接收方只接受比上次大的序列号 适合有状态连接,但需要持久化
时间戳 + Nonce 组合 两者结合,既防重放又防时间窗口攻击 最推荐,兼顾安全与性能

我个人最推荐「时间戳 + Nonce」组合。在消息体里加上时间戳和一个随机数,签名时把这些字段一起签进去。接收方先验签,再检查时间戳是否过期,最后查 Nonce 是否用过。三重保障,基本万无一失。

避坑指南:我曾经在一个物联网项目中,只用了时间戳防重放。结果设备时钟不准,导致大量合法请求被拒绝。后来改成「时间戳 ± 5 分钟窗口 + Nonce 去重」,问题才解决。记住,防重放不是越严格越好,要在安全和可用性之间找平衡。

4.5 SM2 签名与验签的完整流程(SVG 图)

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

SM2 签名与验签流程图 SM2 签名与验签完整流程 签名方(持有私钥 dA) 1. 计算消息摘要 e = SM3(M) 2. 生成随机数 k,计算 k*G = (x1, y1) 3. 计算 r = (e + x1) mod n 4. 计算 s = ((1+dA)^-1 * (k - r*dA)) mod n 输出签名 (r, s) 和消息 M 验签方(持有公钥 PA) 1. 检查 r, s ∈ [1, n-1] 2. 计算 e = SM3(M) 3. 计算 t = (r + s) mod n,t ≠ 0 4. 计算 (x1, y1) = s*G + t*PA 5. 验证 r == (e + x1) mod n ✅ 验签通过 / ❌ 验签失败 💡 说明: 签名方使用私钥,验签方使用公钥。随机数 k 必须每次不同,且不可预测。验签前务必确认公钥可信。

4.6 代码示例:SM2 签名与验签(Java 版)

下面是我在实际项目中用过的代码片段。注意,这里用了 Bouncy Castle 库,它是目前支持 SM2 最成熟的 Java 库之一。

import org.bouncycastle.crypto.params.ECPrivateKeyParameters;
import org.bouncycastle.crypto.params.ECPublicKeyParameters;
import org.bouncycastle.crypto.signers.SM2Signer;
import org.bouncycastle.crypto.digests.SM3Digest;
import org.bouncycastle.crypto.params.ParametersWithID;
import org.bouncycastle.util.encoders.Hex;

public class SM2SignDemo {
    
    // 签名
    public static byte[] sign(byte[] message, ECPrivateKeyParameters privateKey, byte[] userId) {
        SM2Signer signer = new SM2Signer();
        ParametersWithID params = new ParametersWithID(privateKey, userId);
        signer.init(true, params);
        signer.update(message, 0, message.length);
        return signer.generateSignature();
    }
    
    // 验签
    public static boolean verify(byte[] message, byte[] signature, 
                                  ECPublicKeyParameters publicKey, byte[] userId) {
        SM2Signer signer = new SM2Signer();
        ParametersWithID params = new ParametersWithID(publicKey, userId);
        signer.init(false, params);
        signer.update(message, 0, message.length);
        return signer.verifySignature(signature);
    }
    
    public static void main(String[] args) {
        // 假设已有密钥对
        byte[] msg = "Hello SM2".getBytes();
        byte[] userId = "1234567812345678".getBytes(); // 16字节用户ID
        
        // 签名
        byte[] sig = sign(msg, privateKey, userId);
        System.out.println("签名: " + Hex.toHexString(sig));
        
        // 验签
        boolean ok = verify(msg, sig, publicKey, userId);
        System.out.println("验签结果: " + (ok ? "通过" : "失败"));
    }
}

💡 注意:SM2 签名时,用户 ID(userId)是必须的。国密标准规定,如果未指定用户 ID,默认使用 "1234567812345678"。我建议你在项目中显式传入,避免歧义。

4.7 防重放攻击实战:签名消息结构设计

最后,我分享一个我在生产环境中使用的消息结构。它把防重放机制直接嵌入了签名内容:

{
  "timestamp": 1712345678,      // 当前 Unix 时间戳(秒)
  "nonce": "a1b2c3d4e5f6",     // 随机字符串,每次不同
  "data": "原始业务数据",       // 真正的业务负载
  "signature": "..."            // 对上面三个字段拼接后的 SM2 签名
}

接收方收到后,先验签,再检查 timestamp 是否在 ±5 分钟内,最后查 nonce 是否在 Redis 里存在。如果 nonce 已存在,说明是重放,直接拒绝。nonce 的过期时间设为 10 分钟,自动清理。

⚠️ 重要:签名一定要覆盖 timestamp 和 nonce,否则攻击者可以篡改这两个字段。我曾经见过一个系统,只对 data 字段签名,timestamp 和 nonce 明文传输,结果被中间人轻松绕过防重放。记住,签名的范围决定了安全边界。

好了,SM2 签名与验签的核心内容就这些。从原理到流程,从代码到防重放,每一步都有坑,但也都有解法。你动手写代码时,记得把随机数生成、用户 ID 处理、防重放机制这三块重点检查一遍。嗯,这些经验,都是我用加班换来的。