资讯动态

国密SM4前后端传参加密实战:Spring Boot过滤器与前端对齐指南

发布时间:2026/10/22 10:58:48 来源:尧图企业网站定制
如果你在浏览器里按下F12能看到自己系统的登录密码原样躺在Payload里那你多半和我一样迟早要面对“传参加密”这个需求。前阵子接手的一个老项目就出了这么档子事——接口全是Spring Boot写的前后端传参一直是明文JSON线上被安全扫描审出一堆敏感字段客户要求一个月内整改。折腾下来最后敲定的方案是用国密SM4对请求体和响应体做整包加解密。这篇文章就是那段时间踩坑、填坑的完整记录从算法选型、后端过滤器实现、前端脚本对齐到线上问题排查都有给同样被“前后端传参加密”这条需求卡住的同学做个参考。1. 为什么前后端传参要加密先想清楚到底在防什么1.1 明文接口被扒光的三个场景很多人第一反应是“我都有HTTPS了为什么还要在应用层做加密”实际上HTTPS解决的是传输链路被窃听的问题但接口参数在到达服务器之前、在浏览器内存和前端调试面板里依然是明文的这中间有几个非常现实的风险点抓包即裸奔HTTPS有证书校验但用户自己装的代理工具比如常见调试工具可以安装自定义根证书从而解密流量。这种情况下请求参数、响应数据都是明文可见的。只要用户配合一次接口的报文格式就全暴露了。日志侧泄露Nginx访问日志、网关日志、应用日志如果没有做脱敏POST body里的手机号、身份证号会干净地躺在日志文件里。日志文件一旦被拖走或同步到第三方存储等于数据批量泄露。前端调试面板可见浏览器DevTools的Network面板里能看到完整请求和响应。对于内部系统一个普通员工按一下F12就能看到别人的敏感数据字段虽然这不是高烈度攻击但审计要求往往不允许这种“裸奔”。我接手的那套系统就是第三种情况居多但因为涉及公网可访问的查询接口第一种情况随时可能变成现实。客户的安全要求明确敏感字段在传输过程中即使被抓包也不能直接还原成明文可读的内容。这就是应用层传参加密的直接动机。1.2 什么样的系统才值得上这套方案先说句泼冷水的话不是所有项目都应该做前后端传参加密。我见过有的团队为了“显得安全”把加解密逻辑塞进所有接口结果性能下降、排错困难还破坏了正常调试流程。值得做的场景通常具备以下特征系统涉及个人敏感信息比如手机号、身份证号、银行卡号、家庭住址系统需要满足等保测评或行业安全合规要求明文接口无法过关接口面向公网或跨网段调用无法保证调用方环境完全受控有第三方监控或爬虫在批量拉取数据加密后能显著提高拉取成本。如果只是内部管理后台、数据不敏感、访问完全在内网那上HTTPS加上服务端权限控制就够了。加解密不是越多越好而是“用在该用的地方”。1.3 为什么是SM4而不是AES或RSA选型时组里有过一轮争论有人建议AES有人说直接上RSA。最后定的是SM4理由很务实国产算法合规金融、政务类项目对密码算法有明确管理要求商用密码应用安全性评估里会检查是否使用合规算法。SM4作为公开发布的国密对称算法标准符合合规要求备查时不需要额外解释“为什么用AES”。对称加密性能远高于非对称接口传参的数据量虽然不大但AES和SM4这类对称算法在速率上远高于RSA、SM2这类非对称算法。RSA更适合加密密钥本身不适合直接加密业务报文。如果要用非对称完成“前后端传参加密”常规做法是前端用后端公钥加密一个随机密钥再用这个密钥做对称加密本质还是对称加密兜底。SM4在Java侧与前端生态都已成熟Java端可以通过密码学库扩展实现前端也有对应的SM4实现库双方对齐加解密参数后完全可以跑通。在我们这个场景里客户端与服务端是一对一关系不需要复杂的密钥协商对称加密就够了。用SM4而不用AES更多是合规和统一技术路线的考虑。2. SM4算法与Java端落地基础选型、依赖和参数对齐2.1 SM4关键参数速览SM4是一种分组密码算法分组长度128位16字节密钥长度也是128位16字节。做应用层封装时真正影响互操作性的不是算法本身而是几个配套参数参数常见选择说明工作模式CBC / ECBCBC需要初始向量IV安全性更高ECB相同明文会得到相同密文一般不推荐填充方式PKCS5Padding / PKCS7Padding两者在分组密码上等价Java命名里常写PKCS5Padding输出编码Base64 / Hex密文是二进制需要转成可传输的字符串字符集UTF-8保证前后端中文不乱码必须两端一致密钥16字节长度可随机生成前端和后端持有同一份前端库里的“CBC模式”对应Java里的“CBC”但要注意部分前端库默认填充方式或者默认输出格式可能和后端不一致这一块是最大的坑后面专门讲。2.2 Java侧引入BouncyCastleJava标准库默认不自带SM4算法业界最常见的做法是利用BouncyCastle提供的实现。在Spring Boot项目里的pom.xml加依赖dependency groupIdorg.bouncycastle/groupId artifactIdbcprov-jdk18on/artifactId version1.78/version /dependency我这边的实践是只在需要做国密算法的模块引入它而不是全局依赖。依赖引入后并不需要额外注册Provider使用JCE标准接口时只要指定算法名BouncyCastle会通过Security的偏好机制自动被调用。如果你在项目里遇到No such algorithm: SM4的报错多半是依赖没引进来或者版本太旧。2.3 模式与填充ECB和CBC的坑我知道网上很多博客直接用ECB模式代码很好写密钥丢进去就完事。但ECB模式有一个特征相同的明文分组会得到相同的密文分组。接口报文里有大量重复字段比如JSON里频繁出现的userId、name这种keyECB加密后在密文层面会留下明显的重复块规律虽然不能直接还原内容但足以给分析者提供线索安全强度打了折扣。CBC模式引入了初始向量IV相同明文在不同IV或不同会话下会产生不同密文推荐优先使用。但CBC不是免费的前后端必须同时约定IV的值而且IV本身不需要保密可以明文传输前后端写死同一个值在多数业务场景里也够用。填充方式就记住一点PKCS5Padding在Java和多数前端实现里对于16字节分组的SM4实际效果等同于PKCS7Padding不用过于纠结命名。真正的坑出在“前端库默认填充是NoPadding”或者“前端加密后没补足到16字节”这类情况后面排查章节会展开。3. 后端完整实现从请求体解密到响应体加密3.1 定义统一的SM4工具类后端封装一个工具类所有加解密都走同一个入口避免Controller里散落一堆Cipher.getInstance(SM4)。我这边简化后的核心方法如下import org.bouncycastle.jce.provider.BouncyCastleProvider; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.Security; import java.util.Base64; public final class Sm4Util { static { if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) null) { Security.addProvider(new BouncyCastleProvider()); } } private static final String TRANSFORMATION SM4/CBC/PKCS5Padding; public static String encrypt(String plainText, String hexKey, String hexIv) { try { byte[] keyBytes hexStringToByteArray(hexKey); byte[] ivBytes hexStringToByteArray(hexIv); Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyBytes, SM4), new IvParameterSpec(ivBytes)); byte[] encrypted cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encrypted); } catch (Exception e) { throw new IllegalStateException(SM4加密失败, e); } } public static String decrypt(String cipherBase64, String hexKey, String hexIv) { try { byte[] keyBytes hexStringToByteArray(hexKey); byte[] ivBytes hexStringToByteArray(hexIv); Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, SM4), new IvParameterSpec(ivBytes)); byte[] decrypted cipher.doFinal(Base64.getDecoder().decode(cipherBase64)); return new String(decrypted, StandardCharsets.UTF_8); } catch (Exception e) { throw new IllegalStateException(SM4解密失败, e); } } private static byte[] hexStringToByteArray(String hex) { int len hex.length(); byte[] data new byte[len / 2]; for (int i 0; i len; i 2) { data[i / 2] (byte) ((Character.digit(hex.charAt(i), 16) 4) Character.digit(hex.charAt(i 1), 16)); } return data; } }这里有个细节值得说明我把密钥和IV都当成十六进制字符串传入而不是直接传Base64字符串。原因很简单——前端sm-crypto库在处理密钥时十六进制字符串的兼容性最好可以减少后面联调时“密钥格式对不上”的问题。3.2 请求包装与解密过滤器拿到工具类后真正的难点来了如何让Spring Boot自动接收解密后的参数同时尽量不改Controller代码。我的方案是自定义过滤器加HttpServletRequestWrapper。思路是这样的过滤器只对加了特定标记的接口生效从请求Body里读出Base64密文解密成JSON字符串再包装成一个“能从这个字符串里读出内容”的新Request对象。Controller层完全不知道中间发生了什么依然按平时的RequestBody接收对象。import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequestWrapper; import java.io.*; public class DecryptRequestWrapper extends HttpServletRequestWrapper { private final byte[] decryptedBody; public DecryptRequestWrapper(HttpServletRequest request, String plainBody) { super(request); this.decryptedBody plainBody.getBytes(StandardCharsets.UTF_8); } Override public ServletInputStream getInputStream() { ByteArrayInputStream byteArrayInputStream new ByteArrayInputStream(decryptedBody); return new ServletInputStream() { Override public int read() { return byteArrayInputStream.read(); } Override public boolean isFinished() { return byteArrayInputStream.available() 0; } Override public boolean isReady() { return true; } Override public void setReadListener(ReadListener readListener) { // 按需实现 } }; } Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } }过滤器里读取原始Body并解密的逻辑我单独提取成了方法方便复用public class Sm4Filter implements Filter { private final String sm4Key; private final String sm4Iv; Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; if (shouldDecrypt(httpRequest)) { String cipherBody readBody(httpRequest); String plainBody; try { plainBody Sm4Util.decrypt(cipherBody, sm4Key, sm4Iv); } catch (Exception e) { throw new ServletException(解密密文失败, e); } DecryptRequestWrapper wrappedRequest new DecryptRequestWrapper(httpRequest, plainBody); chain.doFilter(wrappedRequest, response); } else { chain.doFilter(request, response); } } }readBody需要注意编码问题必须用request.getCharacterEncoding()回落UTF-8读取否则遇到中文可能从源头就乱了。3.3 响应体重写与加密输出请求能解密还不够响应也得加密返回否则前端拿到的还是明文JSON。实现方式和请求侧对称包装HttpServletResponse把原本要写出去的明文拦截到内存缓冲区里等Controller执行完了再统一加密输出。核心是重写响应对象的getOutputStream()和getWriter()让它们写入一个ByteArrayOutputStream。等过滤器链路结束后把缓冲区里的明文JSON加密后设置响应头Content-Type和Content-Encoding之类的标记再把密文写回真正的响应流。这里最容易翻车的是Content-Length。Spring Boot或Servlet容器在正常响应时可能会自动设置长度如果我们先写明文再写密文长度不一致就会导致客户端读取截断。稳妥做法是显式调用response.setHeader(Content-Length, String.valueOf(cipherBytes.length))强制修正。3.4 用注解控制加密范围全量加密是灾难调试和排错会非常痛苦。我用的方案是自定义一个EncryptApi注解只有标注在Controller接口上的方法才走加解密包含三个开关属性encryptRequestBody()是否需要解密请求体默认trueencryptResponseBody()是否需要加密响应体默认trueskipForSwagger()是否在本地调试或Swagger环境跳过默认false。过滤器无法直接拿到方法注解需要通过Spring的HandlerMapping解析到HandlerMethod后再判断这要求过滤器内部注入ApplicationContext并在运行时获取路由对应的Handler。如果项目结构允许也可以改用HandlerInterceptor但要小心拦截器对静态资源、错误页的覆盖范围不如过滤器广。两种方式我都试过最终选择过滤器加注解组合因为拦截器在某些异常场景下处理不到响应体而过滤器能更早地拿到请求和响应。4. 前端配合实战sm-crypto与后端对齐的关键细节4.1 前端加解密调用方式前端我们最终选了sm-crypto这个库它同时支持SM2、SM3、SM4其中SM4的加解密调用非常简洁import { sm4 } from sm-crypto; const KEY 0123456789abcdef0123456789abcdef; // 十六进制32个字符对应16字节 const IV 0123456789abcdef; // 十六进制16个字符对应8字节 export function encryptData(plainText) { return sm4.encrypt(plainText, KEY, { iv: IV, mode: cbc, output: base64 }); } export function decryptData(cipherBase64) { return sm4.decrypt(cipherBase64, KEY, { iv: IV, mode: cbc, output: string }); }加密时用output: base64解密时用output: string这两个选项是对齐后端的核心。如果没写outputsm-crypto默认输出十六进制字符串后端用Base64解码就会直接失败。4.2 模式、填充、密钥编码的严格对齐联调阶段我们遇到了不止一次“前端加密、后端解密出来是乱码”的情况每次排查到最后几乎都是同一个原因某个参数没对齐。我把必须对齐的项列成了一张检查表每次联调前先过一遍对齐项后端值前端值工作模式CBCcbcIV十六进制字符串iv: 十六进制字符串密钥十六进制字符串十六进制字符串输出格式Base64字符串output: base64填充PKCS5Padding插件默认PKCS7即可字符集UTF-8UTF-8最难发现的是IV长度问题。SM4的IV要求16字节十六进制表示就是32个字符。我们一开始用的IV只有8字节16个十六进制字符前端库竟然也能加密输出后端解密时报BadLengthException。这类报错信息比较隐晦第一次遇到时完全想不到是IV长度不够。4.3 密钥分发与登录态绑定对称加密最头疼的就是密钥分发。前后端各持一份同一个密钥意味着密钥的“配送链条”越长泄露面越大。我的经验是分两层来处理部署期注入前端构建配置里不直接写死密钥而是由部署平台在构建时注入到运行时配置后端也从环境变量或配置中心读取不放进源码仓库。这样即使源码泄露密钥也不会跟着泄露。会话级换钥进阶方案登录成功后服务端生成一次性会话密钥用SM2非对称加密下发给前端后续业务参数都用会话密钥做SM4加解密。这个方案安全性更好但实现复杂度上了一个台阶。如果项目还在起步阶段先用固定密钥做整包加密后续再演进成会话级换钥也不迟。我这边前期为了赶整改节点采用的是固定密钥方案但在密钥存储上严格隔离了测试环境和生产环境。上线后第一件事就是确认生产密钥和测试密钥不一样这一点千万不能贪省事。5. 实测中踩过的坑乱码、截断与拦截器失效排查5.1 坑一中文乱码问题居然在读取请求体第一次联调时前端传英文和数字一切正常但只要报文里出现中文后端解出来就是一堆问号。我一开始怀疑是SM4解密环节出了问题单独用工具类加密中文再解密发现完全正常。排查链路一步步往下走最后定位到过滤器的readBody方法上。问题不在解密而在读取原始请求体时没有指定字符集。如果原始请求被Servlet容器按ISO-8859-1处理中文字节就被错误解码了。修改方式是在读取时固定用request.getCharacterEncoding()没有就默认UTF-8private String readBody(HttpServletRequest request) throws IOException { StringBuilder sb new StringBuilder(); String line; try (BufferedReader reader request.getReader()) { while ((line reader.readLine()) ! null) { sb.append(line); } } return sb.toString(); }getReader()本身已经按照请求字符集配置创建但前提是请求头里带了Content-Type: application/json;charsetUTF-8前端必须设置否则容器可能按默认字符集处理。这个问题排查了两个小时最后就是一个请求头字符集配置的事。5.2 坑二响应数据被截断Content-Length没更新响应侧联调时前端解密接口返回数据发现JSON解析总是报错有时候能拿到一半字段有时候拿到的是一串乱码。打开Network面板对比发现密文长度明显比明文长度长但响应头里的Content-Length还是明文的长度前端按这个长度读数据读到一半就断了。修复思路是响应体加密完成后强制重设Content-Lengthbyte[] cipherBytes cipherText.getBytes(StandardCharsets.UTF_8); response.setHeader(Content-Length, String.valueOf(cipherBytes.length)); response.getOutputStream().write(cipherBytes); response.getOutputStream().flush();这里还有一个隐藏问题如果框架在过滤器执行前已经提交了响应比如sendError再写加密输出就可能抛IllegalStateException。我的处理方式是捕获异常回退为明文输出并记录告警日志至少保证接口在异常情况下可访问而不是彻底挂死。5.3 坑三过滤器没覆盖到错误路径和静态资源改造上线后运维反馈有些接口在请求异常时报错错误响应是明文而正常响应是密文前端统一解密时收到明文的错误信息直接解析失败。排查后发现问题出在过滤器注册的URL模式上。我只匹配了/api/*但Spring Boot处理参数校验失败、404、500时返回体可能走了/error端点根本不会经过/api/*过滤器。解决方案有两个方向一是把过滤器URL模式放宽到/*在过滤器中再判断是否属于需要加密的接口二是对错误响应做同样的加解密处理和正常接口保持格式一致。我选择了前者因为错误响应也应该保持密文格式否则前端解析逻辑必须区分“响应是明文还是密文”非常容易出岔子。5.4 坑四白名单接口和调试接口的特殊处理还有一个差点被忽略的场景有些内部接口是给其他服务调用用的比如定时任务回调、第三方Webhook它们没有前端加解密能力如果被过滤器强制解密就会直接报错。处理方式是维护一个“白名单路径集合”在过滤器里优先判断白名单内的请求原样透传不进行加解密处理。Swagger调试也建议默认跳过否则你在Swagger页面里提交明文会被后端当密文解当场报错。我自己的习惯是通过请求头里的一个标志位区分比如本地调试时前端添加X-Skip-Crypto: true后端过滤器检测到就跳过这样不需要频繁改配置。6. 性能验证与安全边界加密不是“上了就完事”6.1 加解密耗时实测记录上线前组里最担心的是加解密拖慢接口响应。我在模拟环境下对典型业务报文做了简单压测报文大小在1KB到10KB之间结论是SM4加解密本身的耗时在这个量级下可以忽略不计。报文大小加密耗时解密耗时1KB 1ms 1ms10KB约 1-2ms约 1-2ms100KB约 5-10ms约 5-10ms真正影响性能的不是算法本身而是需要读取完整请求体、再包装Request、再重新解析JSON的链路开销。尤其要注意如果原始请求体已经很大再经过一次Base64解码、解密、重新构建Request内存占用会上升。所以在设计上要限制加解密接口的报文大小一般超过10MB的请求体就不适合做整包应用层加解密了应改用文件加密或流式加密方案。6.2 密钥不落盘的几种方式密钥管理是整个方案里最容易被忽视的一环。我见过有项目把密钥直接写在application.yml里提交到代码仓库这等于白加密。实际落地时至少要做到环境变量注入后端从System.getenv(SM4_KEY)读取密钥不在任何配置文件中硬编码配置中心管理如果项目已经用了配置中心把密钥放在配置中心独立的配置分组里并设置访问权限定期轮换机制固定密钥方案建议每三个月轮换一次前后端同时更换。轮换会有一个切换窗口稳妥做法是支持双密钥新接口用新密钥老接口短期内保留旧密钥解密等客户端全部升级后再下线旧密钥。6.3 这套方案防住了什么、没防住什么最后说点实在的。前后端传参加密绝对不是“银弹”它的安全价值局限在特定层面防住了网络抓包直接看到明文业务参数应用日志在不脱敏的情况下泄露请求体前端调试面板直接展示敏感字段非专业爬虫直接解析接口格式。没防住重放攻击攻击者把密文原样重放后端无法区分新旧请求恶意注册和批量请求因为加密只能保护数据内容不能保护接口频率服务端被攻破后的数据泄露密钥存在服务端服务器沦陷一切保护都失效XSS脚本拿到解密后的明文再向外发送前端内存里的数据始终是可读的。所以正确的组合姿势是HTTPS依然要开应用层传参加密作为敏感字段的补充保护接口层面再加验签、防重放、限流。加密解决的是“参数被直接看到”的问题而不是“接口被恶意调用”的问题。我自己在跑完整个项目后的体会是传参加密最值钱的部分不是那几行加解密代码而是在这个过程中把请求链路、响应链路、异常链路和调试链路重新理了一遍。很多系统原本的接口规范是模糊的改造加密后反而逼着团队把报文格式、字符集、错误码约定全部明确了下来。如果你们团队准备做这个改造建议先别急着写代码花半天时间把接口清单和白名单边界理清楚后面能省出好几天的联调时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