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默认值不一样。

Tip:接口输入建议全部用 Base64 编码。SM2 签名原始数据通常不是字符串,可能是二进制、文件流。Base64 能避免编码乱码问题,也方便日志传输。

签名流程:先杂凑,再签名

SM2 的签名流程不是直接签你的数据,而是先对数据做一次 SM3 杂凑,拿到一个 256位的摘要,再对这个摘要签名。这样做的好处很明显:你传 1KB 还是 1GB 的数据,签名耗时几乎一样。

  1. 数据预处理:取待签名数据,拼接用户 ID 和椭圆曲线参数,生成 Z 值。
  2. 计算消息摘要:使用 SM3 对 Z + 待签名数据做一次杂凑,得到 e。
  3. 生成随机数 k:这一步非常关键。k 必须是真随机数,不能重复。
  4. 计算椭圆曲线点:(x1, y1) = k * G
  5. 计算 r:r = (e + x1) mod n,如果 r = 0 或 r + k = n,回到第3步。
  6. 计算 s:s = ((1 + dA)^(-1) * (k - r * dA)) mod n,如果 s = 0,回到第3步。
  7. 输出签名:(r, s),按标准编码为字节序列。
警告:随机数 k 不能重复使用,也不能被预测。服务化环境下,多线程并发时要注意随机数碰撞问题。我见过一个项目因为使用伪随机种子,导致两次签名结果一样——这在密码学上是灾难性的,私钥可以被直接推导出来。

验签流程:拒绝「虚假签名」

验签是签名的逆操作,核心流程如下:

  1. 提取 Z 值:用公钥和用户 ID 重现 Z。
  2. 计算消息摘要 e:与签名流程一致。
  3. 检查 r 和 s:验证区间 [1, n-1],不通过直接返回 false。
  4. 计算 t:t = (r + s) mod n,如果 t = 0,返回 false。
  5. 计算椭圆曲线点:(x, y) = s * G + t * P(P 为公钥)。
  6. 验证:检查 (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
核心指标:在 99% 的场景下,单次签名/验签应该控制在 5ms 以内。如果超过 10ms,建议检查使用的是软件实现还是硬件加速卡。对于交易类业务,TPS 至少要撑到 3000+,否则到线上肯定扛不住。

代码实现: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());
}
Tip:一定要做超时控制。签名过程中如果遇到大流量,服务器负载飙升,某个请求卡在点运算上,可能导致整个线程池被占满。我一般会在接口层设置 500ms 超时,配合熔断降级。

常见陷阱与服务化最佳实践

  • 陷阱一:状态管理——SM2 签名算法本身是无状态的。千万不要在服务里保存签名时的随机数 k 或者临时椭圆曲线点。这些用完即弃,持久化只会带来安全风险。
  • 陷阱二:密钥生命周期——签名服务只管签,密钥的生成、轮换、吊销应该交给独立的密钥管理服务。我见过一个项目把私钥写死在配置文件里,运维的人都能看到……这很危险。
  • 陷阱三:日志泄漏——不要明文打印待签名数据或签名结果。日志里只保留 KeyID、请求来源、响应状态码即可。我曾经在排查问题时发现,全链路追踪的日志里赫然躺着完整的签名原文——吓得我立马改配置。
SM2 签名服务系统架构 SM2 签名服务系统架构 客户端 API 网关 签名服务 密钥管理服务 本地缓存 数据库 监控 & 日志 日志 & 指标上报 签名服务无状态,密钥生命周期由独立的密钥管理服务管控

这张图里,签名服务只管两个事:签名和验签。密钥管理和缓存是独立组件。为什么这样拆?因为密钥要定期轮换,而服务实例扩缩容时不能把私钥跟着一起拷贝——容易出现密钥泄露。我在做部署时,还特意把签名服务和密钥管理服务放到了不同的子网段,加了一层网络隔离。

一句话总结:SM2 签名服务化,核心是「接口轻、密钥独立、性能可水平扩展」。别把简单的事做复杂了。