ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot接口全链路加解密:RSA+AES防篡改防重放实战

Spring Boot接口全链路加解密:RSA+AES防篡改防重放实战

接口安全这件事,很多团队的第一反应是“上HTTPS就够了”。HTTPS确实解决了传输链路加密,但等你真正被安全测试报告点了名,或者开放接口给外部合作方之后,大概率会发现:服务端收到请求时,数据已经被还原成了明文,之后的日志打印、网关转发、异常堆栈,每一处都可能留下原文记录。说白了,HTTPS只是把风险挡在了门口,屋子里面依然到处都是裸奔的数据。

我这次想聊的是在 Spring Boot 里用 RSA + AES 做接口全链路加解密,并把防篡改、防重放一起设计进去的完整方案。这套思路适用于对外开放 API、涉及资金或隐私数据、需要对核心接口做强控制的场景。后端开发可以直接从里面抄走一整套可运行的代码骨架,甚至包括密钥管理、灰度开关和排障经验,不只是给你几个“看起来能用”的工具类。

1. 为什么需要全链路加解密:HTTPS之外的真实威胁

先别急着写代码,把问题想透再做,方案才不会拧巴。很多人对“接口加密”的理解是:客户端把参数加密传给服务端,服务端解密正常处理。这没错,但它只是“局部加密”。全链路加解密,强调的是从客户端发起请求,到服务端返回响应,整个数据进出边界的过程中,业务明文不应该出现在任何中间环节。

1.1 HTTPS之外的真实威胁

HTTPS 保护的是“客户端 <-> 服务端”之间这段网络传输链路,数据到达服务端之后就会被 TLS 终止,还原成明文。之后数据的走向通常是这样:

  • 应用网关或反向代理收到明文请求,可能打访问日志;
  • 业务代码在处理前打 debug 日志,把请求体顺手打印出来;
  • 服务端调用下游服务时,如果用了 HTTP 明文协议,内网里又是一段裸奔链路;
  • 全局异常处理器捕获异常时,常常把“请求参数”一并拼到告警信息里。

这些环节里,任何一处被拖库、被脱库、被内部人员访问,核心字段(手机号、身份证、支付金额)就会直接暴露。更常见的是合作方场景:你对接的外部系统拿着你的接口文档,数据到了对方服务端之后,对方的日志、监控、第三方插件一样会看到这些明文。你根本无法控制对方内部的数据处理流程。全链路加解密的价值在于,把明文的出现范围压缩到最小:只在业务代码的“解密之后”到“加密之前”这段内存里存在明文。

1.2 全链路加解密到底保护了什么

做全链路加解密,核心解决四类问题:

  • 传输链路上的明文暴露:即使中间有旁路抓包,拿到的是密文;
  • 服务端日志、网关日志里的敏感信息泄露:密文日志即使被打开,也读不出业务含义;
  • 数据在合作方/中间服务商侧二次转发时的泄露:对方拿到的是密文和密钥密文,密钥不落地;
  • 报文被篡改或重放:配合签名和防重放机制,可以挡住绝大部分自动化脚本攻击。

很多人担心性能。以现代机器的处理能力,一次 RSA 解密大约 1~5 毫秒,AES 解密一次申请几十微秒到几百微秒,对于一个普通业务接口来说,这个损耗在可接受范围内。后面我会专门讲性能实测和优化策略。

2. RSA + AES 混合加密:方案选型与设计思路

2.1 为什么不用纯 RSA 或纯 AES

如果只用 RSA 做全量数据加密,有两个硬伤:

  1. 性能差:公钥加密、私钥解密涉及大数模幂运算,比 AES 慢几个数量级。如果每个请求体都是几十上百 KB,RSA 加密解密会吃掉大量 CPU;
  2. 明文长度限制:RSA 一次能加密的明文长度取决于密钥长度。2048 位密钥配合 PKCS1Padding,最多只能加密 245 字节。你可以把长文本分段加密,但分段后密文膨胀、传输包变大,代码复杂度也上去了。

如果只用 AES,问题同样明显:AES 是对称加密,加密和解密用同一把密钥,密钥怎么安全地告诉对方?直接把 AES 密钥放在请求里,等于把保险柜钥匙贴在保险柜门上。

