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 为什么是SM2+SM4,而不是只用SM2?
这是一个常见的疑问。SM2本身是非对称加密,可以直接加密数据,为什么还要引入SM4?
- 性能瓶颈: 非对称加密(如SM2、RSA)的运算速度远慢于对称加密(如SM4、AES)。对于可能包含大量数据的请求体或响应体(比如一个列表查询结果),全程使用SM2加密解密会成为严重的性能瓶颈。
- 适用场景分离: 对称加密算法密钥短、速度快,适合加密“大数据”。非对称加密算法安全性基于数学难题,适合做密钥交换和数字签名。两者结合是业界最佳实践(类似TLS中RSA+AES的组合)。
- 方案灵活性: 采用混合加密后,每次请求可以动态生成一个随机的SM4会话密钥。这个密钥只用一次(或一个短会话),用SM2加密后传输。即使某一次的SM4密钥被破解,也不会影响其他会话的安全,实现了前向安全性。
在我们的实现中,SM4负责保护“数据内容”(Data),SM2负责保护“加密数据的钥匙”(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;charset=UTF-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的INCR+EXPIRE命令,针对“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 RedisTemplate<String, 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> <groupId>com.yourcompany</groupId> <artifactId>sm-encryption-spring-boot-starter</artifactId> <version>1.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 ApiResult<BusinessData> handleSecuredData(@RequestBody BusinessData data) { // 这里的data已经是拦截器解密后的明文对象了 // 处理业务逻辑... BusinessData result = someService.process(data); return ApiResult.success(result); // 这个result会被自动加密后返回给前端 } @GetMapping(\"/public-info\") public ApiResult<String> 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运行稳定,希望它也能帮你快速搞定国密合规需求。源码包我已经整理好了,如果你在集成过程中遇到其他问题,也欢迎一起交流。