Spring Boot 接入国密 SM2/SM3/SM4 的一些实践记录

先说为什么做这件事

年初公司有个项目要过等保测评,评审意见里明确提了要用国密算法。以前我们对外提供的接口用的是 RSA+AES 那套,迁移过来之后不只是换个加密工具类那么简单,整个报文加解密、签名验签、认证令牌的生成校验都要动,而且密钥管理的方式也得跟着调整。这里把我们踩过的坑和最终的落地方案做个记录。

SM2 是椭圆曲线非对称算法,替代 RSA 做签名和密钥协商,密钥长度 256 位。SM3 做摘要,输出 256 位摘要值,对标 SHA-256。SM4 是对称分组密码,分组和密钥长度都是 128 位,替代 AES 做数据加密。

依赖引入

用 bcprov-jdk15to18 + hutool-all 这组搭配是目前比较省事的。Hutool 的 SmUtil 封装了底层 BC 的细节,日常业务代码不需要直接碰 BC 的 API,但证书解析、密钥转换这类场景还是得用 BC 原生的类。

xml 复制代码
<dependency>
    <groupId>org.bouncycastle</groupId>
    <artifactId>bcprov-jdk15to18</artifactId>
    <version>1.78.1</version>
</dependency>
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.25</version>
</dependency>

注册 Provider 的代码大家应该都知道,静态块里加一次就行:

java 复制代码
@Configuration
public class GMConfig {
    static {
        if (Security.getProvider("BC") == null) {
            Security.addProvider(new BouncyCastleProvider());
        }
    }
}

SM4 加解密:写工具类要注意别把静态和实例混用

先写一个最简单的 SM4 工具类。这里有一个很容易踩的坑,SM4 对象是有状态的,最好不要用静态方法去调实例方法。而且密钥必须由 Spring 管理,不能 new 完就算了。

java 复制代码
@Component
public class SM4Util {
    private SM4 sm4;

    public SM4Util(@Value("${gm.sm4-key}") String sm4Key) {
        byte[] key = sm4Key.getBytes(StandardCharsets.UTF_8);
        if (key.length != 16) {
            throw new IllegalStateException("SM4密钥长度必须为16字节");
        }
        this.sm4 = new SM4(key);
    }

    public String encryptToHex(String plainText) {
        return sm4.encryptHex(plainText);
    }

    public String decryptFromHex(String cipherText) {
        return sm4.decryptStr(cipherText);
    }
}

实例注入之后,哪里要用直接 @Autowired 或者构造器注入。

这里说一句,如果有人告诉你 SM4 加密可以随便用 ECB 模式,别听他的。Hutool 默认的 SM4 底层用 SM4/ECB/PKCS5Padding,生产上至少要用 CBC 模式,加上随机 IV。实测 CBC 比 ECB 也就慢那么几微秒,安全收益大得多。如果你已经用默认的 SmUtil.sm4(key) 跑了,建议尽快改成下面这样:

java 复制代码
SM4 sm4 = SmUtil.sm4(key);
// 设置 CBC 模式,随机IV
byte[] iv = SecureUtil.randomBytes(16);
sm4.setIv(iv);
sm4.setMode(SM4.Mode.CBC);

SM2 签名验签:公钥和私钥一定要分开放

这是我们当时踩过最大的坑。最开始图省事,签名和验签共用一个 SM2 对象,代码写起来确实简单:

java 复制代码
SM2 sm2 = SmUtil.sm2(privateKey, publicKey);
byte[] sign = sm2.sign(data);
boolean verify = sm2.verify(data, sign);

但真实环境里签名是在 A 服务,验签在 B 服务,你可能根本没有对方的私钥,也不可能共用对象。后来我们改成这样:

签名方(持有私钥):

java 复制代码
@Component
public class SignService {
    private final SM2 signer;

    public SignService(@Value("${gm.sm2.private-key}") String privateKeyHex) {
        // 只传入私钥
        this.signer = SmUtil.sm2(privateKeyHex, null);
    }

    public String sign(String content) {
        byte[] digest = SmUtil.sm3(content.getBytes(StandardCharsets.UTF_8));
        byte[] signBytes = signer.signDigest(digest, null);
        return HexUtil.encodeHexStr(signBytes);
    }
}

验签方(持有公钥):

java 复制代码
@Component
public class VerifyService {
    private final SM2 verifier;

    public VerifyService(@Value("${gm.sm2.public-key}") String publicKeyHex) {
        // 只传入公钥
        this.verifier = SmUtil.sm2(null, publicKeyHex);
    }

    public boolean verify(String content, String signHex) {
        byte[] digest = SmUtil.sm3(content.getBytes(StandardCharsets.UTF_8));
        try {
            return verifier.verifyDigest(digest, HexUtil.decodeHex(signHex));
        } catch (Exception e) {
            return false;
        }
    }
}