所以常规做法是混合加密:用 AES 加密业务数据,用 RSA 保护 AES 密钥。AES 加解密速度快、没有长度限制;RSA 只处理一把很小的密钥,性能压力可以忽略。打个比方:你要寄一箱贵重物品,先把物品放进一个坚固的公文箱(AES 加密),再用一把保险柜钥匙去锁住公文箱的钥匙(RSA 加密),最后把公文箱和锁好的钥匙一起寄出去。收件人先用自己手上的保险柜钥匙打开外层,拿到公文箱钥匙,再打开公文箱取货。

对比一下就清楚了:

方案性能密钥长度限制密钥管理适用场景
全量 RSA慢,CPU 开销大有,需分段私钥服务端,公钥客户端,简单超短报文、低频场景
全量 AES快无密钥分发困难局域网/内部服务间固定密钥场景
RSA + AES 混合快,RSA 只处理小数据无(AES 不限长)公钥分发给客户端,私钥服务端持有对外接口、互联网应用,最常用

2.2 一次完整请求的加解密流程

先约定请求报文结构。客户端传给服务端的 JSON 长这样:

{ "encryptKey": "Base64(RSA加密后的AES密钥)", "iv": "Base64(16字节随机IV)", "timestamp": 1735660800, "nonce": "7f4a72c3-6d10-4a8e-b7d9-9f023c1d2e8b", "sign": "Base64(HMAC-SHA256签名)", "data": "Base64(AES加密后的业务明文)" }

这里data是真正的业务参数密文,比如{"username":"tom","amount":100};encryptKey是这次请求随机生成的 AES 密钥,再用服务端 RSA 公钥加密;iv是 AES 加密用的随机向量;timestamp和nonce配合做防重放;sign是消息认证码,防篡改。

服务端收到请求后的处理顺序是固定的:

  1. 先校验timestamp,判断请求是否在可接受的时间窗口内;
  2. 再校验nonce,判断这个随机数是不是已经被用过;
  3. 用 RSA 私钥解密encryptKey,拿到本次请求的 AES 密钥;
  4. 用 AES 密钥校验sign,确认data、timestamp、nonce没有被篡改;
  5. 用 AES 密钥和iv解密data,得到业务明文,继续走正常的 Controller 流程。

注意顺序:时间戳和 nonce 校验必须在 RSA 解密之前吗?时间戳窗口校验可以在解密前先做,nonce 也可以先查,但验签必须在拿到 AES 密钥之后。所以实际代码里,我会把前两步放在前面尽早拦截无效请求,减少不必要的 RSA 解密开销。

响应的结构保持一致。服务端返回时,如果请求里已经有 AES 密钥,就直接复用;如果没有(比如某些特殊场景),就重新生成一个,用客户端公钥加密后放回encryptKey字段。这样客户端只需要维护一次会话密钥,响应解密不需要再做 RSA。

2.3 防篡改:签名与验签设计

防篡改的本质是完整性校验。数据在传输过程中可能被中间人掉包,必须确保收到的密文确实是客户端发的。常见做法有两种:

方案一:HMAC-SHA256,用 AES 密钥作为 MAC 密钥。计算签名的输入是data + timestamp + nonce,密钥就是本次会话的 AES 密钥字节。AES 密钥只有客户端和服务端知道,第三方既拿不到密钥,也无法伪造签名。这是日常接口场景下我最推荐的做法,实现简单、性能好。

方案二:SHA256withRSA,客户端用私钥签名,服务端用公钥验签。这种方案在 B 端对 B 端的场景更合适,因为私钥签名具有不可抵赖性,客户端无法否认“这笔请求是我发的”,相当于法律意义上的电子签名。但缺点是私钥必须妥善保存在客户端环境,一旦泄露后果严重。如果你们有合规审计要求,可以选用这个方案;普通 C 端接口,HMAC 已经足够。

签名输入为什么要带上timestamp和nonce?因为如果只对data签名,中间人可以替换掉时间戳和 nonce,绕过防重放逻辑。把三个字段都纳入签名范围,谁动任何一个字段,验签都会失败,逻辑才是闭环的。

2.4 防重放:时间戳 + nonce 双保险

防重放是防止攻击者把抓到的合法请求反复提交。单靠一个机制不够,时间戳和 nonce 必须配合使用。

时间戳窗口:服务端判断Math.abs(当前时间 - timestamp) > 窗口值就拒绝。窗口一般设置 3 到 5 分钟,太短容易出现客户端时钟偏差导致误杀,太长又会给重放留下更大的窗口。这里的本质是“过期请求一律不认”。

nonce 幂等:每个请求生成一个全局唯一的随机数(UUID 或雪花 ID),服务端把用过的 nonce 缓存起来,在一定时间内重复出现的 nonce 直接拒绝。用 Redis 的SET NX EX实现最方便:

