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 bit(16字节) | 可根据需求升级到 256 |
| AES 算法标识 | AES/CBC/PKCS5Padding | 每请求随机生成 IV |
| 密文编码 | Base64 标准编码 | 禁止 MIME 方式 |
| 字符集 | UTF-8 | 所有明文转字节均用 UTF-8 |
2.3 RSA 公私钥的生成与管理方案
密钥从哪来?当然可以在项目里预先配好一对固定的公私钥。但固定密钥有个致命问题:一旦私钥泄露,攻击者可以解密所有的历史流量和未来流量。我在这个项目里做了一套相对灵活的管理方式:
- 服务端在启动时读取配置的私钥,支持配置中心动态更新。
- 如果密钥未配置,服务端会在第一次启动时自动生成一对新的 2048 位 RSA 密钥,私钥写入配置中心或本地文件,公钥通过一个独立的管理接口下发给客户端或接入方管理员。
- 支持多套密钥轮换:配置中心里维护
keyId -> 私钥的映射,客户端请求时在encryptedKey字段前加上 keyId 前缀,服务端按 keyId 选择对应的私钥解密。这样密钥轮换时不需要停服,平滑过渡。
这套方案的现实收益我后面会讲,但先在这里记住一个原则:密钥轮换不是可选项,是安全体系的必选项。哪怕两三个月才换一次,也必须有这个机制。
3. 核心代码实现:SpringBoot 自动解密
3.1 环境依赖与配置项
先说基础环境:SpringBoot 2.7 以上版本(3.x 用 jakarta 命名空间,代码略有差异),JDK 8+,maven 管理依赖。加密相关不需要额外的第三方库,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(); } /** * 生成随机 IV,16 字节 */ 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是可预测的伪随机数,攻击者一旦拿到几个样本就能推断后续的 IV,CBC 模式的安全性直接归零。必须用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;charset=UTF-8"); response.getWriter().write("{\"code\":40001,\"msg\":\"解密失败,请检查报文格式\"}"); } } }有几个细节值得展开:
第一,为什么用StreamUtils.copyToString而不是自己手动读流?因为copyToString会自动处理字符集,避免中文乱码。
第二,私钥加载建议不要每次都执行。KeyFactory.generatePrivate内部开销不小,可以在过滤器初始化时一次性加载并缓存。如果支持多 keyId 轮换,就维护一个ConcurrentHashMap<String, 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 内嵌容器对过滤器的注册时机问题,或者过滤器被注册了两次。最稳妥的方式是显式声明FilterRegistrationBean:
@Configuration public class ApiEncryptConfig { @Bean public FilterRegistrationBean<ApiDecryptFilter> apiDecryptFilterRegistration(ApiDecryptFilter filter) { FilterRegistrationBean<ApiDecryptFilter> 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 - 11(PKCS1Padding 的填充开销),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 传输时+、/号会被转义,如果请求体是表单格式而不是 JSON,encryptedKey里的+到了服务端会变成空格。解决办法:客户端用 URL 安全的 Base64 编码,服务端对应使用Base64.getUrlDecoder()。但注意:URL 安全的 Base64 与标准 Base64 的编码结果差异只体现在+、/两个字符上,容易混淆,必须保持两端完全一致。
4.3 中文乱码与字符集不一致
加密和解密的字符集必须统一,这是老生常谈但又防不胜防。比如客户端用 UTF-8 加密中文,服务端如果用GBK解密,解密后的字符串直接变成乱码。更隐蔽的问题是:有些客户端框架在组装请求时,默认用ISO-8859-1编码读取字节流,导致密文在传输前已经被"污染"。
我的处理习惯是:明文转字节和密文转字符串这两步全部显式指定StandardCharsets.UTF_8,绝不依赖系统默认编码。另外在application.yml里加上server.servlet.encoding.force=true,确保 SpringBoot 的请求和响应都统一使用 UTF-8。
4.4 流只能读一次引发的 400 血案
我记得排查过一个诡异的现象:加了过滤器之后,某些接口直接 400,但同样的接口在本地跑得好好的。后来发现是过滤器读了一遍输入流解密后,直接调用了filterChain.doFilter(request, response),传的还是原始request而不是包装后的request。导致 Controller 里@RequestBody读到的流已经到末尾,解析出来是空 body,Spring 直接报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.8ms,AES 只占 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 递增。
- 配置中心发布后,服务端自动加载新私钥,同时保留旧私钥(用于解密旧客户端还发来的请求)。
- 通知客户端获取新公钥,客户端本地缓存更新。
- 观察一段时间,确认所有客户端都切换后,下线旧私钥。
整个轮换过程中,服务端始终保持至少两把私钥可用,旧密钥宽限期内"并行共存",避免客户端更新不及时导致的服务不可用。
实现上,密钥加载器只需维护一个ConcurrentHashMap<String, PrivateKey>,配置中心发布事件时动态更新。这个机制解决了运维侧最大的痛点——换密钥从来不需要重启服务。
写在最后的几个经验
这个项目做完,我最深的体会是:接口加密绝不是简单写两个工具类那么简单,它是一套协议设计 + 框架整合 + 运维管理的系统工程。协议设计没想清楚就急着写代码,后面多半要返工;过滤器包装流的细节没留意,线上就会出莫名其妙的 400。好在 SpringBoot 的扩展机制足够成熟,把加解密逻辑收敛到过滤器一层,业务代码干干净净,这也是我推荐所有团队采用的方式。
另外还有个小技巧,调试加密接口时一定要有一个开关能临时关掉加解密。我们线上的做法是debug-mode配置项,专门用来排查到底是加密问题还是业务问题,省去了大量来回扯皮的联调时间。这个不起眼的配置,在关键时刻能救你一命。
如果你正在规划接口加密方案,希望这篇文章能让你少走几个弯路。加密本身不复杂,真正考验功夫的是把这些细节都抠到位。后续如果你们想把响应加密做得更细,或者接入国密算法 SM2/SM4,都是完全可行的扩展方向——只要最底层的协议设计留有空间,上层实现就是水到渠成的事。