踩坑点在于:Hutool 的 SmUtil.sm2(privateKey, publicKey) 如果只传一个参数,另一个位置会被填充默认值,在某些版本里会生成随机的空密钥对,导致签名和验签结果对不上。印象中 Hutool 5.7 之后改了这一块,但如果你用的版本比较老,最好在测试里先跑一遍签名验签的完整链路再往业务里集成。

另外要注意:signDigest 在 Hutool 5.8.x 里要求第二个参数传 userId,国密规范里这个参数默认是 "1234567812345678",显式传 null 在某些版本会走默认值。如果前端用的是其他语言实现的国密库,两边对不上,大概率就是这个 userId 的问题。

签名格式上,SM2 签名结果是 64 字节的 r||s,Hutool 默认返回的就是这个格式。但如果对方用的是 C1C3C2 的 ASN.1 DER 编码格式,需要做转换,别直接 HexUtil.encode 扔给对方。

请求解密和响应加密:用拦截器还是 Advice

我们的做法是直接实现 RequestBodyAdvice 和 ResponseBodyAdvice,而不是用 Filter。理由很简单:Filter 里拿到的是原始字节流,加解密之后还要自己包装 Request 再往下传,代码量大,而且和 Spring MVC 的参数绑定脱节。Advice 是在 JSON 序列化/反序列化前后生效的,直接对字符串操作,代码清爽很多。

java 复制代码
@RestControllerAdvice
public class CryptoRequestBodyAdvice extends RequestBodyAdviceAdapter {

    @Override
    public boolean supports(MethodParameter methodParameter, Type targetType,
                            Class<? extends HttpMessageConverter<?>> converterType) {
        return methodParameter.hasMethodAnnotation(Decrypt.class)
                || methodParameter.hasParameterAnnotation(Decrypt.class);
    }

    @Override
    public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter,
                                           Type targetType,
                                           Class<? extends HttpMessageConverter<?>> converterType)
            throws IOException {
        String cipherText = new String(inputMessage.getBody().readAllBytes(), StandardCharsets.UTF_8);
        String plainText = sm4Util.decryptFromHex(cipherText);
        return new MappingJacksonInputMessage(
                new ByteArrayInputStream(plainText.getBytes(StandardCharsets.UTF_8)),
                inputMessage.getHeaders());
    }
}

注意的点:supports 方法里不是所有请求都解密,要做个注解标记,否则 GET 请求和没有加密的接口全都会走进来。上面用了自定义注解 @Decrypt。

响应加密同理,ResponseBodyAdvice 的 beforeBodyWrite 里拿到 mapper 序列化后的 JSON 字符串,加密后返回:

java 复制代码
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType,
                              MediaType selectedContentType,
                              Class<? extends HttpMessageConverter<?>> selectedConverterType,
                              ServerHttpRequest request, ServerHttpResponse response) {
    String json = JSONUtil.toJsonStr(body);
    return sm4Util.encryptToHex(json);
}

不过要提醒一句,这种方案是对整个响应体做加密,如果某个接口的数据量很大(比如列表查询返回几万条),SM4 加密那点耗时不算什么,但响应体变大之后传输时间会上升。像这种大流量接口我们后来就直接跳过了解密,让前端走 HTTP 压缩,反而效果更好。

SM2 验签和防重放

验签不是验一次就完了,至少还要配合时间戳和 nonce。我们的做法是约定三个 header:

  • X-SM2-Sign: 签名的 hex 串
  • X-Timestamp: 毫秒时间戳
  • X-Nonce: 随机串,每次请求都不一样

签名原文是把这三个字段拼起来:timestamp + "\n" + nonce + "\n" + body,先 SM3 摘要再 SM2 签名。如果只验签不校验时间戳,签名就等于没做------攻击者抓包后可以无限重放。

时间窗口我们设了 5 分钟,不是自然时间 5 分钟,是容差 5 分钟。也就是说客户端时间比服务器快几分钟或者慢几分钟,都不会误杀,但超过 5 分钟的直接拒绝。nonce 用 Redis 存,key 是 nonce:{请求的非ce},TTL 5 分钟,存在就拒绝。这套组合在分布式环境下能挡住大部分重放攻击。

java 复制代码
long timestamp = Long.parseLong(request.getHeader("X-Timestamp"));
if (Math.abs(System.currentTimeMillis() - timestamp) > 300_000L) {
    throw new BizException("请求已过期");
}

String nonce = request.getHeader("X-Nonce");
if (Boolean.TRUE.equals(redisTemplate.hasKey("nonce:" + nonce))) {
    throw new BizException("重复请求");
}
redisTemplate.opsForValue().set("nonce:" + nonce, "1", Duration.ofMinutes(5));