Boolean success = redisTemplate.opsForValue() .setIfAbsent("crypto:nonce:" + nonce, "1", Duration.ofSeconds(500));

返回 true 说明这个 nonce 第一次出现,放行;返回 false 说明有人拿相同 nonce 来过,拒绝。

时间戳能挡住窗口之外的旧请求,nonce 能挡住窗口内的即时重放。比如攻击者 1 秒内重放 100 次同一个请求,每个请求的 timestamp 都在窗口内,如果没有 nonce 缓存,这 100 次请求会全部被放过。只有两个机制配合,才能既防“过期重放”,又防“即时重放”。

3. Spring Boot 实战落地:加密能力接入

理论说完了,下面进入代码。我这边实现的思路是:用注解声明加密范围,用 RequestBodyAdvice 和 ResponseBodyAdvice 做自动解密和加密,业务代码完全不感知加解密过程。

3.1 基础工具箱:AES 与 RSA 工具类实现

先写 AES 工具类。我不会用 ECB 模式,因为 ECB 相同明文会得到相同密文,存在模式泄露风险,实际项目中强烈建议用 CBC 模式加随机 IV:

public class AesUtil { private static final String ALGORITHM = "AES/CBC/PKCS5Padding"; private static final int IV_SIZE = 16; public static String encrypt(String plainText, byte[] aesKey, byte[] iv) { try { Cipher cipher = Cipher.getInstance(ALGORITHM); SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES"); IvParameterSpec ivSpec = new IvParameterSpec(iv); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encrypted); } catch (Exception e) { throw new CryptoException("AES加密失败", e); } } public static String decrypt(String cipherText, byte[] aesKey, byte[] iv) { try { Cipher cipher = Cipher.getInstance(ALGORITHM); SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES"); IvParameterSpec ivSpec = new IvParameterSpec(iv); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(cipherText)); return new String(decrypted, StandardCharsets.UTF_8); } catch (Exception e) { throw new CryptoException("AES解密失败", e); } } public static byte[] generateIv() { byte[] iv = new byte[IV_SIZE]; new SecureRandom().nextBytes(iv); return iv; } public static byte[] generateKey() { byte[] key = new byte[32]; // AES-256 new SecureRandom().nextBytes(key); return key; } }

几个细节说明一下。AES 密钥我直接生成 32 字节,即 AES-256,JDK 8 update 161 之后默认支持,不需要额外装扩展包。CBC 模式的 IV 必须是 16 字节,随机生成,随密文一起传给服务端。IV 本身不需要保密,但不能重复使用,同一个 IV 加密两个不同明文会泄露模式信息,所以每个请求必须重新生成。

再看 RSA 工具类:

public class RsaUtil { private static final String ALGORITHM = "RSA/ECB/PKCS1Padding"; public static byte[] encrypt(byte[] data, PublicKey publicKey) throws Exception { Cipher cipher = Cipher.getInstance(ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, publicKey); return cipher.doFinal(data); } public static byte[] decrypt(byte[] data, PrivateKey privateKey) throws Exception { Cipher cipher = Cipher.getInstance(ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, privateKey); return cipher.doFinal(data); } }

这里要提一个 2048 位 RSA 的常识:密钥长度 2048 位 = 256 字节,PKCS1Padding 会占 11 字节,所以一次最多加密 245 字节。我们的 AES 密钥只有 32 字节,完全放得下,根本不需要做分段处理。

3.2 请求自动解密:RequestBodyAdvice 实现

解密逻辑放在哪里,是个关键选择。如果用 Filter 读 body,读完之后 Spring 的 Controller 就拿不到流了,还得用 ContentCachingRequestWrapper 做包装,繁琐且容易踩坑。我推荐直接用RequestBodyAdvice,它是 Spring 提供给请求体反序列化前做拦截的扩展点,可以把解密后的明文重新包装成 HttpInputMessage,交给正常的 JSON 转换器。

先定义注解,用来标记哪些接口需要走加解密:

@Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface EnableCrypto { } @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface IgnoreCrypto { }

然后写核心的解密 Advice:

@RestControllerAdvice public class CryptoRequestAdvice implements RequestBodyAdvice { @Resource private NonceManager nonceManager; @Resource private CryptoProperties properties; @Override public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) { Class<?> clazz = methodParameter.getContainingClass(); if (!clazz.isAnnotationPresent(RestController.class) && !clazz.isAnnotationPresent(Controller.class)) { return false; } if (clazz.isAnnotationPresent(IgnoreCrypto.class) || methodParameter.hasMethodAnnotation(IgnoreCrypto.class)) { return false; } return clazz.isAnnotationPresent(EnableCrypto.class) || methodParameter.hasMethodAnnotation(EnableCrypto.class); } @Override public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException { byte[] body = StreamUtils.copyToByteArray(inputMessage.getBody()); if (body.length == 0) { return inputMessage; } CryptoRequestDTO dto = JSON.parseObject(body, CryptoRequestDTO.class); // 1. 防重放:时间戳 + nonce ReplayAttackChecker.check(dto.getTimestamp(), dto.getNonce(), nonceManager, properties); // 2. RSA 私钥解密,获得本次请求的 AES 密钥 PrivateKey privateKey = RsaKeyHolder.getPrivateKey(); byte[] aesKey = RsaUtil.decrypt( Base64.getDecoder().decode(dto.getEncryptKey()), privateKey); // 3. HMAC 验签,防篡改 boolean valid = SignUtil.verify( dto.getData(), dto.getTimestamp(), dto.getNonce(), dto.getSign(), aesKey); if (!valid) { throw new CryptoException("签名校验失败"); } // 4. AES 解密业务数据 byte[] iv = Base64.getDecoder().decode(dto.getIv()); String plainText = AesUtil.decrypt(dto.getData(), aesKey, iv); // 5. 把解密后的明文交给 Spring 正常的反序列化流程 CryptoContext.setAesKey(aesKey); byte[] plainBytes = plainText.getBytes(StandardCharsets.UTF_8); return new MappingJacksonInputMessage( new ByteArrayInputStream(plainBytes), inputMessage.getHeaders()); } }

真正机密的是第 5 步。beforeBodyRead返回的HttpInputMessage会被 Spring 当成请求体数据源,继续做 JSON 反序列化和参数绑定。业务 Controller 里拿到的就是解密后的明文对象,完全无感。

这里有一个额外信息:CryptoContext是 ThreadLocal 持有当前请求的 AES 密钥,供响应加密时复用。请求结束后必须在拦截器或过滤器里清空,否则线程池复用会串数据。

3.3 响应自动加密:ResponseBodyAdvice 实现

响应方向逻辑类似。在beforeBodyWrite里拿到 Controller 返回的业务对象,序列化成 JSON,加密后封装成统一响应结构。注意返回值要是一个对象,不要直接返回字符串,否则会再次被序列化导致结构异常。

@RestControllerAdvice public class CryptoResponseAdvice implements ResponseBodyAdvice<Object> { @Override public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) { Class<?> clazz = returnType.getContainingClass(); if (clazz.isAnnotationPresent(IgnoreCrypto.class) || returnType.hasMethodAnnotation(IgnoreCrypto.class)) { return false; } return clazz.isAnnotationPresent(EnableCrypto.class) || returnType.hasMethodAnnotation(EnableCrypto.class); } @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { byte[] aesKey = CryptoContext.getAesKey(); byte[] iv = AesUtil.generateIv(); boolean needTransportKey = aesKey == null; if (aesKey == null) { aesKey = AesUtil.generateKey(); } String plainJson = JSON.toJSONString(body); String data = AesUtil.encrypt(plainJson, aesKey, iv); CryptoResponseDTO result = new CryptoResponseDTO(); result.setData(data); result.setIv(Base64.getEncoder().encodeToString(iv)); result.setTimestamp(System.currentTimeMillis() / 1000); result.setNonce(UUID.randomUUID().toString()); result.setSign(SignUtil.sign(data, result.getTimestamp(), result.getNonce(), aesKey)); if (needTransportKey) { PublicKey publicKey = RsaKeyHolder.getPublicKey(); result.setEncryptKey(Base64.getEncoder().encodeToString( RsaUtil.encrypt(aesKey, publicKey))); } return result; } }

这里的复用策略要注意:请求里带了 AES 密钥的话,响应直接复用,客户端不必再做 RSA 解密,性能最好。但如果你面对的是安全要求非常高的场景,密钥用一次就轮换更稳,响应里就重新生成一个 AES 密钥并 RSA 加密给客户端。我给出的代码两种都支持:请求带密钥就复用,没带就新建并传回。

3.4 防重放与验签的代码实现

防重放的核心NonceManager,用 Redis 的 SETNX 原子操作实现:

@Component public class NonceManager { @Resource private StringRedisTemplate redisTemplate; public boolean checkAndSet(String nonce, long timestamp, long windowSeconds) { long now = System.currentTimeMillis() / 1000; if (Math.abs(now - timestamp) > windowSeconds) { return false; } Boolean ok = redisTemplate.opsForValue().setIfAbsent( "crypto:nonce:" + nonce, "1", Duration.ofSeconds(windowSeconds + 60)); return Boolean.TRUE.equals(ok); } }

缓存时间我故意比时间戳窗口多 60 秒。原因很简单:两个请求如果时间戳都在窗口边缘,紧挨着到达,nonce 缓存先过期,那攻击者即使超时重放也可能被放过。多留 60 秒能把这个漏洞堵上。

验签工具类:

public class SignUtil { private static final String HMAC_ALGORITHM = "HmacSHA256"; public static String sign(String data, long timestamp, String nonce, byte[] aesKey) { String content = data + timestamp + nonce; Mac mac = Mac.getInstance(HMAC_ALGORITHM); SecretKeySpec keySpec = new SecretKeySpec(aesKey, HMAC_ALGORITHM); mac.init(keySpec); return Base64.getEncoder().encodeToString(mac.doFinal(content.getBytes(StandardCharsets.UTF_8))); } public static boolean verify(String data, long timestamp, String nonce, String sign, byte[] aesKey) { String expected = sign(data, timestamp, nonce, aesKey); return MessageDigest.isEqual( expected.getBytes(StandardCharsets.UTF_8), sign.getBytes(StandardCharsets.UTF_8)); } }

这里必须注意两个细节。第一,verify用MessageDigest.isEqual做常量时间比较,不能直接用equals,否则存在时间侧信道攻击风险。第二,签名字符串的拼接顺序必须是data + timestamp + nonce,客户端和服务端约定死,任何一端改顺序都会导致验签失败。data作为密文本身就是 Base64 字符串,拼接时不需要再加其他分隔符,因为字段长度是固定的,不会产生歧义。

4. 配置、性能与上线审查

4.1 密钥管理与轮换策略

RSA 私钥是整个方案里最核心的资产。私钥一旦泄露,所有加密保护全部失效。私钥文件绝对不能提交到代码仓库,也不建议放在 Jar 包里。推荐的做法:

