资讯动态

接口安全加固:AES-GCM+RSA混合加密与Spring Boot落地实践

发布时间:2026/10/21 6:32:27 来源:尧图企业网站定制
干接口安全的同学应该都有过这种体会Restful接口的入参和返回参数直接明文在网络上传输光着身子跑看着心里就不踏实。只要对方抓到包请求结构、字段含义、业务逻辑几乎一览无余如果后端日志再把完整报文打出来那数据泄露就只是时间问题。这篇文章是我在一套服务端项目里落地“入参与出参安全加解密”的完整复盘包含算法选型、密钥协商、统一拦截实现以及后来排查乱码、超时、签名校验失败时的那些坑。适合正在给接口做安全加固、被要求“把敏感接口的数据都加密一下”的同学参考也适合想弄清应用层加密到底怎么落地的人。1. 为什么要折腾入参和返回参数的加解密1.1 明文接口的三类现实风险很多人觉得“我有HTTPS就够了”但实际项目中明文接口的风险点比想象中多。第一类是抓包暴露。HTTPS能防住链路层的窃听但防不住“终端侧被信任的攻击者”。比如用户在手机上抓自己App的流量或者一个第三方SDK把接口请求转发出来又或者客户端被动态调试、注入后主动把请求打给代理这些场景下HTTPS证书链路是完整的但业务数据照样裸露。入参里面的手机号、身份证、银行卡返回参数里的余额、订单明细、发票信息全都可以被看得清清楚楚。第二类是日志泄露。很多系统上线时开了全量报文日志请求体和响应体原样打印。一旦日志系统权限失控、日志文件被拖走敏感数据的泄露面比抓包还大。而且日志通常是长期留存追溯和脱敏成本极高。第三类是篡改与重放。明文接口的参数容易被恶意修改后重新提交。比如下单接口的金额字段如果只做了前端校验后端拿到的数据被改过也校验不出来再比如抢购类的接口别人录下你的正常请求反复重放也能达到攻击效果。加解密并不是只为了“保密”签名校验本身就是加密体系里不可拆的一部分。1.2 什么场景真正需要应用层加解密不是所有接口都需要加密这个要清醒。公开的资讯类页面、商品列表、静态配置接口加密只会带来无谓的CPU开销和排查难度。真正需要做的是这一批涉及用户隐私的手机号、邮箱、身份证、地址、设备信息涉及资金操作的下单、支付、退款、转账、余额查询涉及账号体系的登录、注册、改密、Token刷新toB对接中的敏感业务字段采购价、结算数据、合同相关的结构化数据合规要求明确要求数据加密存储和传输的场景我之前在的那个项目上线前被安全评审整整改了两轮第一轮就是“敏感字段明文传输”第二轮是“日志中存在敏感数据”。最后方案落地时直接定了两层HTTPS负责传输链路安全应用层加解密负责业务数据本体安全两件事互不替代。1.3 为什么不能只依赖HTTPS把话说透一点。HTTPS解决的是“管道安全”它防的是管道上的人但你防不住管道两端的“内部人”和“被攻陷的端点”。我见过一个典型案例客户端和服务端用HTTPS通信安全评审时一切正常结果某次上线后日志系统接入了第三方检索平台开发人员为了排查问题把完整报文打进了日志日志平台权限没收紧一个离职员工的运维账号还能查到半年前的请求记录。这种情况下HTTPS是全程都在但敏感数据还是泄露了。应用层加解密的意义是就算数据被从日志、数据库备份、客户端缓存、抓包工具里拿到也是一堆密文和签名没有密钥根本无法还原。这就是常说的纵深防御每一层都有自己的职责。2. 方案选型先定算法再谈实现2.1 加密算法怎么选算法选型是整个方案的基石选错了后面全是坑。我直接说我用下来的结论。对称加密选AES-GCM而不是大家更熟悉的AES-CBC。GCM是“认证加密”模式加密的同时产出认证标签能同时保证机密性和完整性。CBC模式只加密不认证攻击者可以翻转密文里的某些位来篡改明文自己还得额外做签名或MAC链路复杂还容易做错。AES-GCM在OpenSSL里有硬件加速性能上并不比CBC差Java侧内置支持不需要引任何额外依赖。密钥协商用RSA-OAEP或者直接上国密SM2也行。RSA用的场景最广服务端生成密钥对公钥给客户端私钥留在服务端。客户端每次请求生成一个随机的AES会话密钥用RSA公钥加密后随请求发给服务端服务端用私钥解出AES密钥再做真正的报文加解密。这就是最经典的“混合加密”。签名用HMAC-SHA256别用MD5加盐这种自己拼的方案。HMAC是标准算法实现简单、碰撞难度高能同时防止请求被篡改。我这边在RSA以外再补一层HMAC因为RSA只保护了会话密钥本身而具体报文是否被改动需要报文级签名来确认。国密算法SM2/SM4我用过一段时间主要是银行类项目要求国产化合规。技术上完全可行但实现上要依赖第三方库比如BouncyCastle在部分发行版上要额外加JCE策略。如果不是硬性合规要求通用项目先用AES-GCMRSA更省心。2.2 混合加密的完整流程整个请求链路我拆成了五步每一步都有明确职责客户端调用登录或密钥接口获取服务端的RSA公钥含密钥版本号。客户端生成一个临时的AES-256会话密钥并生成随机的IVInitialization Vector初始向量和Nonce。业务报文用AES-GCM加密得到密文和认证标签会话密钥用RSA公钥加密得到密钥密文。客户端把“密钥密文 报文明文密文 时间戳 Nonce 签名”组装成统一请求结构发送给服务端。服务端用RSA私钥解出会话密钥再用会话密钥解密报文校验签名和时间戳然后执行业务逻辑。响应方向就是反过来。服务端也可以用同样的方式生成一个临时的响应密钥或者复用客户端的会话密钥来加密响应报文。我在实际项目中是复用了客户端密钥减少一次RSA解密提高响应速度但前提是整体链路的密钥生命周期管理做干净了。关于AES的IV必须每次随机生成绝不能固定。固定IV会让相同明文产出相同密文攻击者能通过密文比对推断业务状态变化等同于把加密废了一半。2.3 请求和响应的报文格式设计统一格式方便前端排障和服务端解析。我用的结构是这样{ version: 1, timestamp: 1699999999000, nonce: 6f8a9c21d4e7412ba91d, encKey: base64(RSA加密后的AES会话密钥), sign: base64(HMAC-SHA256签名), payload: base64(AES-GCM加密后的业务报文) }version是密钥版本号服务端切换密钥时旧版本请求还能正常解密不用强制客户端热更新。timestamp用来做重放防护我一般允许前后5分钟的时间偏移超过直接拒绝。nonce是一次性随机串和时间戳一起参与签名避免相同报文反复重放。payload是加密后的业务数据整体是Base64编码的字符串。sign是用双方约定好的密钥对“timestamp nonce payload”做的签名防止中间人改掉任何一个字段。响应格式我保持一致客户端解密逻辑可以复用。唯一区别是响应里可以不带encKey直接用请求里协商好的会话密钥即可。为什么不把整个body直接做成一个密文字符串我也试过比如{data:一串密文}简单是简单但sign、version、timestamp这些控制字段没地方放硬塞进payload里又不方便服务端先做验签再解密。统一结构更利于排查问题各字段职责单一。3. 完整实现基于Spring Boot的请求解密与响应加密3.1 核心思路统一拦截业务无感方案定完后落地方式很关键。我不希望业务代码里到处调CryptoUtil.decrypt那会让Controller层乱成一锅粥。正确做法是把加解密放到Servlet层面的Filter里做业务Controller拿到的直接就是解密后的POJO。我选Filter而不是Spring Interceptor原因有两点一是Filter在Spring MVC处理链之前能最原始地拿到请求体能处理请求体缓存问题二是响应流也可以在这个层面被包装和改写Interceptor想改写响应体很别扭往往得再搞一层ResponseBodyAdvice。Filter一个位置把入参解密和出参加密全搞定逻辑最简单。整个流程是这样加一个自定义Filter在doFilter里读取原始请求体判断当前接口是否需要加解密通过路径规则或注解需要就走解密逻辑把解密后的内容重新包装成Request继续放行响应回来时对输出流做包装待Controller写入内容后统一加密再交给客户端。3.2 加解密工具类实现先写核心工具类。这个类负责AES-GCM加解密、RSA加解密、HMAC签名三块功能。为了不引入额外依赖全部用JDK自带的API。import javax.crypto.Cipher; import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.*; import java.util.Base64; public class CryptoUtil { private static final int GCM_TAG_LENGTH_BITS 128; private static final int AES_KEY_SIZE_BITS 256; // 生成AES会话密钥 public static byte[] generateAesKey() throws Exception { KeyGenerator generator KeyGenerator.getInstance(AES); generator.init(AES_KEY_SIZE_BITS); return generator.generateKey().getEncoded(); } // AES-GCM加密data为明文key为AES密钥返回 iv密文tag 的合并结果 public static byte[] aesGcmEncrypt(byte[] data, byte[] key) throws Exception { // 12字节随机IVGCM推荐IV长度 byte[] iv new byte[12]; SecureRandom random new SecureRandom(); random.nextBytes(iv); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); SecretKeySpec keySpec new SecretKeySpec(key, AES); GCMParameterSpec gcmSpec new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv); cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec); byte[] cipherText cipher.doFinal(data); // 拼接iv 密文 byte[] result new byte[iv.length cipherText.length]; System.arraycopy(iv, 0, result, 0, iv.length); System.arraycopy(cipherText, 0, result, iv.length, cipherText.length); return result; } // AES-GCM解密入参为 iv密文tag 的合并结果 public static byte[] aesGcmDecrypt(byte[] encryptedData, byte[] key) throws Exception { byte[] iv new byte[12]; System.arraycopy(encryptedData, 0, iv, 0, iv.length); byte[] cipherText new byte[encryptedData.length - iv.length]; System.arraycopy(encryptedData, iv.length, cipherText, 0, cipherText.length); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); SecretKeySpec keySpec new SecretKeySpec(key, AES); GCMParameterSpec gcmSpec new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv); cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); return cipher.doFinal(cipherText); } // RSA公钥加密OAEP填充 public static byte[] rsaEncrypt(byte[] data, PublicKey publicKey) throws Exception { Cipher cipher Cipher.getInstance(RSA/ECB/OAEPWithSHA-256AndMGF1Padding); cipher.init(Cipher.ENCRYPT_MODE, publicKey); return cipher.doFinal(data); } // RSA私钥解密 public static byte[] rsaDecrypt(byte[] data, PrivateKey privateKey) throws Exception { Cipher cipher Cipher.getInstance(RSA/ECB/OAEPWithSHA-256AndMGF1Padding); cipher.init(Cipher.DECRYPT_MODE, privateKey); return cipher.doFinal(data); } // HMAC-SHA256签名 public static String hmacSign(String data, byte[] key) throws Exception { Mac mac Mac.getInstance(HmacSHA256); SecretKeySpec keySpec new SecretKeySpec(key, HmacSHA256); mac.init(keySpec); byte[] result mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(result); } }这里有几个细节要特别注意。第一GCM的tag长度我固定用128位这是安全性和性能的平衡点。第二AES/GCM/NoPadding里写NoPadding是标准写法GCM模式自带认证逻辑不是真正不做填充。第三RSA加密时用OAEP填充而不用PKCS1OAEP更安全能避免部分已知的格式攻击。3.3 请求解密与响应加密的Filter实现Filter这块分为两步请求解密和响应加密。先看请求方向。Component public class ApiCryptoFilter extends OncePerRequestFilter { private final PrivateKey privateKey; // 省略密钥注入 Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { // 只有需要加解密的接口才处理 if (!shouldDecrypt(request)) { chain.doFilter(request, response); return; } // 读取原始body只允许读一次 byte[] requestBody StreamUtils.copyToByteArray(request.getInputStream()); if (requestBody.length 0) { chain.doFilter(request, response); return; } try { // 解析统一的请求结构 RequestEnvelope envelope objectMapper.readValue(requestBody, RequestEnvelope.class); // 校验签名和时间戳防止篡改和重放 if (!verifySign(envelope) || !checkTimestamp(envelope.getTimestamp())) { response.setStatus(HttpStatus.BAD_REQUEST.value()); response.getWriter().write({\code\:403,\msg\:\invalid sign or expired\}); return; } // 1. RSA解出AES会话密钥 byte[] aesKey CryptoUtil.rsaDecrypt( Base64.getDecoder().decode(envelope.getEncKey()), privateKey); // 2. AES-GCM解密payload byte[] plainData CryptoUtil.aesGcmDecrypt( Base64.getDecoder().decode(envelope.getPayload()), aesKey); // 3. 将解密后的内容包装为新的Request HttpServletRequest wrappedRequest new RequestWrapper(request, plainData); chain.doFilter(wrappedRequest, response); } catch (Exception e) { response.setStatus(HttpStatus.BAD_REQUEST.value()); response.getWriter().write({\code\:400,\msg\:\decrypt failed\}); } } }请求体读取是个容易踩坑的地方。request.getInputStream()只能读一次如果Filter前面还有其他组件读过Body后面Controller再读就会拿到空流。所以要么在Filter链的最前面处理要么用Spring提供的ContentCachingRequestWrapper先把内容缓存下来。我这边是自定义Filter并确保它在链的最前面同时自己实现一个RequestWrapper来缓存明文bodyController再用RequestBody读取时拿到的就是解密后的内容。响应方向的实现逻辑对称核心是包装HttpServletResponse的输出流在Controller执行完、数据写出时拦截下来加密。// 响应包装类拿到Controller写入的内容并加密 public class ResponseWrapper extends HttpServletResponseWrapper { private final ByteArrayOutputStream buffer new ByteArrayOutputStream(); public ResponseWrapper(HttpServletResponse response) { super(response); } Override public ServletOutputStream getOutputStream() { return 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 PrintWriter getWriter() { return new PrintWriter(buffer); } public byte[] getBytes() { return buffer.toByteArray(); } }在Filter末尾调用chain.doFilter后从ResponseWrapper里取出明文内容用会话密钥加密后塞回真正的response流。有个细节必须在业务线程完全返回后处理否则拿不到完整数据。用OncePerRequestFilter加自定义包装类这个顺序天然是保证的。3.4 前端配合的几个关键点这个方案必须前后端一起改。前端拿到RSA公钥后逻辑是固定的生成随机AES会话密钥H5端可用Web Crypto APIApp端用系统自带的加密库不要自己写随机数。对明文payload做AES-GCM加密。用RSA公钥加密AES会话密钥。组装统一结构发送请求。收到响应后用同一个AES密钥解密。前端最常犯的错误是Base64编码不一致。Java默认的Base64标准编码和解码都是线程安全的但H5端要注意URL安全的Base64和标准Base64不是一回事。我建议前后端统一使用“标准Base64不换行”并且字符集统一UTF-8能规避掉90%的乱码问题。另外一个很实际的点调试期怎么办。总不能前端每次联调都要先生成密钥、加密报文再发请求吧我这边做了一个开关接口测试环境可以直接明文请求响应也明文返回只有线上环境强制加密。开关配置在服务端和接口的加解密开关绑定api: security: crypto-enabled: true # 线上真加密 # debug: false # 本地调测时改为false直接透传这个开关的粒度必须到接口级别而不是全局。有的接口比如健康检查、静态配置根本不需要加密全局加密会把简单事情复杂化。我是用注解加路径白名单双层控制注解优先。4. 踩坑实录与排查技巧4.1 常见问题速查表现象大概率原因处理方式解密后中文乱码字符集不一致服务端用GBK而前端用UTF-8统一指定UTF-8并在响应头显式声明报错Tag mismatchAES-GCM解密失败说明密钥不对或密文被改动核对AES密钥是否一致检查payload是否被截断报错BadPaddingExceptionRSA解密失败通常公钥私钥不匹配检查密钥对版本是否重新生成过请求体为nullFilter提前读了InputStream用ContentCachingRequestWrapper缓存或自己实现RequestWrapper响应体为空ResponseWrapper没获取到完整数据确认在chain.doFilter返回后再加密输出Base64解码异常前端用了URLSafe编码或加了换行统一标准Base64编码前去除换行Controller拿到的还是密文Filter顺序错了Spring MVC先执行了将ApiCryptoFilter注册到FilterRegistrationBean的最前位时间戳校验老失败客户端时间不准或误差设置太小允许前后5分钟偏移并校准客户端时间这里最大的坑是RSA解密BadPaddingException。我当时排查了整整一个下午最后发现是有个环境变量里配置的RSA密钥对和服务端实际加载的不是同一对。这种问题最隐蔽因为代码、算法、格式全对就是密钥不是同一对。后来我统一把密钥版本号加到请求结构里客户端拿着哪个版本的公钥加密服务端就用对应版本的私钥解密再没出过这种问题。4.2 中文乱码和Base64的细节中文乱码看着简单实际上一旦出现就是连环问题。我遇到过的情况是AES解密后字节数组是好的但转字符串时用了平台默认编码。Linux服务器上默认一般是UTF-8Windows开发机默认是GBK同一段代码在开发环境正常上线就乱码。我给自己定了个铁律所有涉及字节和字符串转换的地方显式指定StandardCharsets.UTF_8绝不依赖系统默认。Base64方面我要提醒Base64.getEncoder()默认输出会有换行符吗JDK的Base64.getEncoder()是不换行的但有些类库如android.util.Base64默认开启换行Java服务端解析时会遇到IllegalArgumentException。所以我在协议里明确要求所有Base64字符串必须是“单行、无换行、标准字符集”。4.3 调试期怎么优雅地“关掉”加密我在前面提到了开关机制这里展开讲讲。crypto-enabled这个开关在测试环境设为false时Filter直接透传不上加密逻辑联调和本地写单测都会很舒服。但要注意不能只靠一个开关就完事还要处理好“部分接口加密、部分接口不加密”的过渡期。我常用的做法是每个接口在注解里声明是否需要加解密比如ApiSecurity(encrypt true)。Filter处理时先看注解注解没标就按配置决定。这样老接口可以逐步迁移新接口默认强制加密灰度发布时只放开影响面小的几个接口不会一把梭。线上排查问题时会遇到一个尴尬的场景加密接口出故障了想用curl模拟请求都做不到得写个小工具生成密文。我建议提前写一个命令行小工具输入明文和公钥直接输出加密后的完整报文。排查问题时能省很多时间。4.4 性能与日志脱敏很多人担心加解密拖垮接口性能我实测下来AES-GCM在有硬件加速的CPU上非常快即使普通云服务器加密1KB数据耗时也就在微秒到毫秒级别。真正有开销的是RSA非对称加密一次RSA-2048加密大概在几毫秒到十几毫秒但我们的设计里RSA只加密一个32字节的AES密钥而不是整个报文所以整体开销完全可以接受。性能优化的两个建议一是如果接口QPS很高可以缓存公钥和私钥对象避免每次解密都从文件或ClassPath重新加载二是如果对延迟极度敏感考虑服务端下发AES密钥时用长会话模式客户端在会话有效期内复用同一个AES密钥减少RSA加解密的次数。但要注意长会话会降低密钥安全性建议控制有效时间在10分钟以内并且和nonce校验结合使用。日志脱敏方面我有一个血泪教训Filter里解密的密文和明文任何一层都不要打印。尤其是明文的payload一旦打到日志里面前面所有加密等于白做。我有次排查问题时顺手在Filter里加了一行日志把解密后的请求体打了出去上线一周后安全扫描就报了敏感数据泄露。后来我专门在日志规范里加了一条含有手机号、身份证、银行卡、token等字段的日志一律掩码处理密文长度超过128位的body不允许整体打印只能打印长度和MD5摘要。5. 从“能用”到“好用”的经验沉淀5.1 密钥管理与轮换密钥管理是最容易被忽略但又最关键的一环。我见过有些团队把RSA私钥直接放在代码仓库里还写死在常量类里这基本等于把钥匙挂在门锁旁边。我的做法是RSA私钥和HMAC签名密钥放在环境变量或配置中心不进入代码仓库。每个环境用单独一套密钥测试、预发、生产互不相同。密钥版本号放到统一请求结构里线上切换密钥时保留上一版本私钥继续解密一段时间等客户端都切到新版本后再下线旧密钥。这个“双版本并行”的窗口期一般控制在3到7天。AES会话密钥是每次请求临时生成的不需要持久化用完就丢。真正需要运维的是RSA密钥对和HMAC密钥建议至少每三个月轮换一次。轮换时先发新公钥给客户端等客户端完成切换后再把旧私钥停用。整个过程尽量在低峰期操作并且提前准备好回滚预案——万一客户端有兼容性问题能快速切回旧版本。5.2 别只做加解密纵深防御要配套应用层加解密只是接口安全的一个环节它解决的是“看得懂、改不了、赖不掉”的问题但挡不住所有攻击。我建议在加解密之外把下面几件事一起做防重放时间戳nonce配合服务端nonce缓存在有效期内重复使用的nonce直接拒绝防篡改HMAC签名覆盖payload、时间戳、nonce和version任何字段变动都验签失败防弱口令AES密钥必须真随机严禁用固定字符串或UUID做种子输入校验解密后的数据仍然要做参数校验、长度限制、白名单校验不能因为“已经解密了”就放松另外我还会在网关层做限流和风控比如同一IP单位时间内访问次数超过阈值就临时封禁。加密和风控是两条线加密解决数据安全风控解决滥用行为两者缺一不可。5.3 这个方案还能怎么扩展如果后续要接入更多客户端类型可以把加解密能力沉淀成一个独立的SDK前端、App、后端统一引用同一个版本的SDK避免各端算法实现不一致导致互调失败。如果是微服务架构可以考虑把加解密能力下沉到网关层在网关统一做RSA、AES交换下游服务只拿到解密后的明文这样下游服务无感知接入成本最低。但对下游服务来说数据在服务内部走的是明文内部链路的安全性要靠其他手段补足比如服务间调用单独做服务间认证和通信加密。如果业务量再大可以上专用的密钥管理服务来做密钥的存储和轮换替代手工管理密钥对的方式。不过这也意味着要引入新的依赖项和运维成本需要权衡。6. 最后分享一些实操体会我在实际项目中踩过几次坑之后总结的经验是加解密方案真正难的点从来不是“会不会写AES”而是“密钥怎么管、兼容怎么做、排查怎么搞”。密钥管理做不好再标准的算法也会在灰度发布时炸掉兼容设计做不好前端老版本用户直接请求失败比不加密还麻烦排查工具没准备线上出问题只能干瞪眼。我个人强烈建议动手之前先写一页技术方案把算法选型、报文格式、密钥生命周期、调试开关、排查工具五件事列清楚再让前后端同学评审一遍。方案定了以后再写代码会顺畅很多。这个内容后续还可以这样扩展做一个自动生成“加密报文”的命令行工具再把接口加解密状态做成仪表盘监控看看哪些接口还没接入、哪些接口加解密耗时异常。等整个体系跑一段时间你会明显感觉到接口层面的数据裸露少了很多心里踏实不少。

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

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

免费获取报价 →
↑