☰
Spring Boot 接入国密 SM2/SM3/SM4 的一些实践记录
2026/10/15 4:08:33 网站建设 项目流程

先说为什么做这件事

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

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

依赖引入

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

<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 的代码大家应该都知道,静态块里加一次就行:

@ConfigurationpublicclassGMConfig{static{if(Security.getProvider("BC")==null){Security.addProvider(newBouncyCastleProvider());}}}

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

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

@ComponentpublicclassSM4Util{privateSM4sm4;publicSM4Util(@Value("${gm.sm4-key}")Stringsm4Key){byte[]key=sm4Key.getBytes(StandardCharsets.UTF_8);if(key.length!=16){thrownewIllegalStateException("SM4密钥长度必须为16字节");}this.sm4=newSM4(key);}publicStringencryptToHex(StringplainText){returnsm4.encryptHex(plainText);}publicStringdecryptFromHex(StringcipherText){returnsm4.decryptStr(cipherText);}}

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

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

SM4sm4=SmUtil.sm4(key);// 设置 CBC 模式,随机IVbyte[]iv=SecureUtil.randomBytes(16);sm4.setIv(iv);sm4.setMode(SM4.Mode.CBC);

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

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

SM2sm2=SmUtil.sm2(privateKey,publicKey);byte[]sign=sm2.sign(data);booleanverify=sm2.verify(data,sign);

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

签名方(持有私钥):

@ComponentpublicclassSignService{privatefinalSM2signer;publicSignService(@Value("${gm.sm2.private-key}")StringprivateKeyHex){// 只传入私钥this.signer=SmUtil.sm2(privateKeyHex,null);}publicStringsign(Stringcontent){byte[]digest=SmUtil.sm3(content.getBytes(StandardCharsets.UTF_8));byte[]signBytes=signer.signDigest(digest,null);returnHexUtil.encodeHexStr(signBytes);}}

验签方(持有公钥):

@ComponentpublicclassVerifyService{privatefinalSM2verifier;publicVerifyService(@Value("${gm.sm2.public-key}")StringpublicKeyHex){// 只传入公钥this.verifier=SmUtil.sm2(null,publicKeyHex);}publicbooleanverify(Stringcontent,StringsignHex){byte[]digest=SmUtil.sm3(content.getBytes(StandardCharsets.UTF_8));try{returnverifier.verifyDigest(digest,HexUtil.decodeHex(signHex));}catch(Exceptione){returnfalse;}}}

踩坑点在于: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 序列化/反序列化前后生效的,直接对字符串操作,代码清爽很多。

@RestControllerAdvicepublicclassCryptoRequestBodyAdviceextendsRequestBodyAdviceAdapter{@Overridepublicbooleansupports(MethodParametermethodParameter,TypetargetType,Class<?extendsHttpMessageConverter<?>>converterType){returnmethodParameter.hasMethodAnnotation(Decrypt.class)||methodParameter.hasParameterAnnotation(Decrypt.class);}@OverridepublicHttpInputMessagebeforeBodyRead(HttpInputMessageinputMessage,MethodParameterparameter,TypetargetType,Class<?extendsHttpMessageConverter<?>>converterType)throwsIOException{StringcipherText=newString(inputMessage.getBody().readAllBytes(),StandardCharsets.UTF_8);StringplainText=sm4Util.decryptFromHex(cipherText);returnnewMappingJacksonInputMessage(newByteArrayInputStream(plainText.getBytes(StandardCharsets.UTF_8)),inputMessage.getHeaders());}}

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

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

@OverridepublicObjectbeforeBodyWrite(Objectbody,MethodParameterreturnType,MediaTypeselectedContentType,Class<?extendsHttpMessageConverter<?>>selectedConverterType,ServerHttpRequestrequest,ServerHttpResponseresponse){Stringjson=JSONUtil.toJsonStr(body);returnsm4Util.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 分钟,存在就拒绝。这套组合在分布式环境下能挡住大部分重放攻击。

longtimestamp=Long.parseLong(request.getHeader("X-Timestamp"));if(Math.abs(System.currentTimeMillis()-timestamp)>300_000L){thrownewBizException("请求已过期");}Stringnonce=request.getHeader("X-Nonce");if(Boolean.TRUE.equals(redisTemplate.hasKey("nonce:"+nonce))){thrownewBizException("重复请求");}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。

publicclassGmJwt{privatefinalSM2sm2;publicGmJwt(StringprivateKeyHex,StringpublicKeyHex){this.sm2=SmUtil.sm2(privateKeyHex,publicKeyHex);}publicStringcreateToken(StringuserId,longexpireSeconds){Map<String,Object>header=newHashMap<>();header.put("alg","SM3WITHSM2");header.put("typ","JWT");Map<String,Object>payload=newHashMap<>();payload.put("sub",userId);payload.put("iat",System.currentTimeMillis()/1000);payload.put("exp",System.currentTimeMillis()/1000+expireSeconds);StringheaderSeg=base64Url(header);StringpayloadSeg=base64Url(payload);StringsigningInput=headerSeg+"."+payloadSeg;byte[]sign=sm2.sign(signingInput.getBytes(StandardCharsets.UTF_8));Stringsignature=Base64.getUrlEncoder().withoutPadding().encodeToString(sign);returnsigningInput+"."+signature;}privateStringbase64Url(Objectobj){returnBase64.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。配好后大概是这样的:

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,加密数据头部带一个字节的版本号,轮换时新写的数据用新密钥,读数据时根据版本号取对应密钥。这样就能同时支持新旧两套密钥并行工作一段时间。

publicclassKeyHolder{privatestaticfinalMap<Integer,SM2>SM2_MAP=newConcurrentHashMap<>();privatestaticfinalMap<Integer,SM4>SM4_MAP=newConcurrentHashMap<>();privatestaticvolatileintCURRENT_VERSION=1;publicstaticSM4getSm4(intversion){returnSM4_MAP.get(version);}publicstaticSM4getCurrentSm4(){returnSM4_MAP.get(CURRENT_VERSION);}publicstaticvoidrotateKey(intversion,Stringsm4KeyHex,Stringsm2PrivHex,Stringsm2PubHex){SM4_MAP.put(version,newSM4(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/


需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询