  • 私钥从环境变量、配置中心或独立的密钥管理服务读取;
  • 配置文件里只放公钥,私钥路径通过CRYPTO_PRIVATE_KEY这类环境变量动态指定;
  • 生产环境私钥权限收紧,只有应用部署账号能读。

密钥对生成可以直接用命令:

openssl genrsa -out private_key.pem 2048 openssl rsa -in private_key.pem -pubout -out public_key.pem

轮换策略我建议采用双密钥并行。具体操作是:提前生成一组新密钥,把新公钥分发给客户端;服务端先保留新私钥和旧私钥两把,解密时先尝试新私钥,失败再用旧私钥。等全部客户端都切换完成,再下线旧私钥。这样能实现无缝切换,不会出现密钥一换全部客户端同时报错的事故。

4.2 性能实测与 AES 密钥复用

这套方案在交付前做过一次压测,我把体感数据分享出来。RSA 2048 私钥解密一次大约 1 到 3 毫秒,服务端公钥加密在 0.5 到 1 毫秒;AES-256 CBC 加解密一 KB 左右的数据,耗时通常在 0.1 毫秒以内。也就是说,单个请求增加的加解密耗时大约 2 到 4 毫秒,占接口总耗时的比例很低。真正影响吞吐的点反而在 JSON 序列化和 Base64 编解码上,所以没必要为了省时间在加密算法上做妥协。

但如果你们的接口调用频率极高、单机 QPS 逼近瓶颈,我建议做两个优化:

  1. 会话级 AES 复用:登录成功或握手阶段生成一次 AES 密钥,后续请求直接引用,避免每个请求都做一次 RSA 解密。代价是密钥复用窗口拉长,泄露后果更大,适合中等安全等级场景;
  2. 避免重复加解密:静态资源、内部探活接口、白名单接口都不要走加解密链路,用@IgnoreCrypto明确标记。

4.3 灰度发布与开关设计

全链路加密上线,最大的风险是客户端和服务端升级不同步。我经历过一次:服务端先上了强制加密,结果旧版本客户端没更新,线上直接全部解密失败,虽然只有几分钟,但足够让人血压拉满。

所以代码里一定要预留开关。我一般会在配置里加一个crypto.enabled开关,线上发布时先打开一半流量验证,再逐步放量:

crypto: enabled: true private-key: ${CRYPTO_PRIVATE_KEY} public-key: ${CRYPTO_PUBLIC_KEY} timestamp-window-seconds: 300 nonce-cache-seconds: 360

开发环境可以把开关关掉,直接用 Postman 调明文接口调试,不用每次拼密文报文。@IgnoreCrypto注解则解决“同一个服务里部分接口不想加密”的问题,比如对外提供公网 IP 白名单保护的内部接口。

5. 常见问题与排查技巧实录

这套方案在实际项目里跑过几个月,踩了不少坑,我把最典型的问题整理成速查表,遇到类似场景可以直接对着排查。

5.1 解密出来乱码、Base64 异常

乱码问题九成是字符集不统一。加密前明文必须统一用 UTF-8 编码,JSON 序列化时也要显式指定StandardCharsets.UTF_8。我之前遇到过一次,前端用 JavaScript 的 TextEncoder 默认 UTF-8,服务端用 String.getBytes() 没指定字符集,Windows 环境下默认 GBK,一解密就乱码。统一 UTF-8 后问题消失。

Base64 异常则要关注 URL 安全变体。标准 Base64 里有+、/、=这些字符,在 URL 传参时会被转义成%2B、%2F、%3D,导致解码失败。这是我在接入 App 客户端时踩过的坑。解决办法是统一约定用 URL-safe Base64,Java 里用Base64.getUrlEncoder()和Base64.getUrlDecoder(),前端对应base64url编码。

5.2 签名总是不通过

签名不通过的常见原因有三个。第一,签名拼接顺序不一致,前端拼data + timestamp + nonce,后端拼timestamp + nonce + data,肯定对不上。第二,HMAC 密钥传的是字符串而非字节数组,前后端拿到同一把密钥却算出不同的签名,一般是因为字符串转字节数组时字符集不一致。第三,时间戳单位搞混,前端传毫秒、后端按秒解析,差了一千倍,签名内容自然不同。

我调试这样的问题,习惯先把签名内容明文打出来对比,前后端各自打印要签名的字符串,一眼就能看出差异。千万别在验证阶段用“盲试”的方式,效率太低了。

5.3 调试工具直接调用报失败

Postman、Swagger、curl 直接调加密接口,大概率报“解密失败”。这不是程序 bug,是调试工具不会自动走加密流程。正确做法是:开发环境把crypto.enabled关掉,或者给文档页面接口加@IgnoreCrypto。如果一定要联调加密报文,我建议写一个简单的在线加密工具页,输入明文自动生成加密报文,再粘贴到 Postman 里,比纯手拼效率高很多。

5.4 拦截器污染 Body、日志泄漏

如果你用 Filter 做加解密,切记不能用 Filter 直接读getInputStream()还不做包装,否则 Controller 参数绑定拿到的会是空对象。Spring 里要包装ContentCachingRequestWrapper,让流被多次读取。不过我建议能不用 Filter 就不用,RequestBodyAdvice的方案更干净。

日志这个坑更隐蔽。解密后随手打一条 log,把 URL 和参数都打出来,结果日志平台一搜关键词,加密形同虚设。如果业务上必须打日志,也要只打解密后的非敏感字段,或者打哈希值、掩码过的脱敏值。

5.5 常见问题速查表

现象原因解决办法
解密后中文乱码字符集不统一前后端统一 UTF-8
Base64 解码抛异常URL 转义导致+/被替换统一使用 URL-safe Base64
签名验证失败拼接顺序、字符集、时间戳单位不一致前端打印拼签字符串对齐
请求被误判重放时钟偏差超过窗口窗口放宽到 5 分钟,同时 NTP 同步服务端时间
Postman/Swagger 直接调失败调试工具未走加密链路开发环境关闭开关或使用@IgnoreCrypto
拦截器读后参数为空Filter 未包装请求流使用 ContentCachingRequestWrapper 或基于 Advice 实现
日志中出现明文解密后打日志只记录脱敏字段,严禁完整明文日志

这套全链路加解密方案里,代码本身只是其中一个环节,真正决定成败的是细节:密钥怎么存、开关怎么切、日志怎么打、签名怎么拼。这些坑我都替你踩过一遍了,照着上面的方案落地,至少能省下两个晚上的排查时间。如果你正在设计接口安全方案,或者想给现有系统加一层保护,希望这份整理能帮到你。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进