4. SM2 签名与验签:数字签名原理、SM2 签名算法、验签流程、防重放攻击
数字签名,说白了就是电子世界的「手写签名」加「指纹」。你签了名,别人就能确认这东西是你写的,而且你赖不掉。SM2 的签名算法,我用了好几年,在多个项目中落地过。今天咱们就把它的原理、流程,还有那些容易踩的坑,一次性说清楚。
4.1 数字签名到底在解决什么问题?
你想想看,在网络里传输一条消息,对方怎么确认:
- 消息是你发的?(身份认证)
- 消息没被人改过?(完整性)
- 你事后不能否认?(不可抵赖性)
数字签名就是干这三件事的。SM2 签名算法基于椭圆曲线密码学,它的安全性建立在「椭圆曲线离散对数问题」的数学难题上。嗯,这里要注意,SM2 的签名算法和 ECDSA 很像,但细节上有差异,千万别混用。
核心概念:SM2 签名 = 用户私钥对消息摘要进行加密变换,生成一个签名值 (r, s)。验签 = 用用户公钥验证 (r, s) 是否与消息匹配。
4.2 SM2 签名算法:我习惯这么理解
SM2 签名算法,我个人习惯把它拆成三步:
- 预处理:对原始消息做哈希(SM3),得到消息摘要 e。
- 生成随机数:产生一个临时随机数 k,计算椭圆曲线上的点 (x1, y1) = k * G。
- 计算签名: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) 后,验签方需要:
- 检查 r 和 s 是否在 [1, n-1] 范围内,不在就直接拒绝。
- 计算消息摘要 e = SM3(M)。
- 计算 t = (r + s) mod n,如果 t = 0,验签失败。
- 计算椭圆曲线点 (x1, y1) = s * G + t * PA,其中 PA 是公钥。
- 验证 r == (e + x1) mod n,相等则通过,否则失败。
你看,整个过程不需要私钥参与,所以公钥可以公开。但要注意,验签前一定要确认公钥是可信的,否则签名验证就失去了意义。
💡 我的经验:在实际项目中,我建议把验签逻辑封装成一个独立模块,输入是「消息、签名、公钥」,输出是「通过/失败」。这样方便单元测试,也便于后续升级。我曾经在一个金融项目中,因为验签模块和业务逻辑耦合太紧,导致换算法时改得头皮发麻。
4.4 防重放攻击:签名不是万能的
签名能保证消息没被篡改,但不能防止攻击者把签名过的消息再发一次。这就是重放攻击。比如你给银行发了一条「转账 100 元」的签名消息,攻击者截获后,重复发送 100 次,银行就会执行 100 次转账。
怎么防?我常用的方案有几种:
| 方案 | 原理 | 优缺点 |
|---|---|---|
| 时间戳 | 消息中带上当前时间,接收方检查时间差是否在允许范围内 | 简单,但依赖时钟同步,有窗口期 |
| 随机数(Nonce) | 每次通信使用一个唯一的随机数,接收方记录已用过的 Nonce | 安全,但需要维护状态 |
| 序列号 | 消息带上递增的序列号,接收方只接受比上次大的序列号 | 适合有状态连接,但需要持久化 |
| 时间戳 + Nonce 组合 | 两者结合,既防重放又防时间窗口攻击 | 最推荐,兼顾安全与性能 |
我个人最推荐「时间戳 + Nonce」组合。在消息体里加上时间戳和一个随机数,签名时把这些字段一起签进去。接收方先验签,再检查时间戳是否过期,最后查 Nonce 是否用过。三重保障,基本万无一失。
避坑指南:我曾经在一个物联网项目中,只用了时间戳防重放。结果设备时钟不准,导致大量合法请求被拒绝。后来改成「时间戳 ± 5 分钟窗口 + Nonce 去重」,问题才解决。记住,防重放不是越严格越好,要在安全和可用性之间找平衡。
4.5 SM2 签名与验签的完整流程(SVG 图)
下面这张图,把签名和验签的完整流程画出来了。我建议你把它保存下来,写代码时对照着看,不容易出错。
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 处理、防重放机制这三块重点检查一遍。嗯,这些经验,都是我用加班换来的。