有一个小细节是验签的顺序:先验时间戳,再查 nonce,最后做签名校验。如果时间戳已经超时了,就不用再费 CPU 去算 SM2 验签了,能省一点是一点,毕竟 SM2 验签是椭圆曲线点乘运算,200~500 微秒不算便宜。

再说一个被坑过的地方:前端签名用的原始 body 必须和服务器端收到的 body 完全一致,差一个空格都验不过。如果前端有人做了 JSON 序列化字段排序,或者把 null 字段过滤掉了,服务端就会偶发验签失败。我们的处理是约定:前端在签名之前必须使用和后端序列化规则一致的 JSON 字符串。后来为了省事,干脆把签名内容从 body 改为所有 query 参数按字典序排列 + 请求体 JSON 原样拼接,这样两边都好算。

国密 JWT:算法名要写对

我们本来想用开源库直接做国密 JWT,后来发现 java-jwt 和 jjwt 都不原生支持 SM2,要么自己扩展,要么直接手写。手写 JWT 也没多复杂,反正 JWT 本质就是三段 base64url 加签名,签名算法我们用 SM3WITHSM2。

这里要特别注意,header 里的 alg 别写成 "SM2",BC 里对应的算法名是 SM3WITHSM2。

java 复制代码
public class GmJwt {
    private final SM2 sm2;

    public GmJwt(String privateKeyHex, String publicKeyHex) {
        this.sm2 = SmUtil.sm2(privateKeyHex, publicKeyHex);
    }

    public String createToken(String userId, long expireSeconds) {
        Map<String, Object> header = new HashMap<>();
        header.put("alg", "SM3WITHSM2");
        header.put("typ", "JWT");

        Map<String, Object> payload = new HashMap<>();
        payload.put("sub", userId);
        payload.put("iat", System.currentTimeMillis() / 1000);
        payload.put("exp", System.currentTimeMillis() / 1000 + expireSeconds);

        String headerSeg = base64Url(header);
        String payloadSeg = base64Url(payload);
        String signingInput = headerSeg + "." + payloadSeg;

        byte[] sign = sm2.sign(signingInput.getBytes(StandardCharsets.UTF_8));
        String signature = Base64.getUrlEncoder().withoutPadding()
                .encodeToString(sign);

        return signingInput + "." + signature;
    }

    private String base64Url(Object obj) {
        return Base64.getUrlEncoder().withoutPadding()
                .encodeToString(JSONUtil.toJsonStr(obj).getBytes(StandardCharsets.UTF_8));
    }
}

验证端不需要持有私钥,只需要公钥。JWT 的过期时间还是要校验的。我们把 token 存 Redis 里做主动失效,服务端强制下线某个用户时直接把 key 删掉,纯粹靠 exp 不够灵活。

SSL 层搞国密,劝你直接放弃,用 Nginx 卸载

Spring Boot 内嵌 Tomcat 对国密 TLS 的支持基本为零,Tomcat 官方 JSSE 只实现了标准算法套件。有人可能会去找 bcprov 里的 GMSSLSocketFactory,那个类更多是演示用的,生产环境没人会直接用。

我们最终的方案是在 Nginx 层面做国密 TLS 卸载。需要注意 Nginx 原生不支持国密,需要用 gmssl 这个补丁版本或者第三方预编译的 Nginx。配好后大概是这样的:

nginx 复制代码
server {
    listen 443 ssl;
    server_name example.com;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_certificate     /etc/gmssl/server.crt;
    ssl_certificate_key /etc/gmssl/server.key;
    ssl_ciphers "SM2-WITH-SMS4-SM3:ECC-SM2-SM4-CBC:ECC-SM2-SM3-SM4-CBC";

    location /api {
        proxy_pass http://spring-boot-service:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

客户端如果是浏览器,还需要安装国密根证书和客户端证书。这种模式实际上是客户端到 Nginx 这段走国密,Nginx 到 Spring Boot 走内网明文 HTTP。内网一般没人嗅探,这个方案优点是能够验证通道的合规性,缺点就是证书签发和管理比较麻烦。

如果要双证书(国密 + 国际算法共存),主流做法是在 Nginx 上配两个 server 块或者用 ssl_certificate + ssl_certificate_key 分别引入两个证书,根据客户端握手时的 cipher 套件自动选择。但说实话,这个配置比国密本身还要复杂,而且最终效果取决于浏览器/客户端的国密插件。我们最后没做,等保那边也接受了。

密钥管理:私钥不落盘,SM4 密钥半年一换

私钥不能放在代码里,也不能放在配置中心明文存放。我们的做法是:中心化密钥管理服务(早期直接用数据库 + 应用启动时加载)统一管理,应用启动时调用密钥服务接口获取私钥和 SM4 数据密钥,私钥使用内存持有。至于这台密钥服务的数据库,主密钥从启动参数或者环境变量注入,和数据库里的密文分开。

密钥轮换要特别注意平滑性,直接换个新密钥会导致旧数据解不开。我们用的办法是引入 keyVersion,加密数据头部带一个字节的版本号,轮换时新写的数据用新密钥,读数据时根据版本号取对应密钥。这样就能同时支持新旧两套密钥并行工作一段时间。

java 复制代码
public class KeyHolder {
    private static final Map<Integer, SM2> SM2_MAP = new ConcurrentHashMap<>();
    private static final Map<Integer, SM4> SM4_MAP = new ConcurrentHashMap<>();
    private static volatile int CURRENT_VERSION = 1;

    public static SM4 getSm4(int version) {
        return SM4_MAP.get(version);
    }

    public static SM4 getCurrentSm4() {
        return SM4_MAP.get(CURRENT_VERSION);
    }

    public static void rotateKey(int version, String sm4KeyHex, String sm2PrivHex, String sm2PubHex) {
        SM4_MAP.put(version, new SM4(HexUtil.decodeHex(sm4KeyHex)));
        SM2_MAP.put(version, SmUtil.sm2(sm2PrivHex, sm2PubHex));
        CURRENT_VERSION = version;
    }
}

轮换频率我们是 SM4 半年换一次,SM2 一年换一次。关键点是把旧密钥至少保留一个轮换周期,确保老数据能解密。

一个实际的性能结论

压测数据就不放表格了,直接说结论。我们用 JMeter 模拟了一个 200 并发持续跑 3 分钟的混合场景,其中每个请求要经过 SM2 验签 + SM4 解密 + SM2 签名返回。原生 JDK 下(没用硬件加速)TPS 大概在 300 左右,主要瓶颈在 SM2 的验签上,一次 verifyDigest 约 400 微秒,200 并发下 CPU 直接打满。

后来做了一轮优化:

  1. SM2 实例复用------原来每次请求都 new 一个 SM2 对象,改成单例后性能有提升;
  2. 验签失败快速短路------时间戳校验、nonce 校验都在签名校验之前,不合法请求直接返回错误,不再走 SM2 验签;
  3. 服务端加解密支持异步化------非核心链路的加解密操作丢到线程池里异步执行。

最终压测结果大约是 TPS 1300 左右,单请求平均耗时 220ms,95 分位 310ms。这个数据比纯 Java 实现的 BC 库理论值也差不多了。如果你要支撑更高吞吐量,建议把 SM2 验签放到支持国密的加密机或者支持 SM4-NI 的 CPU 上。

总结

国密算法本身不复杂,BC + Hutool 已经封装得很好了,开发成本不高。真正麻烦的是密钥管理和合规审计这部分,得考虑清楚再动手。需要再次提醒这几个点:

  1. 公钥/私钥不要放在同一个 SM2 对象里用,生产环境本来就是分离的;
  2. 签名前后拼接的原文格式一定要统一,最好用 SDK 约束;
  3. 轮换密钥时保留旧版本,避免数据解不开;
  4. 能不动 TLS 协议栈就不动,国密 SSL 在业务侧落地性价比很低。

官网地址:https://farerboy.com/


相关推荐
伯恩bourne3 小时前
Spring Boot Bean 注册的两种方式:@Component 家族 vs @Configuration + @Bean
java·spring boot·后端
张彦峰ZYF3 小时前
停顿短不等于延迟低:G1、ZGC 与低延迟服务的目标分野、代价账本与选型标定
后端·回收暂停、应用停止总时长·到达安全点、分配停滞·屏障吃吞吐、并发线程吃核心·并发窗口吃堆余量·分区式靠挑回收集兑现目标·染色指针式不支持压缩引用
pride.li4 小时前
第03章 Python 教程-基础语法:注释与变量
服务器·前端·后端·python·算法·计算机视觉
databook4 小时前
用 Pydantic AI 从零搭建一个能干活的 AI Agent
人工智能·python
省钱兄--zs4 小时前
深圳24小时自助健身房软硬件解决方案实战指南与开发流程详解
java·数据仓库·spring boot·小程序·架构
康小旺4 小时前
把 markitdown 做成 Windows 桌面版:拖进文档就出 Markdown,附 233MB→64MB 打包裁剪全过程
python
upala4 小时前
【仓颉语言入门 · 第二篇 第 6 课】
后端
罗丹丹Luna4 小时前
移动端显示“已提交”以后:SAP 与低代码集成该怎样暴露真实状态
后端·架构
码事漫谈4 小时前
因为太想讨好你,所以AI会骗你
后端