SpringBoot接口加密实战:RSA+AES混合加密自动解密方案 1. 项目背景与方案选型1.1 为什么组合 RSA AES先说说我踩过的单加密方案的坑接手这个项目之前我第一个想到的问题是接口数据要加密直接用 RSA 不就行了吗真正动手之后才发现RSA 虽然安全但它有两个硬伤一是加密块长度限制1024 位密钥最多只能加密 117 字节明文2048 位也才 245 字节二是加解密速度慢得感人实测一次性加密 10KB 数据RSA 2048 位要几十毫秒而 AES 只需要几百微秒。如果接口动辄传输 JSON 报文、图片 Base64 或者批量数据纯 RSA 方案在性能和可用性上直接出局。反过来纯 AES 对称加密虽然速度快但密钥怎么安全地下发给客户端是个大问题——你要是把 AES 密钥写死在客户端代码里反编译一抓一个准要是每次请求都通过明文接口下发密钥那加密本身就没有意义花架子一个。所以业界最常见的做法就是混合加密RSA 用来保护 AES 会话密钥AES 用来加密真正的业务数据。这个组合的精髓可以类比成保险柜和保险箱的关系——RSA 是那道厚重的主门负责把最关键的钥匙安全地交给服务器AES 是里面的储物箱负责快速存取大量物品。攻击者即使窃听了传输内容拿不到 RSA 私钥就永远解不开 AES 密钥。我当时给这个项目定的核心目标有两个一是所有业务接口的请求体自动解密、响应体自动加密业务代码零感知二是密钥不能硬编码要支持多客户端、可配置、可轮换。整个方案要既能保护数据在传输过程中的机密性又能扛得住重放、篡改这类常见攻击。1.2 “自动解密”的“自动”到底体现在哪很多人在这个需求上的第一反应是写一个加密工具类然后在每个 Controller 方法里手动调用解密。这确实能跑但等于把加密逻辑侵入到了业务层——每个接口都要写一坨解密代码出错概率高不说后面想换加密方案还得全局改动维护成本直接翻倍。我的做法完全不同利用 SpringBoot 的过滤器Filter机制在请求进入 Controller 之前统一拦截自动完成解密在响应返回之前统一加密。业务层看到的始终是明文数据Controller 里的代码跟普通接口没有任何区别。这意味着什么意味着后端加解密逻辑完全收敛到了一层独立组件里业务开发人员不需要知道加密细节加解密功能可以作为一个公共模块复用到多个项目中。在动手之前我需要把所有前置条件先想清楚请求体格式怎么定、密钥怎么协商、过滤器如何处理输入输出流、响应加密要兼容哪些 Content-Type。这一套设计做完后面写代码其实就是按图施工。2. 加密协议与数据格式设计2.1 请求报文的统一封装结构接口自动解密的第一步是约定客户端到底以什么格式把加密数据传上来。这个协议如果不定清楚后面过滤器写起来就是无底洞。我这边定的标准请求体结构如下{ encryptedKey: 用RSA公钥加密后的AES密钥Base64编码, iv: AES初始化向量Base64编码, data: 用AES加密后的业务数据Base64编码 }三个字段的分工很明确encryptedKey是会话钥匙每次请求客户端随机生成一个新的 AES 密钥用服务器下发的 RSA 公钥加密iv是 AES-CBC 模式必需的初始向量每个请求都随机生成data是用 AES 密钥和 iv 加密后的密文。为什么不直接用固定 AES 密钥因为如果所有请求都用同一个 AES 密钥一旦某个客户端被攻破整个系统的历史流量都可能被解密。每次请求随机生成 AES 密钥即使某个密钥泄露也只能影响那一次请求。代价是多一次 RSA 加密每次请求要对 16~32 字节的密钥做 RSA这个开销完全在可接受范围内。对于那种 GET 请求参数也要加密的场景我的处理方式是约定一个X-Encrypt-Params请求头把类似的 JSON 结构 Base64 编码后塞进去服务端过滤器解析后再把解密后的参数放回请求参数表。不过说实话GET 参数本身就容易在网关日志、浏览器历史里留下痕迹更稳妥的做法是敏感接口一律改 POST Body 加密。2.2 密钥约定与格式规范这个环节最容易踩坑。光说用 RSA 加密具体用什么填充模式、什么编码格式客户端和服务端不说清楚十有八九联调时对不上。我这边定死的规范如下RSA 密钥长度2048 位。1024 位在目前的安全强度下已经不够看直接废弃。RSA 填充模式RSA/ECB/PKCS1Padding。这是 Java 和大部分主流语言默认支持的兼容性最好。不要一上来用 OAEP部分语言的实现不够完善容易踩坑。AES 模式AES/CBC/PKCS5Padding。CBC 模式需要配合随机 IV 使用安全性有保障。编码格式所有二进制密文统一用 Base64 编码后放入 JSON。注意 Java 的Base64有标准、URL 安全、MIME 三种类型这里必须统一用标准 Base64而且要明确告诉客户端不要用 MIME 类型否则输出里带换行符服务端一解析直接报错。混合加密时对 AES 密钥做 RSA 加密得到的是二进制数据传输时也要 Base64在 Java 侧千万不要先转成字符串再做 RSA那样原始字节序会改变解密出来全是乱码。这个协议定完我顺手整理了一张速查表贴在项目文档里项目约定值说明RSA 密钥长度2048 bit公私钥均为 PKCS#8 格式RSA 算法标识RSA/ECB/PKCS1Padding兼容各语言实现AES 密钥长度128 bit16字节可根据需求升级到 256AES 算法标识AES/CBC/PKCS5Padding每请求随机生成 IV密文编码Base64 标准编码禁止 MIME 方式字符集UTF-8所有明文转字节均用 UTF-82.3 RSA 公私钥的生成与管理方案密钥从哪来当然可以在项目里预先配好一对固定的公私钥。但固定密钥有个致命问题一旦私钥泄露攻击者可以解密所有的历史流量和未来流量。我在这个项目里做了一套相对灵活的管理方式服务端在启动时读取配置的私钥支持配置中心动态更新。如果密钥未配置服务端会在第一次启动时自动生成一对新的 2048 位 RSA 密钥私钥写入配置中心或本地文件公钥通过一个独立的管理接口下发给客户端或接入方管理员。支持多套密钥轮换配置中心里维护keyId - 私钥的映射客户端请求时在encryptedKey字段前加上 keyId 前缀服务端按 keyId 选择对应的私钥解密。这样密钥轮换时不需要停服平滑过渡。这套方案的现实收益我后面会讲但先在这里记住一个原则密钥轮换不是可选项是安全体系的必选项。哪怕两三个月才换一次也必须有这个机制。3. 核心代码实现SpringBoot 自动解密3.1 环境依赖与配置项先说基础环境SpringBoot 2.7 以上版本3.x 用 jakarta 命名空间代码略有差异JDK 8maven 管理依赖。加密相关不需要额外的第三方库JDK 自带的javax.crypto和java.security就够用。唯一需要额外引入的可能是commons-codec不过 JDK 8 之后的java.util.Base64足够好用没必要多引一个依赖。配置文件application.yml中需要增加的内容api-encrypt: enabled: true private-key: MIIEvgIBADANBgkqhkiG9w0BAQE... # RSA私钥 Base64也可不配置自动生成 debug-mode: false excluded-paths: - /api/encrypt/rsa-public-key - /actuator/health配置项说明enabled总开关方便在本地开发或调试阶段关闭加解密直接看明文报文。private-key服务器私钥从配置中心或本地文件读取。注意私钥本身也是敏感信息不要放在代码仓库里必须走配置中心或环境变量注入。debug-mode为 true 时过滤器只打日志不解密方便排查报文结构问题。excluded-paths白名单比如获取公钥的接口和健康检查接口不需要解密。3.2 工具类RSA 与 AES 加解密实现工具类拆成两个RsaUtils负责 RSA 公钥加密、私钥解密AesUtils负责 AES 加解密和密钥生成。核心代码我直接贴出来注释写得很细直接抄作业的节奏。public class RsaUtils { /** * 加载PKCS#8格式的私钥Base64字符串 */ public static PrivateKey loadPrivateKey(String base64Key) throws Exception { byte[] keyBytes Base64.getDecoder().decode(base64Key); PKCS8EncodedKeySpec spec new PKCS8EncodedKeySpec(keyBytes); KeyFactory factory KeyFactory.getInstance(RSA); return factory.generatePrivate(spec); } /** * 加载X.509格式的公钥Base64字符串 */ public static PublicKey loadPublicKey(String base64Key) throws Exception { byte[] keyBytes Base64.getDecoder().decode(base64Key); X509EncodedKeySpec spec new X509EncodedKeySpec(keyBytes); KeyFactory factory KeyFactory.getInstance(RSA); return factory.generatePublic(spec); } /** * 用RSA公钥加密数据 */ public static byte[] encrypt(PublicKey publicKey, byte[] data) throws Exception { Cipher cipher Cipher.getInstance(RSA/ECB/PKCS1Padding); cipher.init(Cipher.ENCRYPT_MODE, publicKey); return cipher.doFinal(data); } /** * 用RSA私钥解密数据 */ public static byte[] decrypt(PrivateKey privateKey, byte[] data) throws Exception { Cipher cipher Cipher.getInstance(RSA/ECB/PKCS1Padding); cipher.init(Cipher.DECRYPT_MODE, privateKey); return cipher.doFinal(data); } }RsaUtils里有一个重要的坑对 AES 密钥做 RSA 加密时直接对 16 字节的密钥字节数组加密输出是 256 字节2048 位密钥的固定输出长度没有任何问题。但如果哪天有人图省事把 AES 密钥转成十六进制字符串再 RSA 加密32 个字符已经超过 245 字节上限了直接报Data must not be longer than 245 bytes。我后面在踩坑章节里还会详细展开。AesUtils相对简单public class AesUtils { /** * 生成随机 AES 密钥默认 128 位 */ public static SecretKey generateKey() throws Exception { KeyGenerator generator KeyGenerator.getInstance(AES); generator.init(128); return generator.generateKey(); } /** * 生成随机 IV16 字节 */ public static IvParameterSpec generateIv() { byte[] ivBytes new byte[16]; new SecureRandom().nextBytes(ivBytes); return new IvParameterSpec(ivBytes); } /** * AES/CBC/PKCS5Padding 加密 */ public static byte[] encrypt(SecretKey key, IvParameterSpec iv, String plainText) throws Exception { Cipher cipher Cipher.getInstance(AES/CBC/PKCS5Padding); cipher.init(Cipher.ENCRYPT_MODE, key, iv); return cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); } /** * AES/CBC/PKCS5Padding 解密 */ public static String decrypt(SecretKey key, IvParameterSpec iv, byte[] cipherText) throws Exception { Cipher cipher Cipher.getInstance(AES/CBC/PKCS5Padding); cipher.init(Cipher.DECRYPT_MODE, key, iv); byte[] plainBytes cipher.doFinal(cipherText); return new String(plainBytes, StandardCharsets.UTF_8); } }这里特别要强调SecureRandom的使用——有些初学者会用new Random()生成 IV这是严重的安全隐患因为Random是可预测的伪随机数攻击者一旦拿到几个样本就能推断后续的 IVCBC 模式的安全性直接归零。必须用SecureRandom。3.3 请求包装与自动解密过滤器这是整个项目最核心的一段代码。过滤器要做的事分三步读取请求体的字节流。解析出 JSON 中的encryptedKey、iv、data三个字段。用 RSA 私钥解出 AES 密钥再用 AES 密钥解出明文最后把明文重新写入请求体让后续的RequestBody正常解析。问题来了Servlet 的HttpServletRequest的输入流只能读一次读完了 Controller 里再读就是空的。解决办法是定义一个RequestWrapper继承HttpServletRequestWrapper把解密后的明文缓存到内存的byte[]中重写getInputStream()和getReader()方法。public class DecryptedRequestWrapper extends HttpServletRequestWrapper { private final byte[] plainBody; public DecryptedRequestWrapper(HttpServletRequest request, byte[] plainBody) { super(request); this.plainBody plainBody; } Override public ServletInputStream getInputStream() { ByteArrayInputStream byteArrayInputStream new ByteArrayInputStream(plainBody); 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 listener) { // 不需要实现默认空实现 } }; } Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } }过滤器本身用OncePerRequestFilter实现保证一次请求只过滤一次Component public class ApiDecryptFilter extends OncePerRequestFilter { Value(${api-encrypt.private-key:}) private String privateKey; Value(${api-encrypt.enabled:true}) private boolean enabled; Value(${api-encrypt.debug-mode:false}) private boolean debugMode; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { if (!enabled || debugMode) { filterChain.doFilter(request, response); return; } // 只处理需要解密的内容类型 String contentType request.getContentType(); if (contentType null || !contentType.contains(application/json)) { filterChain.doFilter(request, response); return; } String body StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); if (StringUtils.isBlank(body)) { filterChain.doFilter(request, response); return; } try { // 1. 解析加密报文 JSONObject encryptedPayload JSON.parseObject(body); String encryptedKey encryptedPayload.getString(encryptedKey); String ivBase64 encryptedPayload.getString(iv); String dataBase64 encryptedPayload.getString(data); // 2. RSA 私钥解密出 AES 密钥 PrivateKey rsaPrivateKey RsaUtils.loadPrivateKey(privateKey); byte[] aesKeyBytes RsaUtils.decrypt(rsaPrivateKey, Base64.getDecoder().decode(encryptedKey)); SecretKeySpec aesKey new SecretKeySpec(aesKeyBytes, AES); // 3. AES 解密出明文 IvParameterSpec iv new IvParameterSpec(Base64.getDecoder().decode(ivBase64)); byte[] cipherBytes Base64.getDecoder().decode(dataBase64); String plainText AesUtils.decrypt(aesKey, iv, cipherBytes); // 4. 用明文包装请求继续后续流程 DecryptedRequestWrapper wrapper new DecryptedRequestWrapper(request, plainText.getBytes(StandardCharsets.UTF_8)); filterChain.doFilter(wrapper, response); } catch (Exception e) { log.error(接口解密失败URI: {}, request.getRequestURI(), e); response.setStatus(HttpStatus.BAD_REQUEST.value()); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:40001,\msg\:\解密失败请检查报文格式\}); } } }有几个细节值得展开第一为什么用StreamUtils.copyToString而不是自己手动读流因为copyToString会自动处理字符集避免中文乱码。第二私钥加载建议不要每次都执行。KeyFactory.generatePrivate内部开销不小可以在过滤器初始化时一次性加载并缓存。如果支持多 keyId 轮换就维护一个ConcurrentHashMapString, PrivateKey keyCache。第三异常分支的设计。解密失败时不要直接抛 500而是返回一个业务码40001方便客户端区分是加密格式错误还是密钥错误。同时日志里必须记录原始 URI 和密文前几十个字符否则线上排查时证据链完全断掉。第四如果 POST 的 Content-Type 不是application/json比如表单提交过滤器直接放行避免误伤其他接口。这个开关也可以做成配置项。3.4 响应加密扩展请求自动解密做完响应自动加密其实是同样的模式反向操作。不过这步可选性更强——如果你的接口主要给自家 App 调用响应加密的价值很高如果给第三方系统对接反而要谨慎因为对方未必愿意配合做解密。我这里实现的响应加密过滤器方案是在doFilter中捕获响应的输出内容用同一个 AES 密钥加密后返回。注意响应加密不能直接用请求里的 AES 密钥因为响应是对称加密体系中另一方——客户端——还需要解密这里可以使用同一个密钥因为客户端创建密钥它知道密钥服务端通过 RSA 解密获得了密钥双方共享同一密钥进行双向通信。实现上需要包装HttpServletResponse拦截输出流public class EncryptedResponseWrapper extends HttpServletResponseWrapper { private final ByteArrayOutputStream buffer new ByteArrayOutputStream(); private final ServletOutputStream outputStream; private final HttpServletResponse response; public EncryptedResponseWrapper(HttpServletResponse response) { super(response); this.response response; this.outputStream new ServletOutputStream() { Override public void write(int b) { buffer.write(b); } Override public boolean isReady() { return true; } Override public void setWriteListener(WriteListener listener) { // 默认空实现 } }; } Override public ServletOutputStream getOutputStream() { return outputStream; } public byte[] getBufferContent() { return buffer.toByteArray(); } }然后在过滤器链路返回时获取缓冲内容做 AES 加密再写入真正的响应这个扩展我只说思路具体代码根据项目实际情况调整。响应加密最大的坑在于响应里可能有非 JSON 内容比如文件下载的二进制流。所以过滤器中必须加白名单判断只对application/json的响应做加密。3.5 配置 SpringBoot 的过滤器注册很多人在这一步卡壳明明写了Component但过滤器不生效。原因多半是 Spring Boot 内嵌容器对过滤器的注册时机问题或者过滤器被注册了两次。最稳妥的方式是显式声明FilterRegistrationBeanConfiguration public class ApiEncryptConfig { Bean public FilterRegistrationBeanApiDecryptFilter apiDecryptFilterRegistration(ApiDecryptFilter filter) { FilterRegistrationBeanApiDecryptFilter registration new FilterRegistrationBean(); registration.setFilter(filter); registration.addUrlPatterns(/api/*); registration.setOrder(Ordered.HIGHEST_PRECEDENCE 1); return registration; } }Ordered.HIGHEST_PRECEDENCE 1是为了让解密过滤器尽量靠前执行但又不能抢在最前面——像CharacterEncodingFilter这种设置字符集的过滤器需要先执行。如果顺序反了过滤器读到的请求体可能是乱码字节。4. 踩坑实录你可能也会遇到的几个问题4.1 RSA 加密大文本直接报错这是初学时最容易撞上的墙。有同事直接把整个请求体用 RSA 加密然后跑起来发现javax.crypto.IllegalBlockSizeException: Data must not be longer than 245 bytes。原因很简单RSA 单次加密的最大明文长度 密钥位数 / 8 - 11PKCS1Padding 的填充开销2048 位密钥就是 256 - 11 245 字节。我一再强调 RSA 只用来加密 AES 密钥就是在源头规避这个限制。如果团队里有人非要直接 RSA 加密整个报文项目上线前 code review 一定要把这条作为红线。从设计层面来说这个限制不是缺陷反而是安全性的体现——非对称加密本身就不是为大块数据设计的它就是一把钥匙保险柜。业务数据量大用对称加密是正道。4.2 Base64 换行符与特殊字符的连锁反应有一次联调客户端拿 Java 版 SDK 测数据一切正常换成另一个语言写的客户端就报解密失败。排查了半天发现那个语言的标准库 Base64 编码输出会在每 76 个字符处插入换行符MIME 模式JSON 解析后换行符被转成\n字符Base64 解码时直接抛异常。这种问题教出来的教训是协议文档里必须写明编码方式为标准 Base64无换行同时服务端解析时最好兼容一下先做一次空白符过滤再解码。稳妥的写法String cleanedData dataBase64.replaceAll(\\s, ); byte[] cipherBytes Base64.getDecoder().decode(cleanedData);另外一个跟 Base64 相关的问题是 URL 传输时、/号会被转义如果请求体是表单格式而不是 JSONencryptedKey里的到了服务端会变成空格。解决办法客户端用 URL 安全的 Base64 编码服务端对应使用Base64.getUrlDecoder()。但注意URL 安全的 Base64 与标准 Base64 的编码结果差异只体现在、/两个字符上容易混淆必须保持两端完全一致。4.3 中文乱码与字符集不一致加密和解密的字符集必须统一这是老生常谈但又防不胜防。比如客户端用 UTF-8 加密中文服务端如果用GBK解密解密后的字符串直接变成乱码。更隐蔽的问题是有些客户端框架在组装请求时默认用ISO-8859-1编码读取字节流导致密文在传输前已经被污染。我的处理习惯是明文转字节和密文转字符串这两步全部显式指定StandardCharsets.UTF_8绝不依赖系统默认编码。另外在application.yml里加上server.servlet.encoding.forcetrue确保 SpringBoot 的请求和响应都统一使用 UTF-8。4.4 流只能读一次引发的 400 血案我记得排查过一个诡异的现象加了过滤器之后某些接口直接 400但同样的接口在本地跑得好好的。后来发现是过滤器读了一遍输入流解密后直接调用了filterChain.doFilter(request, response)传的还是原始request而不是包装后的request。导致 Controller 里RequestBody读到的流已经到末尾解析出来是空 bodySpring 直接报HttpMessageNotReadableException。所以上文代码里最关键的一行是filterChain.doFilter(wrapper, response)——必须把解密后的包装对象传下去。这个看起来不起眼的细节恰恰是整个自动解密能不能落地的核心。如果你检查代码时发现doFilter传的是原始 request不管接口现在跑起来正常不正常都建议改成传 wrapper否则排查半天都不知道问题出在哪。4.5 密钥泄露之后的应急响应虽然是管理层面的问题但我还是想提一嘴。有一次测试环境的私钥被某个开发同学误提交到了代码仓库虽然及时删除了但 Git 历史里还留着。这意味着任何能访问仓库历史代码的人都可能翻出私钥。当时我们的应急措施是立即生成新的密钥对通过配置中心强制所有节点刷新同时通知所有联调方重新获取公钥。应急流程本身能跑通但整个过程涉及多方协调费时费力。所以密钥管理一定要纳入基础设施层面私钥只存在于配置中心和服务端内存代码仓库里出现任何.pem、.key文件都应该是高危告警。这也是我后面要讲密钥轮换机制的初衷。5. 性能优化与安全加固5.1 性能优化核心是降低 RSA 运算频率和字节拷贝先说性能底数。我这边的压测数据4 核 8G 的机器并发 50请求体 1KB纯 RSA 解密 AES 密钥 AES 解密数据单次完整解密耗时平均在 1.2ms 左右其中 RSA 解密占了 0.8msAES 只占 0.3ms。如果你的接口 QPS 很高这 1ms 多就是实打实的性能开销。三个优化方向第一RsaUtils.loadPrivateKey不要每次请求都调用初始化过滤器时加载一次并缓存。实测这条优化能省 0.2~0.3ms。第二减少byte[]拷贝。StreamUtils.copyToString把流转成字符串再转成byte[]中间多了一次编码。可以直接用request.getInputStream().readAllBytes()读原始字节流再new String(bytes, UTF_8)一次到位。第三如果用 HTTP/2可以考虑在连接级别做加密协商避免每次请求都执行 RSA。不过这属于深度优化一般场景用到就说明加密报文业务量已经很大了。5.2 安全加固重放攻击与时间戳校验光有加密其实还不够。攻击者不需要解密他直接录制一段合法加密请求原封不动地重放一样能达到目的比如重复下单。所以我会在请求体里加一个时间戳字段服务端只接受当前时间±5分钟内的请求。具体实现是约定解密后的明文 JSON 首字段为timestamp过滤器解密后检查时间差。超过窗口期直接返回40003 请求过期。如果希望进一步防御重放可以配合 Redis 做请求唯一性校验把nonce字段存到 Redis 里相同 nonce 只允许一次。代价是每次请求多一次 Redis 访问非交易类系统一般不强制。5.3 安全加固AES 密钥升级到 256 位JDK 8 默认的 AES 支持 128 位想用 256 位需要安装无限制权限策略文件JDK 8 之前的版本JDK 9 就没有这个限制了。从安全强度来说128 位 AES 在可预见的未来足够安全但如果你们的安全合规部门对加密强度有硬指标可以直接上 256。改动量很小就是KeyGenerator.init(128)改成init(256)同时客户端密钥长度保持一致即可。5.4 性能与安全的平衡响应加密的开关策略响应加密会带来明显的性能损耗尤其是接口返回大数据量时。我在实际项目里做的策略是核心交易类接口请求加密 响应加密都开启。普通查询类接口只做请求解密响应明文返回但经过网络安全通道。静态资源、文件下载接口完全不加密。这个策略可以通过过滤器里的路径匹配实现给每种接口类型配置一个路由前缀过滤器中白名单判断即可。核心思路是加密要加到刀刃上而不是一刀切给所有接口增加无谓的延迟。6. 密钥轮换机制的完整实现思路密钥轮换之前提过一嘴这里展开说一下完整方案。因为这是生产环境绕不开的问题。我的做法是在协议里给encryptedKey加一个版本前缀格式为keyId:密文。客户端加密之前先从配置中心拿到公钥列表选取 keyId 最大的那个公钥加密 AES 密钥。服务端过滤器解析encryptedKey字符串冒号前面的 keyId 决定用哪个私钥。轮换策略如下生成新的密钥对私钥写入配置中心keyId 递增。配置中心发布后服务端自动加载新私钥同时保留旧私钥用于解密旧客户端还发来的请求。通知客户端获取新公钥客户端本地缓存更新。观察一段时间确认所有客户端都切换后下线旧私钥。整个轮换过程中服务端始终保持至少两把私钥可用旧密钥宽限期内并行共存避免客户端更新不及时导致的服务不可用。实现上密钥加载器只需维护一个ConcurrentHashMapString, PrivateKey配置中心发布事件时动态更新。这个机制解决了运维侧最大的痛点——换密钥从来不需要重启服务。写在最后的几个经验这个项目做完我最深的体会是接口加密绝不是简单写两个工具类那么简单它是一套协议设计 框架整合 运维管理的系统工程。协议设计没想清楚就急着写代码后面多半要返工过滤器包装流的细节没留意线上就会出莫名其妙的 400。好在 SpringBoot 的扩展机制足够成熟把加解密逻辑收敛到过滤器一层业务代码干干净净这也是我推荐所有团队采用的方式。另外还有个小技巧调试加密接口时一定要有一个开关能临时关掉加解密。我们线上的做法是debug-mode配置项专门用来排查到底是加密问题还是业务问题省去了大量来回扯皮的联调时间。这个不起眼的配置在关键时刻能救你一命。如果你正在规划接口加密方案希望这篇文章能让你少走几个弯路。加密本身不复杂真正考验功夫的是把这些细节都抠到位。后续如果你们想把响应加密做得更细或者接入国密算法 SM2/SM4都是完全可行的扩展方向——只要最底层的协议设计留有空间上层实现就是水到渠成的事。