SM2 签名服务化:接口设计、签名验签流程、性能要求与实现
SM2 签名,说白了就是给数据打上一个「防伪戳」。服务化之后,这个戳不能随便打,得保证效率,还得扛得住高并发。我在做金融系统的国密改造时,曾经因为签名接口设计得太「重」,差点被压测打趴下。嗯,今天就来聊聊怎么把 SM2 签名做成一个靠谱的服务。
接口设计:RESTful 风格,还是 RPC?
我个人习惯用 RESTful 接口。为什么呢?对调用方友好,调试方便,与现有的微服务生态融合度高。你想想看,现在写个前端调用,谁不熟悉 HTTP POST + JSON?
核心接口就两个:签名和验签。配合上密钥管理(生成、查询、注销),再加一个性能监控的钩子,基本就够了。
| 接口路径 | 方法 | 输入 | 输出 |
|---|---|---|---|
/api/v1/sm2/sign |
POST | {"keyId": "xxx", "data": "base64(data)", "userId": "optional"} |
{"signature": "base64(r||s)", "keyId": "xxx"} |
/api/v1/sm2/verify |
POST | {"keyId": "xxx", "data": "base64(data)", "signature": "base64(r||s)"} |
{"valid": true/false, "message": "optional"} |
为什么我要在设计里暴露 userId?因为 SM2 签名标准(GM/T 0003.2)里是要求用户ID的。很多人在做服务化时忽略了这个参数,导致签名结果和别家不互通。我曾经遇到过一个对接方,双方用的都是国密,签出来的值就是验不过——折腾了半天,发现是用户ID默认值不一样。
签名流程:先杂凑,再签名
SM2 的签名流程不是直接签你的数据,而是先对数据做一次 SM3 杂凑,拿到一个 256位的摘要,再对这个摘要签名。这样做的好处很明显:你传 1KB 还是 1GB 的数据,签名耗时几乎一样。
- 数据预处理:取待签名数据,拼接用户 ID 和椭圆曲线参数,生成 Z 值。
- 计算消息摘要:使用 SM3 对 Z + 待签名数据做一次杂凑,得到 e。
- 生成随机数 k:这一步非常关键。k 必须是真随机数,不能重复。
- 计算椭圆曲线点:(x1, y1) = k * G
- 计算 r:r = (e + x1) mod n,如果 r = 0 或 r + k = n,回到第3步。
- 计算 s:s = ((1 + dA)^(-1) * (k - r * dA)) mod n,如果 s = 0,回到第3步。
- 输出签名:(r, s),按标准编码为字节序列。
验签流程:拒绝「虚假签名」
验签是签名的逆操作,核心流程如下:
- 提取 Z 值:用公钥和用户 ID 重现 Z。
- 计算消息摘要 e:与签名流程一致。
- 检查 r 和 s:验证区间 [1, n-1],不通过直接返回 false。
- 计算 t:t = (r + s) mod n,如果 t = 0,返回 false。
- 计算椭圆曲线点:(x, y) = s * G + t * P(P 为公钥)。
- 验证:检查 (e + x) mod n 是否等于 r,相等则签名有效。
这里有个容易踩的坑:验签前一定要检查公钥是否在有效期内、是否被注销。很多服务只验签名不验公钥状态,导致已作废的私钥签的数据仍然被认为是合法的。我在做交易系统时,专门加了一层「公钥缓存 + 黑名单」检查。
性能要求与实测指标
服务化之后,SM2 签名的性能主要受以下因素影响:
- 椭圆曲线点乘运算:全流程最慢的步骤,单次点乘大约 0.5~2ms(取决于具体实现)。
- 随机数生成:如果使用硬件熵源,耗时可能比点乘还高。
- 并发数:无状态设计下,SM2 签名可以水平扩容。但要注意 JNI/Native 库的线程安全。
下面是基于 Intel Xeon 4214 的压测数据(单节点 16 核,Java + Bouncy Castle + 国密改造版):
| 指标 | 签名 (SM2) | 验签 (SM2) |
|---|---|---|
| 单次耗时 (p50) | 1.8ms | 2.1ms |
| 单次耗时 (p99) | 4.3ms | 5.0ms |
| 吞吐 (8 并发) | ~4500 TPS | ~3800 TPS |
| 内存占用 (空闲) | ~80MB | ~85MB |
代码实现:Java 示例
我习惯用 Java 来做服务化实现。核心类拆成 Sm2SignService 和 Sm2VerifyService,通过 Spring Boot 暴露 Rest 接口。下面是一个简化版的签名实现骨架:
// 注意:以下代码仅展示核心逻辑,省略了异常处理、参数校验等
public Sm2SignResult sign(Sm2SignRequest request) {
// 1. 根据 keyId 获取私钥(从密钥管理服务或本地缓存)
PrivateKey privateKey = keyManager.getPrivateKey(request.getKeyId());
// 2. 使用 SM3 对 data 进行杂凑(带用户 ID)
byte[] hash = sm3WithZ(request.getUserId(), request.getDecodedData(), privateKey);
// 3. 执行 SM2 签名算法
SM2Sign sign = engine.sign(hash, privateKey);
// 4. 格式化为标准 DER/RS 编码
return Sm2SignResult.success(sign.toByteArray());
}
常见陷阱与服务化最佳实践
- 陷阱一:状态管理——SM2 签名算法本身是无状态的。千万不要在服务里保存签名时的随机数 k 或者临时椭圆曲线点。这些用完即弃,持久化只会带来安全风险。
- 陷阱二:密钥生命周期——签名服务只管签,密钥的生成、轮换、吊销应该交给独立的密钥管理服务。我见过一个项目把私钥写死在配置文件里,运维的人都能看到……这很危险。
- 陷阱三:日志泄漏——不要明文打印待签名数据或签名结果。日志里只保留 KeyID、请求来源、响应状态码即可。我曾经在排查问题时发现,全链路追踪的日志里赫然躺着完整的签名原文——吓得我立马改配置。
这张图里,签名服务只管两个事:签名和验签。密钥管理和缓存是独立组件。为什么这样拆?因为密钥要定期轮换,而服务实例扩缩容时不能把私钥跟着一起拷贝——容易出现密钥泄露。我在做部署时,还特意把签名服务和密钥管理服务放到了不同的子网段,加了一层网络隔离。