资讯动态

Spring Boot前后端接口传参基于国密SM4加密方案实战

发布时间:2026/10/21 5:00:39 来源:尧图企业网站定制
前后端联调的那段日子前端同事把接口报文往我一摆我盯着 HTTP 抓包工具里的明文请求愣了半秒。里面不光有业务参数还把账号、手机号、订单号全裸奔在传输链路里。客户方要求核心接口的数据不能在传输过程中露出明文等保评审也明确提到敏感字段要加密。项目用的是 Spring Boot后端 Java、前端 Vue大家第一反应都是“上 HTTPS”。但 HTTPS 只保护传输通道不能解决入口被中间人代理或者前端被篡改后抓包的问题而且合规那边点名要国密算法。于是我基于国密 SM4 做了一套“前后端传参加密解密”的完整方案。核心思路很简单请求发出的那一刻前端把参数加密后再发给后端后端拿到密文解密后走正常业务逻辑返回结果时后端把响应体加密前端收到密文再解出来渲染。这套方案适合所有用 Spring Boot 开发接口、前端用 axios 这类 HTTP 库的项目不管你的系统是 B 端内部的管理平台还是 C 端面向用户的业务系统只要想给报文加一层“业务层加密”都可以直接参考这套落地做法。1. 整体方案设计为什么选择 Filter 拦截器 注解标记这套组合1.1 需求场景拆解HTTPS 都布置了为什么还要加一层先说一个我反复跟团队确认过的结论HTTPS 解决的是“链路窃听”问题但它解决不了“客户端到服务端之间的全链路可信”问题。你在浏览器里打开开发者工具网络面板里照样能看到请求参数加载完的 JS 也照样能被下下来跑一遍。对于合规要求严格的场景明文出现在客户端环境里这件事本身就是风险。我遇到的实际需求比这个更进一步系统要接受第三方审计审计方明确把“核心业务接口的请求参数和响应数据不得明文传输”写进了整改项。这就不是 HTTPS 能一句带过的必须靠业务层加解密来兜底。所谓业务层加解密就是在应用协议内部再做一层加密让 HTTP 层面的抓包工具只能看到一坨 Base64 字符串真正的业务参数藏在这层壳里。基于这个需求我设定了几个技术约束第一不能影响现有 Controller 的编码方式最好做到接口代码零侵入第二GET 和 POST 两种传参方式都要覆盖第三前后端各自有一套独立的工具类加解密规则必须完全对齐第四密钥至少要能周期性轮换不能写死在代码里一用就是三年。这些都指向了“拦截器 注解 请求包装”的实现模式。1.2 方案选型对比SM4 加密、拦截器还是请求体通知密码算法的选型几乎没有悬念。SM4 是国密标准中的对称分组密码算法分组长度 128 位密钥长度也是 128 位加解密速度非常快特别适合接口传参这种“每次请求小数据量、高频调用”的场景。业界和它对比的无非是 AES、DES、3DES。DES 早就被证明不安全3DES 性能太差AES 虽然通用性极好但审计和合规不认属于“没得选也得换”。SM4 的轮函数结构和 AES 有相似之处但它是完全自主设计的标准配套的 GM/T 系列标准在等保和密评里是加分项这也是我这次坚持用 SM4 的根本原因。实现方式的选型要更纠结一些。Spring Boot 里做请求解密有两条路一条是实现 RequestBodyAdvice它能在请求体被框架读取之前做拦截处理但对 GET 的 query string 参数无能为力另一条是写一个 FilterFilter 是所有请求的必经之路配合 HttpServletRequestWrapper 重写参数读取方法既能覆盖 POST 的 body也能覆盖 GET 的 query string。我最后选了 Filter 方案因为这套系统是前后端分离项目GET 接口的数量占到一半以上如果只处理 POST前端同事会先动手打人。不过我不建议每个请求都尝试解密那样静态资源和本来就该公开的接口也要走一遍耗时的解密逻辑。我定义了一个 Crypto 注解放在 Controller 类或方法上Filter 里判断当前请求的 Controller 方法是否有这个注解有才进入解密流程。这样就把“哪些接口要加密”的开关完全交到了开发手里一个注解搞定。1.3 动态 IV 与密钥分发策略同一个 Key 不能一直用SM4 是对称加密前后端必须持有同一个密钥。密钥怎么给到前端是这套方案里最容易翻车的地方。我用的是“管理端静态配置 定时轮换”的方式来分发普通用户访问的系统里接口地址由 Nginx 代理SM4 密钥由运维通过配置中心分发到前端构建环境换密钥的时候只需要改配置中心并触发前端重新发布。虽然不算是学术意义上的绝对安全但在内网落地是完全够用的因为它把密钥从代码仓库里挪出去了。除此之外我还做了一层“动态 IV”。IV 是初始向量在很多实现里被忽略掉了直接用固定 IV结果就是“同一个明文在同一把密钥下永远产生同一个密文”。这是很危险的信号抓包抓多了别人就能通过密文重复率反推出业务行为规律。我的做法是每次请求由前端生成一个随机 16 字节 IV加密时带上它然后把 IV 的 Base64 值放到自定义请求头里一起传给后端。这样同一参数被加密两次出来的密文完全不一样后端拿到之后用同一个 IV 才能正确解密。整体请求链路是这样的前端生成随机 IV用 SM4/CBC PKCS7Padding 加密参数把密文放进 body 或 query把 IV 放在请求头Spring Boot 的 Filter 拦截请求从请求头取 IV对请求体或参数做解密后重新包装 Request 对象Controller 里拿到的就是明文参数响应时 ResponseBodyAdvice 拦截返回结果同样生成随机 IV 加密 data 字段把 IV 放在响应头前端 axios 响应拦截器里再做一次解密。整个过程对业务代码完全透明。2. SM4 核心点拆解分组、模式、填充、IV 之间的匹配关系2.1 搞懂 SM4 的关键属性和加解密对称性SM4 是一个分组密码这里的“分组”是硬性规定的不管你的参数是 1 个字节还是 1 万个字节它加密的时候都是按 16 字节一块一块处理的。如果明文长度不是 16 的整数倍就需要填充算法把它补齐。SM4 还有一个特点它的解密过程用的是和加密相同的轮结构只是轮密钥顺序反过来。这个特性在做硬件实现和嵌入式开发时很有意义但在 Spring Boot 里我们直接调用 Bouncy Castle 提供的 JCE 接口就够了不需要自己去实现轮函数。在 Java 里使用 SM4有两个库可选Bouncy Castle 和 Hutool。Hutool 封装的 SmUtil 用起来确实很爽代码就一行。但 Hutool 的默认实现是基于 ECB 模式而且它自己实现的 SM4 类对 CBC 模式的支持并不是开箱即用的你得自己拼参数。我更推荐直接用 Bouncy Castle它是 Java 密码学领域的老牌兼容库SM4 的相关实现已经非常成熟Maven 里引入 bcprov 之后直接用标准 JCE API 就能写后续切换到 SM2、SM3 也顺理成章。Bouncy Castle 的引入方式是这样的在 pom.xml 里加一条依赖dependency groupIdorg.bouncycastle/groupId artifactIdbcprov-jdk18on/artifactId version1.78/version /dependency依赖加好之后我写了一个工具类 Sm4CbcUtil把密钥、IV、加密、解密都封装起来。下面是核心代码package com.yourproject.crypto; import org.bouncycastle.jce.provider.BouncyCastleProvider; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.security.SecureRandom; import java.security.Security; import java.util.Base64; public class Sm4CbcUtil { private static final String ALGORITHM SM4; private static final String TRANSFORMATION SM4/CBC/PKCS7Padding; static { if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) null) { Security.addProvider(new BouncyCastleProvider()); } } private final byte[] keyBytes; public Sm4CbcUtil(byte[] keyBytes) { this.keyBytes keyBytes; } public String encryptToBase64(String plainText, byte[] ivBytes) throws Exception { Cipher cipher Cipher.getInstance(TRANSFORMATION, BouncyCastleProvider.PROVIDER_NAME); SecretKeySpec keySpec new SecretKeySpec(keyBytes, ALGORITHM); IvParameterSpec ivSpec new IvParameterSpec(ivBytes); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encrypted cipher.doFinal(plainText.getBytes(java.nio.charset.StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encrypted); } public String decryptFromBase64(String cipherText, byte[] ivBytes) throws Exception { Cipher cipher Cipher.getInstance(TRANSFORMATION, BouncyCastleProvider.PROVIDER_NAME); SecretKeySpec keySpec new SecretKeySpec(keyBytes, ALGORITHM); IvParameterSpec ivSpec new IvParameterSpec(ivBytes); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); byte[] decrypted cipher.doFinal(Base64.getDecoder().decode(cipherText)); return new String(decrypted, java.nio.charset.StandardCharsets.UTF_8); } public static byte[] generateRandomIv() { byte[] iv new byte[16]; new SecureRandom().nextBytes(iv); return iv; } }注意到我加密出来的密文做了 Base64 编码。原因很朴素加密出来的是一堆无法打印的字节直接塞进 JSON、请求头、URL 参数里会被编码器破坏。Base64 把二进制数组变成了 ASCII 字符串方便在各个上下文里传递和解码。后面前端对齐的时候后端也要用相同的方式处理 IV。2.2 工作模式对比为什么放弃 ECB 和 CTR选择 CBCSM4 本身只是分组加密算法怎么把一个个 16 字节分组串起来由工作模式决定。常见的模式有 ECB、CBC、CTR、GCM 四种。ECB 最简单每一块明文独立加密成一块密文块与块之间没有任何关联。它的致命问题是相同明文块一定得到相同密文块如果用户提交的参数里有很多结构雷同的字段密文会长得一模一样这等于把数据结构泄露给了抓包者。CTR 模式是按计数器位置做异或可以并行处理、性能极高但它不提供完整性保护密文被篡改之后你很难察觉到。CBC 模式引入了 IV。每个明文分组先和前一个密文分组做异或再执行加密。这样一来相同的明文在不同的 IV 下会产生完全不同的密文而且当前分组之前的任意一个密文改动都会影响后面所有分组的解密结果对篡改有一定的扩散效果。当然CBC 不防主动攻击者改动明文所以正规方案里一般会再加 SM3 或 HMAC 做完整性校验。我这套方案的定位是业务层防泄露防篡改就交给时间戳和签名头去兜底CBC 已经足够满足原始需求。在有 IV 需求的模式里IV 必须是 16 字节的随机数而且每次请求都要变。实现上我用 SecureRandom 生成一个 16 字节数组作为 IV。它不需要加密保护因为它本身不包含业务信息就算抓包人员拿到 IV没有密钥也解不开密文这是 IV 公开是安全的设计。前端生成的 IV 会放在请求头里后端解密时读取这个头再拼接解密参数顺序必须严格一致。2.3 填充方式 PKCS7Padding 与字符串编码细节SM4 的分组是 16 字节如果明文最后一块不足 16 字节就得填充。PKCS7Padding 的规则是“缺多少字节就填充多少个值为这个数字的字节”。比如最后一块差 4 字节就补 4 个 0x04如果明文恰好是 16 的整数倍呢PKCS7Padding 也会补一个完整的 16 字节填充块这是为了解密时能明确区分“填充的数据”和“真实数据”。很多刚接触的人会在这里栽跟头他们看到最后多了 16 个 0x10 字节以为是自己加密错了其实是算法设计如此。我处理字符串的时候会统一使用 UTF-8 编码Java 端的字符串默认是 UTF-16 内存模型存取时必须显式指定 Charset否则密文对不上。曾经调试过一个中文乱码问题后端解密出来全是“”前端那边用 utf8 字节数组加密到 Java 这边却用平台默认字符集编码两边不匹配。这个细节会在后面的排查章节细说。对齐前后端时大家要共同确认几个点工作模式是不是 CBC、填充方式是不是 PKCS7、密钥是不是 16 字节 UTF-8 字符串、IV 是不是 16 字节。只要有一个点不一致解密就一定报错。3. 后端实现细节注解定义、请求包装解密、响应加密3.1 自定义 Crypto 注解控制加解密范围我不想让所有接口都被动套上解密逻辑。有的接口本来就要暴露大数据量列表有的接口要对接第三方回调它们根本不适合做后端解密。所以我搞了一个极简的注解package com.yourproject.crypto.annotation; import java.lang.annotation.*; Target({ElementType.TYPE, ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) Documented public interface Crypto { boolean decrypt() default true; boolean encrypt() default true; }定义在类上表示这个 Controller 里所有接口默认都走加解密定义在方法上可以单独精细化控制。比如你有个查询下拉数据的接口数据本身不敏感你就可以在方法上写Crypto(decrypt true, encrypt false)只解密请求参数不加密响应也能少一层序列化开销。这个注解在后面的自定义 Filter 和被 ResponseBodyAdvice 的 supports 方法里都要读取。使用注解的时候提醒一句注解要在org.springframework.web.method.HandlerMethod上判断。HandlerMethod 是 Spring MVC 把 URL 映射到具体 Controller 方法后的封装对象里面包含了方法上的注解。Filter 拿到的只是 HttpServletRequest想读出 HandlerMethod 必须把 request 的 attribute 用HandlerMapping.class.getName() .handler这个 key 去取或者干脆把解密逻辑放在一个自定义拦截器 HandlerInterceptor 里做那样 preHandle 方法天然能拿到 HandlerMethod。这个权衡大家根据自己的工程习惯选我写在 Filter 里是为了后续给非 Spring Boot 容器也留一条路。3.2 解密请求参数重写 HttpServletRequestWrapper 的读取逻辑Filter 解密请求的核心道具是 HttpServletRequestWrapper。这个包装器可以让我们接管原始 Request 的流读取方法在流被 Controller 读取之前把密文替换成明文。我拆成两步第一步是解密请求体第二步是重写 getParameter 相关方法。处理 POST 请求时请求体里是一段 Base64 密文字符串。Filter 需要先拿到请求头的 IV然后从输入流里读出这段密文解密成明文 JSON再把明文字节缓存到包装类里。之后 Controller 里凡是调用request.getInputStream()或request.getReader()拿到的都是我缓存进去的明文而不是原始密文。这里有一个坑Servlet 的输入流只能被读取一次RequestBodyAdvice 没包装的话在 Filter 里读完了后面 Controller 就再也读不到了所以包装器的getInputStream()返回的流要基于缓存好的明文数组重新构建。处理 GET 请求时密文在 query string 里比如?dataabc123xxx。包装器需要重写getParameter(String name)、getParameterValues(String name)和getParameterMap()。因为一个 GET 请求可能同时存在加密参数和普通参数比如?data密文pageNo1pageSize10所以我在解密的时候只尝试解密名为 data 的参数解密失败则保持原样剩下的普通透传参数原样返回。核心代码是这样public class DecryptRequestWrapper extends HttpServletRequestWrapper { private final byte[] decryptedBody; private final Sm4CbcUtil sm4CbcUtil; private final MapString, String[] params new HashMap(); public DecryptRequestWrapper(HttpServletRequest request, byte[] decryptedBody, byte[] ivBytes, Sm4CbcUtil sm4CbcUtil) { super(request); this.decryptedBody decryptedBody; this.sm4CbcUtil sm4CbcUtil; if (request.getParameterMap() ! null) { this.params.putAll(request.getParameterMap()); } // 尝试解密 query string 里名为 data 的参数 String encryptedParam request.getParameter(data); if (encryptedParam ! null) { try { String decrypted sm4CbcUtil.decryptFromBase64(encryptedParam, ivBytes); String[] values decrypted.split(); MapString, String[] decryptedParams new HashMap(); for (String value : values) { String[] kv value.split(, 2); if (kv.length 2) { decryptedParams.put(kv[0], new String[]{kv[1]}); } } params.putAll(decryptedParams); } catch (Exception e) { throw new BusinessException(1001, 参数解密失败); } } } Override public String getParameter(String name) { String[] values params.get(name); return values ! null values.length 0 ? values[0] : null; } Override public String[] getParameterValues(String name) { return params.get(name); } Override public MapString, String[] getParameterMap() { return java.util.Collections.unmodifiableMap(params); } 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)); } }设置了纯业务代码不需要感知加解密Controller 里正常用一个 DTO 接参Spring MVC 在绑定参数时调用的是getParameter系列方法拿到手的自然就是解密后的明文。这里有个值得注意的边界如果 DTO 接收的是RequestBodyJSON 对象Spring 读取的输入流是我们重写的 getInputStream所以同样能正确反序列化。注意Filter 里面要区分请求路径如果把这个包装器用在了没有任何注解的接口上逻辑上也会执行解密。所以潜规则是“被包装的接口请求参数 data 一定是密文没被包装的接口data 就是普通参数”两边不能混用。3.3 响应加密用 ResponseBodyAdvice 拦截返回结果响应加密我用的是ResponseBodyAdvice这是 Spring MVC 提供的一个钩子Controller 方法执行完在消息转换器把返回值写入响应流之前我们可以修改返回值。这样服务端的业务代码完全不用改返回什么对象都行最后统一在外面套一层加密壳。先看一下这个接口的关键方法RestControllerAdvice public class CryptoResponseAdvice implements ResponseBodyAdviceObject { private static final ObjectMapper OBJECT_MAPPER new ObjectMapper(); Override public boolean supports(MethodParameter returnType, Class? extends HttpMessageConverter? converterType) { return returnType.hasMethodAnnotation(Crypto.class) || returnType.getContainingClass().getAnnotation(Crypto.class) ! null; } Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class? extends HttpMessageConverter? selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { // 生成随机 IV byte[] ivBytes Sm4CbcUtil.generateRandomIv(); String ivBase64 Base64.getEncoder().encodeToString(ivBytes); // 把返回对象序列化为 JSON 字符串再加密 Sm4CbcUtil sm4CbcUtil new Sm4CbcUtil(Sm4KeyHolder.get()); try { String plainText; if (body instanceof String) { plainText (String) body; } else { plainText OBJECT_MAPPER.writeValueAsString(body); } String cipherText sm4CbcUtil.encryptToBase64(plainText, ivBytes); response.getHeaders().add(X-Response-Iv, ivBase64); return Map.of(code, 200, message, ok, data, cipherText); } catch (Exception e) { throw new BusinessException(1002, 响应加密失败); } } }返回给前端的数据格式我统一成{code, message, data}data 是 Base64 密文响应头X-Response-Iv放 IV。前端要把密文和 IV 配对拿出来再做解密如果 header 缺失或者密文格式不对就能第一时间判定为“链路出了问题”而不是糊里糊涂地把加密串渲染到页面上。特别留意处理 String 返回值。如果 Controller 本身就是返回 StringSpring MVC 的默认消息转换器是 StringHttpMessageConverter它返回的 body 就是一个字符串对象不需要 OBJECT_MAPPER 再序列化一次。如果你对所有 Object 一视同仁地writeValueAsString字符串内容会被加上一层 JSON 引号转义前端解密后拿到的是一堆带转义符号的怪字符。我这里用 instanceof 分流是比较稳妥的做法。3.4 iv 传递与全局异常处理器的配合解密请求需要 IV加密响应也需要 IV。我把请求的 IV 放在X-Request-Iv这个自定义请求头里后端从 Header 读取响应 IV 放在X-Response-Iv响应头里前端从 Header 读取。这样做能避免把 IV 拼在密文串里再解析一遍逻辑更清晰而且 Header 在很多网关日志里会自动记录方便排查。这里有个很容易忽略的问题全局异常处理器抛出的异常会不会也走到 ResponseBodyAdvice会。因为你抛出的 BusinessException 最终交给 RestControllerAdvice 里的 ExceptionHandler 处理返回值同样会进到 beforeBodyWrite。处理这个问题我的经验是自定义一个 Result 结构全局异常处理器返回一个明确的Result.fail(code, message)但这个结果不再走加密。怎么区分我在 supports 方法里直接排除掉那些“异常处理专用”的方法或者根据 body 的类型识别。用body instanceof Result ((Result) body).isError()判断需要加密的方法足够实用。另外如果接口返回的是一个文件流或者已经写好的二进制数据ResponseBodyAdvice 根本不会生效所以文件下载接口不需要担心加密干扰响应。实际遇到的情况是上传和下载走的是 MultipartFile 和 StreamingResponseBody这些消息转换器类型不同supports 里如果只针对 MappingJackson2HttpMessageConverter 会返回 false也就不会重复加密。4. 前端实现axios 拦截器里的 SM4-CBC 加解密4.1 前端如何保证和后端 SM4 算法对齐前端可选的 SM4 实现有好几个我自己用得最顺的是 sm-crypto 这个库。它基于 JavaScript 实现支持 ECB、CBC 模式对 PKCS7Padding 的处理也在封装里做掉了。npm 安装一行命令不够形象实际是npm install sm-crypto引入使用再补一条import { sm4 } from sm-crypto注意 sm-crypto 的加密返回值默认是 hex 字符串如果直接把它放进 JSON 里后端的 Base64 解码会失败。所以前端在拿到 sm4.encrypt 的返回结果之后必须手动把 hex 字符串转成字节数组再走一次 Base64 编码。反过来后端返回的密文是 Base64前端 sm4.decrypt 要求输入 hex 或字节数组那就得先把 Base64 解码成字节数组。这一来一回的编码转换最容易把两端搞乱。我封装了两个工具函数function hexToBytes(hex) { const bytes [] for (let i 0; i hex.length; i 2) { bytes.push(parseInt(hex.substr(i, 2), 16)) } return bytes } function base64ToBytes(base64) { const binary atob(base64) const bytes new Uint8Array(binary.length) for (let i 0; i binary.length; i) { bytes[i] binary.charCodeAt(i) } return Array.from(bytes) } function bytesToBase64(bytes) { let binary bytes.forEach(b { binary String.fromCharCode(b) }) return btoa(binary) }统一以后我前端封装的加密函数长这样function encryptParams(plainText, keyBytes, ivBytes) { const hexResult sm4.encrypt(plainText, keyBytes, { mode: cbc, iv: ivBytes, padding: pkcs7 }) const cipherBytes hexToBytes(hexResult) return bytesToBase64(cipherBytes) }keyBytes 和 ivBytes 在这里都是字节数组。如果你们的配置中心只能下发字符串只要把字符串先按 UTF-8 转成字节数组即可。Java 端用key.getBytes(StandardCharsets.UTF_8)生成 SecretKeySpec前端用 TextEncoder 或手动编码的 Uint8Array 保持一致。4.2 请求拦截器把 body 和 query string 都加密axios 的请求拦截器在业务代码发出请求后、离开浏览器之前执行非常适合统一注入加密逻辑。我的做法是在请求拦截器里判断配置项crypto.enable只有当请求配置开启加密时才走这一段service.interceptors.request.use(config { if (!config.crypto) { return config } const keyBytes getSm4Key() const ivBytes generateIv() config.headers[X-Request-Iv] bytesToBase64(ivBytes) if (config.method get) { const params config.params || {} const plainText JSON.stringify(params) config.params { data: encryptParams(plainText, keyBytes, ivBytes) } } if (config.method post) { const plainText JSON.stringify(config.data) config.data { data: encryptParams(plainText, keyBytes, ivBytes) } } return config })POST 的处理比较直白把原来的请求体整个序列化成 JSON 字符串加密后放进一个data字段里。后端的 DTO 结构就变成只有一个 data 属性的对象或者你用MapString, String接收也行我在后端 Filter 里直接读取原始输入流解密因此 DTO 怎么定义都不重要。GET 的处理我是把所有的 params 对象序列化之后全部塞进一个data参数里。后端包装器的 params map 逻辑支持把解密出的keyvalue键值对重新放回参数容器里。假如一个 GET 请求原本是?userId100namezhang前端序列化后整个变成?data密文串后端解密后把userId和name再填回 paramsController 里拿到的 request 参数依然完好。整个链路的数据一致性是闭环的。注意GET 请求的加密字符串里可能包含、/、这些 Base64 特殊字符放进 URL 里会被服务器解析成空格或分隔符必须预先做一次encodeURIComponent编码。我在上面代码里生成 config.params 之后axios 会自己拼 query但为了保险你可以在手动拼 URL 的场景里额外调用encodeURIComponent。4.3 响应拦截器解析统一壳结构并解密 data响应侧的逻辑要反过来。axios 的 response 拦截器拿到的是后端返回的统一结构{code, message, data}data 是加密后的密文IV 在响应头里。为了把这个壳拆开我把后端所有 Controller 的返回值都包成统一的 Result 体前端再把这个 body 透传出去。解密代码service.interceptors.response.use(response { if (!response.config.crypto) { return response.data } const res response.data if (res res.code 200 res.data) { const ivBase64 response.headers[x-response-iv] const ivBytes base64ToBytes(ivBase64) const cipherBytes base64ToBytes(res.data) const decryptedHex sm4.decrypt(cipherBytes, getSm4Key(), { mode: cbc, iv: ivBytes, padding: pkcs7 }) const decryptedText utf8ArrayToString(hexToBytes(decryptedHex)) res.data JSON.parse(decryptedText) } if (res res.code ! 200) { // 业务异常统一提示 } return res }, error { ... })这里需要注意sm4.decrypt的返回值也是 hex 字符串必须转成字节数组再按 UTF-8 解码成字符串最后 JSON.parse。如果你不转直接把 hex 字符串塞给业务代码页面会渲染出一连串68656c6c6f之类的 ASCII 码。我在真实项目中第一次联调就栽在这后来封装工具函数之后才爽利起来。还有一个小点响应头的大小写。axios 读取自定义响应头的 key 方式通常是大写转小写也就是x-response-iv。如果后端网关或 Nginx 配置了“删除未暴露的响应头”需要改一下跨域配置用Access-Control-Expose-Headers把X-Response-Iv暴露出来否则前端在跨域环境下读不到这个 header。这个坑藏得比较深排查过一晚上。5. 密钥管理与防重放设计加密不是终点抗篡改才是5.1 密钥轮换与多环境隔离如果 SM4 的密钥在前端代码里被明文硬编码那这套加密的意义就要打折扣。我要求项目里所有前端密钥都来自构建时的环境变量不同环境开发、测试、生产使用不同的密钥。开发环境可以用.env.development配置生产环境必须在构建服务器上通过密钥管理服务注入禁止提交到 Git。密钥的轮换周期建议是 30 天到 90 天轮换时前后端同时发布或者做成双密钥兼容的模式给旧密钥留一个 24 小时过渡窗口。后端维护密钥的地方是密钥持有类 Sm4KeyHolder通过配置中心动态更新。上线轮换密钥只需要更新配置并调用一次刷新接口不需要重启服务。如果密钥变了前端还在用旧密钥请求解密一定会失败这时候要设计一个明确的提示让前端能跳转重新登录或者强制刷新页面而不是浏览器白屏。5.2 时间戳、随机数和签名配合防重放SM4 加密能防泄露但它不防重放。攻击者把之前的密文重新发一遍后端一样能解密成功。对于交易类接口这很危险。我的配套方案是请求头里带时间戳X-Timestamp和随机数X-Nonce后端在 Filter 解密之前先校验时间戳和当前时间是否超过 5 分钟再把 nonce 存到 Redis 做一次性消费。这样同一段密文即使被抓包也过期作废无法二次使用。如果有更高的篡改防护要求可以在前端对“明文参数 时间戳 nonce”做一次 SM3 哈希把哈希值放进请求头后端解密后重新计算并比对。SM3 是国密里的哈希算法和 SM4 配合起来非常顺畅。我目前用的这套系统核心交易接口就加了这层验签非核心查询接口只做解密不验签性能开销小很多。整体流程是自洽的。5.3 白名单机制与静态资源放行很多接口并不需要加解密比如用户登录前获取图形验证码、后端心跳检查、静态资源加载。如果在 Filter 里对所有请求都尝试解密会带来两个问题一个是静态资源请求根本没有业务参数读不到密文会继续走原逻辑逻辑上不报错但白白浪费加解密开销另一个是某些第三方回调接口的报文格式是别人定的你没法要求对方也走 SM4。我维护了一个 whitelist 列表作用是在注解判断之前先跳过不兼容的路径if (isWhiteListed(request.getRequestURI())) { chain.doFilter(request, response); return; }这个白名单放在配置中心里维护可以随时增删发布不需要重启后端。同时我还把 Swagger 等文档路径加了进去因为团队里有人习惯直接在文档页面调试接口。如果不对文档路径放行开发环境一打开接口文档就会看到请求参数是密文根本无法手动测试。这个细节非常影响开发体验建议大家一开始就设计进去。6. 实际踩坑与排查实录密文对不上的那些现场6.1 中文乱码、Base64 换行符、URL 特殊字符最早一次联调后端解出来的中文全是问号。排查到最后问题出在前端在sm4.encrypt之前把 JSON.stringify 的字符串直接放进函数sm-crypto 内部对中文字符的处理方式可能和 Java 端 UTF-8 字节不一致。解决办法是在前端把明文先转成 UTF-8 字节数组再转成 hex 字符串喂给 sm4.encrypt。加解密必须保证“两端处理的明文是同一个字节序列”。Base64 还有一个隐蔽问题默认的 Base64 编码器在密文过长时可能会插入换行符。Java 标准库java.util.Base64.getEncoder()并不会插入换行但很多第三方库会。如果响应体里的 Base64 密文中间出现了换行前端atob解码就会报错。我的建议是前端在处理密文前先去掉所有空白字符正则表达式/[\s\r\n]/g替换为空串避免不同 Base64 实现的差异影响联调。6.2 GET 请求密文传输被截断明文?userId100namezhang加密后的 Base64 可能带着号浏览器在解析 URL 时会把当作空格。我第一次调试 GET 请求时后端解出来的字符串中间多了一个空格一直以为是加密逻辑错乱后来才发现是 URL 编码的问题。解决方案就是前端对config.params.data这个值做encodeURIComponent把转成%2B后端再通过 URLDecoder 解码还原。这个我也是踩了好几轮才总结出来的规则。还有一类 GET 场景是网关或负载均衡层开启了对 query string 的日志脱敏网关把密文里的某些字符给替换成了星号。这种问题在测试环境几乎不会出现一到生产环境就有人反馈解密失败最后查日志才看到密文被网关截断了一截。如果你遇到生产环境偶发解密失败优先怀疑是不是中间设备动了参数最好统一在 POST body 里传密文规避 query string 被改写。6.3 String 返回值被二次序列化这个问题的表现是前端解密后得到的数据是hello带引号的字符串或者是像\hello\这样被转义的内容。根因就是我前面提到过的ResponseBodyAdvice 在处理 String 类型的返回值时不能再用 Jackson 去序列化。我最初没做类型判断所有返回值一把梭用 OBJECT_MAPPER 序列化前端拿到字符串类型就变成了 JSON 字符串的形式。后来改成 instanceof String 分支问题就消失了。另外当你统一返回体后前端的JSON.parse(decryptedText)立刻发现 parse 出来的是一个字符串而不是对象就会报错。这个时候先确认后端是否对 String 做了特判以及全局异常返回的数据结构是不是也满足统一的 Result 格式。6.4 排查工具与校验方法三步定位加解密问题我平时调试这类问题有一套固定的三步法。第一步在后端写一个 main 方法直接用 SMCBCUtil 加密一串已知文本再用同一个工具类解密确认 Java 侧加解密本身是自洽的。第二步把前端加密结果拿到这个 main 方法里解或者把后端加密结果的 Base64 拿到前端解能定位问题出在哪一侧。第三步把 IV 和密文都用常量输出对比确认前端的 IV 和后端读取到的 IV 是同一个字节序列。这个三步法能帮我快速区分是算法参数问题、编码问题还是 IV 传递问题。排查问题速查表如下现象可能原因解决方案解密后中文乱码前后端字符串字节编码不一致统一使用 UTF-8 编码并显式指定 CharsetBase64 中包含换行或空格Base64 实现不同导致前端解码前去除空白字符GET 请求解密后多出空格Base64 的被解析为空格对密文参数做 encodeURIComponent响应解密结果带引号String 返回值被二次序列化ResponseBodyAdvice 中特判 String 类型跨域环境读不到响应 IV响应头未暴露给前端配置 Access-Control-Expose-Headers生产偶发解密失败网关修改了 query string改用 POST body 传密文或排查网关策略重复请求可被重放缺少时间戳与 nonce 校验请求头增加时间戳与 nonce 并做 Redis 校验前端调试的时候我还会在浏览器控制台打印加密前的明文、密文、IV、解密后的数据四段对照。只要中间某一段和 Java 侧的日志对不上问题范围立刻缩小。最后再分享一个小技巧我把整个加解密过程封装成一个独立的 NPM 包和 Java 工具类统一发给前后端同事使用版本号保持一致。这样不管谁改了算法细节只要版本不变两边一定是对齐的省掉了大量“我这里明明没问题”的扯皮成本。加密这东西最怕的就是两端各自实现一套能共用一套封装阻力会小得多。

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

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

免费获取报价 →
↑