SpringBoot国密SM2+SM4混合加密与验签方案实战
1. 项目概述与核心价值最近在做一个对数据安全要求比较高的内部系统甲方爸爸明确要求通信过程必须使用国密算法。这让我不得不把之前那套基于RSA/AES的加密方案推倒重来从头研究SM2和SM4。折腾了小半个月总算把一套轻量、可复用的SpringBoot前后端数据加密验签方案给跑通了。今天就把这套“开箱即用”的代码和踩过的坑分享出来核心就四件事用SM4加密业务数据保证机密性用SM2做签名验签确保数据完整性和不可抵赖性再加一套简单的接口防刷机制。源码已经打包好文末会给出获取方式你拿到后改改配置就能直接集成到自己的项目里。为什么非得用国密这不仅仅是政策合规的要求。在实际业务中尤其是涉及金融、政务、物联网等场景使用自主可控的加密算法是硬性门槛。SM2作为非对称算法在相同安全强度下密钥长度比RSA更短运算速度更快。SM4作为对称算法效率和AES相当但它是我们自己的标准。把这两者结合用SM4加密体量大的业务数据用SM2加密SM4的密钥并完成签名既能保证性能又能满足高安全等级的要求。这个项目就是基于SpringBoot把这一套流程封装成简单的注解和工具类让开发者在Controller层几乎无感地实现全链路加密通信。2. 整体架构与核心思路拆解2.1 技术选型与组件职责整个方案的核心是构建一个过滤器和切面组成的处理链对请求和响应进行自动化的加解密与验签。我选择了以下核心组件Hutool-crypto 这是国产工具库Hutool的加密模块它提供了对SM2、SM3、SM4等国密算法的友好封装API简洁避免了直接调用底层BC库Bouncy Castle的复杂性。这是我们加解密操作的基础。Spring Boot Starter 将核心逻辑封装成一个自定义的Starter。这样做的好处是其他项目只需要引入这个依赖进行简单的YAML配置就能自动装配所需的过滤器、工具类等Bean实现“开箱即用”极大降低了集成成本。Spring MVC Interceptor 与 Filter 我采用了两级拦截策略。Filter用于在最早阶段处理全局性的加解密需求例如防刷逻辑和请求体解密可以放在这里。而Interceptor拦截器则更灵活可以基于路径匹配方便我们对需要加密的接口和普通接口进行区分处理。自定义注解 定义如EnableSm2Sm4、EncryptResponse、DecryptRequest等注解。通过在Controller类或方法上添加这些注解来声明该接口是否需要启用加解密功能。这是实现“无侵入”或“低侵入”的关键业务代码只需要关注注解而不需要关心具体的加解密实现。整个数据流转的闭环是这样的前端发起请求时先用SM4加密业务数据报文体然后用SM2的私钥对“SM4密钥”和“业务数据的SM3摘要”进行签名将密文、签名、SM4密钥的密文等打包成一个特定的协议格式例如JSON发送给后端。后端收到后先用SM2公钥验签验证通过后再用SM2私钥解密出SM4密钥最后用SM4密钥解密出原始业务数据。响应过程则完全相反。2.2 为什么是SM2SM4而不是只用SM2这是一个常见的疑问。SM2本身是非对称加密可以直接加密数据为什么还要引入SM4性能瓶颈 非对称加密如SM2、RSA的运算速度远慢于对称加密如SM4、AES。对于可能包含大量数据的请求体或响应体比如一个列表查询结果全程使用SM2加密解密会成为严重的性能瓶颈。适用场景分离 对称加密算法密钥短、速度快适合加密“大数据”。非对称加密算法安全性基于数学难题适合做密钥交换和数字签名。两者结合是业界最佳实践类似TLS中RSAAES的组合。方案灵活性 采用混合加密后每次请求可以动态生成一个随机的SM4会话密钥。这个密钥只用一次或一个短会话用SM2加密后传输。即使某一次的SM4密钥被破解也不会影响其他会话的安全实现了前向安全性。在我们的实现中SM4负责保护“数据内容”DataSM2负责保护“加密数据的钥匙”Key并证明“这份数据是我发的”Signature。分工明确效率与安全兼顾。3. 核心模块实现细节与实操要点3.1 国密密钥对的管理与配置安全的基础是密钥。绝对禁止将私钥硬编码在代码中或提交到Git仓库。我们采用配置文件application.yml注入的方式并强烈建议在生产环境使用配置中心或环境变量。sm: encrypt: enabled: true # 总开关 sm2: public-key: \MFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAEX...你的SM2公钥\ # PEM格式去除头尾标识和换行 private-key: \MIGTAgEAMBMGByqGSM49AgEGCCqBHM9VAYItBHkwdwIBAQQg...你的SM2私钥\ # 同上 sm4: # SM4密钥和IV初始化向量建议定期更换。这里作为示例生产环境应动态生成或从安全处获取。 key: \0123456789ABCDEF0123456789ABCDEF\ # 32位十六进制字符串256位密钥 iv: \ABCDEF0123456789\ # 16位十六进制字符串CBC模式需要 exclude-paths: /api/public/**, /health, /swagger-ui/** # 排除不需要加密的路径注意这里展示的密钥是经过截断的示例。生成SM2密钥对可以使用Hutool的SmUtil.generateKeyPair()或者使用OpenSSL需支持国密命令生成。生成的PEM格式密钥需要去除-----BEGIN PRIVATE KEY-----和换行符拼接成一行字符串再配置。3.2 请求响应协议体设计前后端需要约定一个统一的加密数据交换格式。我设计了一个通用的EncryptedData类来承载。Data public class EncryptedData { /** * 加密后的业务数据SM4加密结果Base64编码 */ private String data; /** * SM4密钥密文使用SM2公钥加密后的结果Base64编码 * 注意此字段在“每次请求使用不同SM4密钥”的模式下是必需的。 * 如果使用固定的SM4密钥则无需传输此字段但安全性较低。 */ private String encryptedKey; /** * 数字签名对“原始数据SM3摘要”的SM2签名Base64编码 */ private String signature; /** * 时间戳用于防重放攻击 */ private Long timestamp; /** * 随机数用于防重放攻击 */ private String nonce; }前端需要按照这个结构组装数据。例如一个登录请求原始报文是{\username\:\admin\,\password\:\123456\}。前端需要随机生成一个SM4密钥sm4Key和IV。用sm4Key和IV加密原始报文得到data。用后端提供的SM2公钥加密sm4Key得到encryptedKey。计算原始报文的SM3摘要并用前端持有的SM2私钥签名得到signature。附上当前时间戳和随机数nonce。将整个EncryptedData对象作为请求体发送。3.3 加解密与验签拦截器实现这是最核心的部分。我们实现一个Sm2Sm4Interceptor继承HandlerInterceptorAdapter。Component public class Sm2Sm4Interceptor implements HandlerInterceptor { Autowired private Sm2Service sm2Service; // 封装SM2操作的Service Autowired private Sm4Service sm4Service; // 封装SM4操作的Service Autowired private AntiBrushService antiBrushService; // 防刷Service Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 判断该方法或类是否有DecryptRequest注解没有则直接放行 if (!needDecrypt(handler)) { return true; } // 2. 防刷校验基于IP、用户ID、接口路径等进行频率限制 if (!antiBrushService.check(request)) { response.setStatus(429); // Too Many Requests response.getWriter().write(\{\\\code\\\:429,\\\msg\\\:\\\请求过于频繁\\\}\); return false; } // 3. 读取并解析请求体为EncryptedData对象 EncryptedData encryptedData parseRequestBody(request); if (encryptedData null) { throw new RuntimeException(\加密数据格式错误\); } // 4. 防重放攻击校验检查timestamp和nonce if (!antiBrushService.checkReplay(encryptedData.getTimestamp(), encryptedData.getNonce())) { throw new RuntimeException(\请求已过期或重复\); } // 5. SM2验签 // 5.1 使用SM2公钥解密encryptedKey得到本次会话的SM4密钥sm4Key String sm4Key sm2Service.decrypt(encryptedData.getEncryptedKey()); // 5.2 使用sm4Key解密data得到原始业务数据明文originalData String originalData sm4Service.decrypt(encryptedData.getData(), sm4Key); // 5.3 计算originalData的SM3摘要 String digest SmUtil.sm3(originalData); // 5.4 使用SM2公钥验证签名验证digest是否与signature匹配 boolean verifySuccess sm2Service.verify(digest, encryptedData.getSignature()); if (!verifySuccess) { throw new RuntimeException(\签名验证失败数据可能被篡改\); } // 6. 验签通过将解密后的原始数据重新设置到Request的Attribute中供后续的RequestBody反序列化使用 // 这里需要一个自定义的HttpServletRequestWrapper来覆盖getInputStream方法 request.setAttribute(\DECRYPTED_BODY\, originalData); return true; } Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { // 判断是否需要加密响应EncryptResponse注解 if (!needEncrypt(handler)) { return; } // 获取Controller方法返回的原始对象将其序列化为JSON字符串 Object originalBody ...; // 需要借助ResponseBodyAdvice或自定义包装器获取 String originalJson JsonUtil.toJsonStr(originalBody); // 生成随机的SM4会话密钥 String sm4SessionKey generateRandomSm4Key(); // 用SM4加密原始响应数据 String encryptedData sm4Service.encrypt(originalJson, sm4SessionKey); // 用SM2公钥加密SM4会话密钥 String encryptedKey sm2Service.encrypt(sm4SessionKey); // 生成原始数据的SM3摘要并用SM2私钥签名 String signature sm2Service.sign(SmUtil.sm3(originalJson)); // 构建响应EncryptedData对象 EncryptedData responseData new EncryptedData(); responseData.setData(encryptedData); responseData.setEncryptedKey(encryptedKey); responseData.setSignature(signature); responseData.setTimestamp(System.currentTimeMillis()); responseData.setNonce(generateNonce()); // 将responseData写入响应流 response.setContentType(\application/json;charsetUTF-8\); response.getWriter().write(JsonUtil.toJsonStr(responseData)); // 重要清空原有的响应防止原始数据泄露 response.resetBuffer(); } }这里的关键点在于preHandle中我们需要一个自定义的HttpServletRequestWrapper来替换掉原始的InputStream使得Spring的RequestBody能读到我们解密后的数据。在postHandle中则需要配合ControllerAdvice和ResponseBodyAdvice接口来拦截所有ResponseBody的返回值进行统一的加密包装。3.4 接口防刷Anti-Brush策略实现防刷不仅仅是限流它是一个综合策略。我实现了以下几个层面频率限制Rate Limiting 使用Guava的RateLimiter或Redis的INCREXPIRE命令针对“IP接口路径”或“用户ID接口路径”做滑动窗口计数。例如同一个IP对/api/login在60秒内最多请求10次。防重放攻击Replay Attack 利用请求协议中的timestamp和nonce。服务端维护一个已使用nonce的缓存如Redis设置合理的过期时间比如5分钟。收到请求后首先检查timestamp是否在可接受的时间窗口内如服务器时间±5分钟防止过期的请求被处理。然后检查nonce是否在缓存中存在如果存在则认为是重放请求直接拒绝。如果不存在则将nonce存入缓存。行为模式分析简单版 对于登录、注册、短信验证码等关键接口可以记录失败次数。短时间内连续失败超过阈值则临时锁定该IP或账号一段时间。Service public class AntiBrushServiceImpl implements AntiBrushService { Autowired private RedisTemplateString, String redisTemplate; private static final String RATE_LIMIT_KEY_PREFIX \rate:limit:\; private static final String NONCE_KEY_PREFIX \nonce:\; private static final long TIME_WINDOW_MS 5 * 60 * 1000L; // 5分钟 Override public boolean check(HttpServletRequest request) { String ip getClientIp(request); String path request.getRequestURI(); String key RATE_LIMIT_KEY_PREFIX ip \:\ path; // 使用Redis实现滑动窗口计数 Long current System.currentTimeMillis(); Long windowStart current - 60000; // 过去60秒 redisTemplate.opsForZSet().removeRangeByScore(key, 0, windowStart); // 移除旧数据 Long count redisTemplate.opsForZSet().count(key, windowStart, current); if (count ! null count 10) { // 阈值10次 return false; } redisTemplate.opsForZSet().add(key, String.valueOf(current), current); redisTemplate.expire(key, 70, TimeUnit.SECONDS); // 设置稍长一点的过期时间 return true; } Override public boolean checkReplay(Long timestamp, String nonce) { // 检查时间戳 long currentTime System.currentTimeMillis(); if (Math.abs(currentTime - timestamp) TIME_WINDOW_MS) { return false; } // 检查随机数是否已使用 String nonceKey NONCE_KEY_PREFIX nonce; Boolean setSuccess redisTemplate.opsForValue().setIfAbsent(nonceKey, \used\, 5, TimeUnit.MINUTES); return Boolean.TRUE.equals(setSuccess); } }4. 源码结构与集成步骤4.1 项目源码目录结构拿到源码后你会看到如下核心结构。我将其设计为一个独立的模块方便你直接引入到父工程中。sm-encryption-spring-boot-starter ├── src/main/java │ └── com │ └── yourcompany │ └── sm │ ├── SmEncryptionAutoConfiguration.java // 自动配置类 │ ├── annotation │ │ ├── EnableSm2Sm4.java // 启用注解 │ │ ├── EncryptResponse.java │ │ └── DecryptRequest.java │ ├── config │ │ └── SmProperties.java // 配置属性绑定类 │ ├── constant │ │ └── SmConstant.java │ ├── core │ │ ├── Sm2Service.java // SM2服务 │ │ ├── Sm4Service.java // SM4服务 │ │ └── AntiBrushService.java // 防刷服务 │ ├── interceptor │ │ └── Sm2Sm4Interceptor.java // 核心拦截器 │ ├── resolver │ │ └── DecryptedRequestBodyResolver.java // 解密请求体解析器 │ ├── advice │ │ └── EncryptedResponseBodyAdvice.java // 加密响应体通知 │ └── util │ └── SmKeyUtil.java // 密钥工具类 ├── src/main/resources │ └── META-INF │ └── spring.factories // Spring Boot自动装配入口 └── pom.xml // 依赖管理hutool-all, spring-boot-starter-web, spring-boot-starter-data-redis等4.2 三步集成到你的SpringBoot项目假设你的主项目名为my-application。第一步引入依赖将sm-encryption-spring-boot-starter模块安装到本地Maven仓库或者部署到私服。然后在主项目的pom.xml中引入dependency groupIdcom.yourcompany/groupId artifactIdsm-encryption-spring-boot-starter/artifactId version1.0.0/version /dependency第二步添加配置在你的application.yml中配置SM2公钥私钥、SM4密钥以及排除路径。sm: encrypt: enabled: true sm2: public-key: \你的公钥\ private-key: \你的私钥\ sm4: key: \0123456789ABCDEF0123456789ABCDEF\ # 生产环境请务必更换 iv: \ABCDEF0123456789\ exclude-paths: /v3/api-docs/**, /webjars/**, /doc.html, /actuator/health第三步启用并注解接口在主启动类上添加EnableSm2Sm4注解来启用整个功能。SpringBootApplication EnableSm2Sm4 public class MyApplication { public static void main(String[] args) { SpringApplication.run(MyApplication.class, args); } }在需要加密传输的Controller方法上添加注解RestController RequestMapping(\/api/secured\) public class SecuredController { PostMapping(\/submit\) DecryptRequest // 声明此接口需要解密请求体 EncryptResponse // 声明此接口需要加密响应体 public ApiResultBusinessData handleSecuredData(RequestBody BusinessData data) { // 这里的data已经是拦截器解密后的明文对象了 // 处理业务逻辑... BusinessData result someService.process(data); return ApiResult.success(result); // 这个result会被自动加密后返回给前端 } GetMapping(\/public-info\) public ApiResultString getPublicInfo() { // 这个方法没有加解密注解走普通HTTP流程 return ApiResult.success(\This is public info.\); } }集成完毕。启动你的应用访问/api/secured/submit接口你会发现请求和响应都变成了EncryptedData格式的密文而业务代码完全感知不到加解密过程。5. 常见问题排查与性能优化实录在实际部署和压测过程中我遇到了几个典型问题这里把排查思路和解决方案记录下来。5.1 问题一验签失败提示“签名无效”这是最高频的问题。可能原因1前后端密钥不匹配。这是最根本的原因。务必确保后端用于验签的SM2公钥与前端用于签名的SM2私钥是配对的。检查密钥是否在配置过程中被意外修改、截断或添加了多余字符如换行符、空格。建议编写一个单元测试用固定的明文和密钥对分别测试后端的签名/验签方法确保自验签能通过。可能原因2摘要算法或编码不一致。签名是针对“数据的摘要”进行的。必须确保前后端计算摘要的算法一致我们用的是SM3并且对原始数据的处理一致例如JSON字符串是否进行了紧凑化处理空格、字段顺序是否会影响最终字符串。解决方案在签名前对原始业务数据字符串进行一次规范化处理例如使用Jackson的ObjectMapper进行序列化确保每次生成的JSON字符串完全一致。可能原因3签名数据Sign Data混淆。SM2签名时是对“原始数据的SM3摘要值”进行签名而不是对原始数据本身直接签名。确认前端没有签错对象。排查工具可以临时在后端拦截器验签失败的地方将收到的encryptedData、解密后的originalData、计算出的digest都打印到日志中生产环境务必注意日志脱敏。然后让前端提供他们用于生成签名的原始数据、计算出的摘要和私钥在本地用工具如Hutool离线验证进行比对。5.2 问题二加解密性能成为瓶颈在压测时发现TPS上不去CPU占用高且主要消耗在加解密环节。优化点1缓存SM2引擎。SM2密钥对的加载和密码器Cipher的初始化是比较耗时的。不要在每次加解密时都重新创建。可以将SM2对象Hutool的SmUtil.sm2()结果作为Bean单例注入在整个应用生命周期内复用。优化点2区分读写操作使用不同密钥。验签使用公钥是只读操作解密使用私钥是写操作。在高并发读场景下使用私钥的操作会成为瓶颈。如果架构允许可以考虑部署多个无状态的应用实例它们共享同一对密钥。或者对于纯验签的服务节点可以只配置公钥不配置私钥提升安全性。优化点3评估是否所有接口都需要全链路加密。对于内部健康检查、监控端点/actuator/**、Swagger文档等接口务必通过exclude-paths配置排除。对于某些查询类接口如果响应数据不敏感可以考虑只做请求签名验签保证请求来源可信响应不做加密提升性能。优化点4调整SM4工作模式。默认使用SM4/CBC/PKCS5Padding。CBC模式虽然安全但无法并行加密。对于大量数据的加密如果性能要求极高且场景允许可以考虑使用SM4/ECB/PKCS5Padding注意ECB模式对重复数据块不安全或者更优的SM4/GCM模式同时提供加密和完整性校验。GCM模式在Java 8及以上版本通过BC库支持。5.3 问题三前端集成困难前端同学反馈不知道如何组装加密请求。解决方案提供前端SDK或详细示例。我为此编写了一个简单的JavaScript/TypeScript工具函数示例并提供了Node.js的测试脚本。关键库推荐使用sm-crypto这个优秀的国密算法JavaScript库。示例代码import { sm2, sm4 } from sm-crypto; // 假设后端提供的SM2公钥16进制字符串不带04前缀 const publicKey 04...; // 前端生成的SM2密钥对仅用于演示实际应由后端分配或固定 const keyPair sm2.generateKeyPairHex(); const privateKey keyPair.privateKey; // 前端私钥用于签名 // const publicKey keyPair.publicKey; // 前端公钥后端需要用它来验签如果双向认证 function buildEncryptedRequest(payload) { // 1. 生成随机SM4密钥和IV const sm4Key randomHex(32); // 32字节十六进制字符串 const iv randomHex(16); // 16字节十六进制字符串 // 2. SM4加密业务数据 const dataCipher sm4.encrypt(JSON.stringify(payload), sm4Key, { mode: cbc, iv: iv }); // 3. SM2加密SM4密钥 (使用后端公钥) const encryptedKey sm2.doEncrypt(sm4Key, publicKey, 1); // 1代表C1C3C2格式 // 4. 计算SM3摘要并签名 (使用前端私钥) const msgHash sm3(JSON.stringify(payload)); // 需要实现或引入sm3函数 const signature sm2.doSignature(msgHash, privateKey); // 5. 组装请求体 return { data: dataCipher, encryptedKey: encryptedKey, signature: signature, timestamp: Date.now(), nonce: generateNonce(), iv: iv // 如果IV也动态生成需要传给后端 }; }提供测试接口在后端提供一个/api/encrypt/test的明文接口接收前端加密后的数据并返回解密和验签的结果方便前端联调。5.4 问题四Redis宕机导致防重放和限流失效防刷服务强依赖Redis一旦Redis不可用checkReplay和check方法会抛出异常可能导致接口完全不可用。降级策略在AntiBrushService的实现中对Redis操作进行try-catch。当捕获到Redis连接异常时可以降级为本地内存缓存如Caffeine进行简单的频率限制或者直接记录错误日志并放行根据安全等级权衡。同时需要配置完善的Redis监控和告警。集群与持久化生产环境务必使用Redis哨兵或集群模式并配置合理的持久化策略保证高可用。这套方案从设计到实现再到问题排查核心思想是在安全、性能和易用性之间寻找平衡。国密算法的集成本身并不复杂难的是如何将其优雅、无感地融入到现有的Web开发流程中并处理好各种边界情况。经过几个项目的打磨目前这套Starter运行稳定希望它也能帮你快速搞定国密合规需求。源码包我已经整理好了如果你在集成过程中遇到其他问题也欢迎一起交流。