SpringCloud接口加密实战:AES+RSA混合加密与防重放设计
最近在给一个SpringCloud项目做接口加密改造趁着复盘把完整的思路和落地细节整理出来。起因其实很直白安全评审时发现核心接口全是明文传输登录密码、用户手机号、订单金额都裸露在请求体里内网机器只要被突破这些数据就能被批量拖走。于是我们定了个目标所有敏感接口必须满足三件事——传输内容不可读、请求参数不可改、重复请求不可用。这篇文章会把那次改造完整复盘一遍从为什么加密、选什么方案、到网关和业务服务的代码落地以及上线后踩过的几个大坑都写清楚。内容适合正在做SpringCloud接口改造的开发者也适合需要给团队定加密规范的架构师。新手读完能知道从哪儿下手老手可以直接跳到代码和排查部分对照。1. 为什么做接口加密先想清楚防谁再决定怎么防1.1 明文接口的真实风险不只在抓包先说一个容易误导的观点很多人觉得系统部署在内网外部抓不到包接口就是安全的。但做过安全评审的都明白内网从来不是铁板一块。以我们当时的系统为例登录接口、用户查询接口、订单详情接口全部走HTTP明文网关访问日志会记录完整URL业务服务的应用日志还会把请求body打出来。任何一台边缘机器被攻破或者运维误操作把日志同步到第三方用户手机号、家庭住址这些高敏字段就等于直接摆在明面上。更麻烦的是重放攻击。假设攻击者拿到了一个转账请求的报文虽然不知道密码但他可以把同样的报文原封不动地再发一次。系统如果没有幂等控制和防重放机制就会出现重复扣款。这类问题在支付、订单这类场景里尤其致命。所以接口加密不只是“防抓包”它的本质是把风险边界缩短到可控范围内即使请求被截获内容不可读即使内容被篡改验签会失败即使内容有效过期时间一到直接作废。这里可以打个比方明文请求像寄明信片每一个经手的中转站都能看到卡面内容接口加密加签之后相当于把卡片装进带锁的信封锁是AES钥匙是RSA封口处还按了签名任何一道中转站想改动内容收件人一验指纹就露馅。1.2 加密不是把系统变成堡垒而是把风险边界划出来我在项目里反复强调一个观念接口加密解决的是“数据在传输和存储链路中被不该看的人看到”的问题不是要解决所有安全问题。系统安全是个分层模型网络层有防火墙传输层有HTTPS/TLS应用层才轮到接口加密。你不可能靠一段AES代码挡住所有攻击这不现实也没必要。所以做改造之前我们先把加密范围划了出来凡是包含用户身份、资金、联系方式三类数据的接口一律走加解密流程纯静态配置、公共服务数据、内部健康检查接口保持原样。这样做的直接收益是性能敏感的低风险接口完全不受影响加解密引入的延迟只出现在少数关键链路上。另一个隐形收益是排查问题更方便日志里需要重点保护的字段少了出问题反而容易定位。1.3 先定需求边界避免方案无限复杂真正动手之前团队坐下来把需求边界对了一遍主要确认几个问题调用方有哪些是自家App、Web前端还是第三方开放平台是否需要兼容已经上线的旧版本客户端密钥的生成、下发、轮换由谁负责加密失败的响应格式怎么统一。这几个问题看似琐碎但它们直接影响技术选型。比如当时我们有一批老旧客户端没法在一周内完成升级如果直接上线强制加密老版本全部不可用。所以我们定了灰度策略新增一个加密开关按接口维度控制新版本客户端通过请求头标明自己支持加密网关和业务服务对这类请求走解密逻辑老版本先放行等客户端覆盖率上去了再逐步收紧。很多人没做这个边界分析一上来就追求全量加密结果联调阶段被兼容性拖死。这个坑建议别踩。2. 方案选型为什么最后选了AESRSA混合加密2.1 HTTPS已经加密了为什么还要做接口层加密选型时第一个被问到的问题我们不是有HTTPS吗再加一层加密不是多余这是很正常的质疑。HTTPS解决的是传输管道安全它保证数据在客户端和服务端之间的网络上不被窃听。但它有两个盲区一是服务端拿到明文请求后可能会被应用日志、网关访问日志、数据库慢查询日志记录下来HTTPS对链路之外的数据落地无能为力二是有不少内网架构服务之间走的是HTTP网关做了一次TLS终止后内部链路就没有加密了。接口层加密的意义恰恰在这里它加密的是消息内容本身而不是传输通道。就算请求日志被全文打印出来看到的也是密文敏感字段不会被轻易拖走。所以HTTPS和接口加密不是二选一它们是叠加关系。我们当时的标准做法是对外统一走HTTPS敏感接口在HTTP body层再做一次业务加密两边同时要求不把鸡蛋放在一个篮子里。2.2 AESRSA混合加密的设计思路接下来是选加密算法。可能有人第一反应是直接用RSA把所有body加密。但纯RSA有两个硬伤加密速度比AES慢很多而且可以加密的明文长度受密钥长度限制。比如1024位的RSA最多加密117字节的明文一个正常的业务JSON根本放不下硬要塞进去只能分段处理代码变得很丑性能也难看。所以业内通用的做法是混合加密AES负责加密业务数据RSA负责加密AES的密钥。打个比方你把重要文件放进保险箱保险箱用AES密码锁锁住然后把密码放进一个RSA信封里信封只有私钥持有者能打开。这样即使业务数据再大AES也能高速处理而RSA只处理几十字节的密钥性能问题就消失了。我们的方案流程是客户端为每个请求生成一个随机的AES密钥用RSA公钥加密后放在请求头里body用AES密钥加密成密文。服务端用自己的RSA私钥解出AES密钥再用这个密钥解body。不过在内部服务之间调用时我们还提供了另一条路直接从配置中心下发AES密钥两端共享同一个密钥省略RSA交换过程。这两种方案各有场景我列了个对比表选型时可以直接对照。方案优点缺点适用场景仅HTTPS部署简单应用无感知服务端日志仍可能记录明文低敏接口、统一传输加密底线RSAAES混合加密密钥不落盘、抗泄露能力强客户端实现较复杂需要管理RSA密钥对面向公网开放的登录、支付等高敏接口配置中心下发AES密钥实现简单、加解密快密钥存于两端泄露风险需要控制可信内网的服务间调用当前项目同时保留了这两套对外的敏感接口走RSAAES内部服务走共享AES密钥成本和安全取了个平衡。2.3 签名与防重放加密不代表请求可信加密只解决了“内容看不了”没解决“内容被改了怎么办”和“同样的请求被重复提交怎么办”。这两个问题要交给签名和防重放机制。签名的作用是让服务端能验证请求是否被篡改。我们用的是HMAC-SHA256客户端把appId、timestamp、nonce、加密后的body拼成一个待签名字符串用appSecret做HMAC计算结果放在header的sign字段。服务端用同样的规则计算一遍两边结果不同就直接拒绝。这里有个常见的误区很多人用AES加密就当签名用了但AES解密只保证密钥对得上不保证原始内容没被换掉签名一定要单独做。防重放的做法是timestamp加nonce。请求头带一个毫秒时间戳和一个随机字符串nonce服务端先检查时间戳是否在5分钟窗口内再检查Redis里有没有这个nonce如果已经出现过就判定为重复请求直接拒绝。这样即使攻击者拿到了一个合法请求报文也没法在后面重复提交。2.4 密钥生命周期与配置中心管理密钥管理是最容易被忽略、但上线后最要命的一环。如果密钥是硬编码在代码里的轮换的时候就要发版本而且一旦代码仓库泄露所有密钥一起泄露。我们用的方案是配置中心集中管理服务启动时拉取密钥配置并监听配置变更自动刷新。每个密钥带一个版本号keyVersion请求头带上这个版本服务端按对应版本解密这样轮换时可以新旧两版并存客户端可以平滑切换。上下线流程要定清楚新版本密钥先推到配置中心保留旧版本继续可用灰度观察一段时间确认客户端全部切过去之后再禁用旧版本。密钥的生成也要有固定流程建议由专人负责生成后直接写入配置中心不经过代码仓库、不写进文档、不打印到日志。这一块属于上线之后很难补的东西一开始没有流程约束后面会非常被动。3. 核心细节与实操要点编码、拦截与灰度3.1 加解密算法里的几个隐藏坑很多第一次写接口加密的同学代码跑通是秒过一到跨语言联调就崩问题几乎都出在细节不一致。先看算法选型Java这边常用的是AES/ECB/PKCS5Padding。ECB模式不需要IV实现最简单但语义安全不如GCM。如果要更强的安全等级建议用AES/GCM/NoPadding不过GCM对部分老版本客户端SDK支持不好选型之前一定要先确认客户端平台能不能支持。我们当时为了兼容性第一个版本选了ECB后续再考虑升级GCM。编码也是个重灾区。AES加密出来的是字节数组不能直接new String出来放到JSON里要用Base64转成字符串。解密的时候先Base64解码再走AES解密最后把明文byte数组转成UTF-8字符串。顺序反了或者编码用错了最常见的就是中文乱码。另外要注意密钥长度AES-128要求密钥正好16字节AES-256要求32字节。如果用256位密钥部分JDK版本需要额外处理权限文件不然会直接在init时报错。跨语言对接的时候密钥长度、padding方式、Base64是否带换行都要提前对齐。3.2 用Filter还是Interceptor解密要提前到读取Body之前解密逻辑放哪层直接影响代码优雅程度。放在Controller里是一种做法但每个接口都要写重复代码而且解密异常处理很分散。放在Interceptor里比Controller好一点但Spring MVC在处理请求时body已经可以被后续参数解析流程读取如果Controller用RequestBody接收参数拦截器里很难优雅地替换请求体。真正靠谱的做法是用Servlet的Filter而且最好继承OncePerRequestFilter保证一个请求只经过一次。核心操作是写一个自定义HttpServletRequestWrapper重写getInputStream和getReader让它们返回解密后的数据。这样Filter里把密文解密成明文塞进包装类后续的RequestBody拿到的就是解密后的正常JSON业务代码一行不用改。注意body流只能读一次如果想先读出来解密不能直接在原request上操作一定要用包装类把新内容接住再往下传。我们在联调时踩过这个坑后面排查部分细说。3.3 自定义注解实现接口灰度刚开始改造并不需要所有接口都强制走解密。为了控制风险和联调范围我们做了一个自定义注解EncryptEndpoint标在需要加密的Controller方法上。Filter里通过判断当前HandlerMethod是否带有这个注解来决定要不要执行解密流程。不过这里有个小麻烦OncePerRequestFilter里拿不到HandlerMethod因为MVC的HandlerMapping还没执行完。我们的处理方案是先用RequestMappingHandlerMapping解析出HandlerExecutionChain再取出HandlerMethod判断。这个流程在Spring Cloud环境里路径比较长如果不想做这么重的解析也可以用URL前缀来控制比如所有敏感接口放在/api/secure/路径下面Filter判断路径前缀实现同样简单。灰度期间两种方式都试过注解方式更精准URL前缀方式很容易落地看团队习惯选择就行。注解方式的另一个好处是后续做响应加密时同一个注解也能被ResponseBodyAdvice复用。一个注解同时控制请求解密和响应加密两端的开关状态始终一致不容易出现请求已经加密、响应还是明文的割裂状态。3.4 统一报文格式与错误码接口加密最怕客户端和服务端各搞一套格式。我们在一开始就定了统一的报文约定请求body整体是一个密文字符串所有元数据放header。这个约定直接决定了两端联调的工作量所以在动手之前先把下面的表同步给客户端团队。Header字段说明appId客户端应用标识timestamp当前毫秒时间戳nonce随机字符串一次性使用signHMAC-SHA256签名keyVersion密钥版本号错误码也统一处理解密失败、验签失败、时间戳过期、重复请求分别对应不同码。这块不提前约定客户端会把所有失败都当成系统异常用户看到的提示五花八门排查问题还要靠抓包效率很低。我们的约定比较简单1001解密失败1002验签失败1003请求已过期1004重复请求。后来新增问题基本都是在这个框架上扩展。响应体的加密约定同样重要。我们约定响应体如果被加密外层包一个JSON包含encrypted字段和data字段。客户端先判断encrypted是否为true再决定直接解析还是先解密这样加密逐步灰度的时候客户端也能兼容两种返回结构。4. 完整实操从密钥分发到业务服务解密的落地链路4.1 密钥配置与版本管理落地第一步是配置下沉。我们当时的配置中心已经接入了Spring Cloud Config链路新增密钥配置很顺。配置里包括appId对应的AES密钥或RSA公钥私钥、keyVersion、有效时间。服务启动时从配置中心拉取放进本地缓存监听变更做刷新。一个简化版的配置示例如下encrypt: enabled: true keys: - version: 1 appId: app_001 algorithm: AES secretKey: abcdef0123456789 rsaPublicKey: ... rsaPrivateKey: ...配置中心的坑在于多环境隔离。我们当时把dev、test、prod的密钥完全分开禁止测试环境密钥用到生产这看起来是常识但在配置复制粘贴时特别容易出错。建议在配置文件的命名上带上环境后缀发布流程里加一道检查别让生产环境悄悄加载了测试密钥。4.2 客户端请求组装示例客户端发起请求时要经过一次完整的组装流程生成随机AES密钥、加密body、用RSA公钥加密AES密钥、拼签名。这里给一个Java客户端的简化示例App端逻辑完全类似。String originBody {\mobile\:\13800000000\,\password\:\123456\}; // 1. 生成随机AES密钥128位 16字节 byte[] aesKey generateAesKey(); // 2. 用AES加密业务body String encryptedBody Base64.getEncoder().encodeToString( AesUtil.encrypt(originBody.getBytes(StandardCharsets.UTF_8), aesKey)); // 3. 用RSA公钥加密AES密钥 String encryptedAesKey Base64.getEncoder().encodeToString( RsaUtil.encrypt(aesKey, LoadPublicKey())); // 4. 拼参与签名 String timestamp String.valueOf(System.currentTimeMillis()); String nonce UUID.randomUUID().toString(); String signStr appIdapp_001timestamp timestamp nonce nonce body encryptedBody; String sign HmacUtil.hmacSha256(signStr, appSecret);签名拼接的字段顺序一定要跟服务端约定一致。我们当时约定按字段名ASCII升序拼接客户端和服务端各写一份文档实际联调中为了这个顺序问题来回改了两版后面排查章节会专门说。4.3 网关层校验与转发Spring Cloud Gateway作为统一入口很适合做全局校验。我们把签名校验、时间戳校验、nonce去重放在网关这一层业务服务只负责解密body。这样做的原因是网关可以访问Redis适合做防重放而且网关校验失败时直接在入口就拦住不会让无效请求打进后台服务浪费计算资源。Component public class SecurityGlobalFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); // 1. 从header取appId、timestamp、nonce、sign、keyVersion // 2. 校验timestamp时间窗口 // 3. 校验nonce是否已存在于Redis // 4. 按约定规则计算sign对比header中的sign // 5. 校验通过后继续否则返回统一错误码 return chain.filter(exchange); } Override public int getOrder() { return -100; } }这里要特别说明的是网关做了身份和签名校验之后要不要把body解密成明文再转给下游取决于内部链路的安全等级。如果网关到业务服务之间是可信内网建议直接转发明文让业务服务只关注业务逻辑避免两次解密。如果内部链路也不完全可信可以走内部共享AES密钥再加密一次。两套方案都可以但一定要提前定好最怕的是网关解密了业务服务又解一次最后报错。这个问题我们真遇到过后面排查章节展开。4.4 业务服务的Request包装与解密实现如果业务服务负责解密body核心就是一个OncePerRequestFilter加一个HttpServletRequestWrapper。第一步先读原始body第二步解析header中的元数据第三步校验签名、解密第四步用解密后的内容构造包装Request最后继续转给业务代码。Component public class DecryptRequestFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { if (!shouldDecrypt(request)) { chain.doFilter(request, response); return; } String encryptedBody StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); String appId request.getHeader(appId); String timestamp request.getHeader(timestamp); String nonce request.getHeader(nonce); String sign request.getHeader(sign); String keyVersion request.getHeader(keyVersion); // 1. 校验时间戳窗口 // 2. 校验nonce // 3. 计算签名并比对 // 4. 根据keyVersion取密钥解密body String plainText AesUtil.decrypt(encryptedBody, loadSecretKey(keyVersion)); DecryptRequestWrapper wrapper new DecryptRequestWrapper(request, plainText.getBytes(StandardCharsets.UTF_8)); chain.doFilter(wrapper, response); } }public class DecryptRequestWrapper extends HttpServletRequestWrapper { private final byte[] body; public DecryptRequestWrapper(HttpServletRequest request, byte[] body) { super(request); this.body body; } Override public ServletInputStream getInputStream() { return new DelegatingServletInputStream(new ByteArrayInputStream(body)); } Override public BufferedReader getReader() throws IOException { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } }包装类的两个重写方法缺一不可只重写getInputStream不重写getReader某些框架解析body时照样读不到数据。DelegatingServletInputStream是Spring自带的一个适配类直接new ByteArrayInputStream包一层就行不用自己实现ServletInputStream。这个细节看起来小但能省不少事。4.5 响应加密与统一结果包装响应加密我们用的是ResponseBodyAdvice在Controller返回之后、写到客户端之前拦截。判断逻辑很简单如果方法上标了EncryptEndpoint就把返回结果整体加密然后包装成统一的响应结构。RestControllerAdvice public class EncryptResponseAdvice implements ResponseBodyAdviceObject { Override public boolean supports(MethodParameter returnType, Class? extends HttpMessageConverter? converterType) { return returnType.hasMethodAnnotation(EncryptEndpoint.class); } Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class? extends HttpMessageConverter? selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { // body是业务返回值序列化成JSON字符串后加密 String json JSON.toJSONString(body); String encrypted AesUtil.encrypt(json, currentSecretKey()); return Map.of(encrypted, true, data, encrypted); } }用ResponseBodyAdvice有个小坑同一个注解既控制请求解密又控制响应加密时如果返回对象已经被其他Advice处理过容易重复加密。我们后来给响应包装类加了一个标记字段已经加密过就直接放行避免二次处理。响应加密的粒度也要控制大面积响应体加密反而会让客户端变得很慢只对敏感接口加密就够用了。5. 常见问题与排查技巧实录5.1 解密后中文乱码中文乱码是最常见的问题十次里有八次是编码链路上某一步用了默认字符集。我们的经验结论是明文在发送端一定要用UTF-8编码成字节加密后的密文用Base64转成字符串解密时先Base64解码再AES解密最后把明文字节用UTF-8转成字符串。这三步任何一步忘了指定字符集都可能乱码。尤其不要在密文上直接调用new String(cipherBytes)然后不传字符集依赖系统默认编码跨平台必出问题。5.2 解密长度异常有同学遇到过IllegalBlockSizeException提示Input length must be multiple of 16。这个报错基本可以锁定在三个原因密钥长度不对、padding模式不一致、或者密文在传输过程中被截断。先检查密钥是不是16字节再确认两端算法字符串完全一致比如Java的AES/ECB/PKCS5Padding和Node.js的AES-128-ECB/PKCS5Padding有时候表面一致实际填充方式有细微差别。最后检查密文有没有被网关或日志系统截断过我们有一次就是因为网关日志打印了超长body把字符串做了截断处理导致下游解密直接失败。5.3 网关和业务服务重复解密这个坑在微服务环境太典型了。网关实现了一版解密业务服务也实现了一版解密结果网关解密后的明文到了业务服务又被当成密文解了一次出来的全是乱码。我们的解法是约定一个内部标识头比如x-decrypted: true。网关解密并替换body后在转发请求时加上这个头业务服务看到这个标识就跳过解密逻辑。如果业务服务需要在内网安全场景下重新加密再落库那就走另外一套标识。关键是整个链路要提前画清楚哪个环节做校验、哪个环节做解密、哪个环节保持原样写进接口文档里别让人凭感觉实现。5.4 验签失败参数排序和时间戳是重灾区验签失败排查起来最耗时。我们的常规做法是让客户端和服务端各打一份待签名字符串直接比对。排在最前面的坑是参数排序规则不一致客户端按sign、body、timestamp的顺序拼服务端按ASCII排序两边算出来的sign肯定不一样。这个必须提前约定而且文档里要写清楚排序依据是字段名还是字段值。第二个坑是时间戳格式不统一有人用秒有人用毫秒服务端做时间窗口校验的时候把秒当毫秒算结果永远在5分钟之外。建议统一规定毫秒时间戳并且把单位写在文档最显眼的位置。第三个坑是签名串里带了空白字符JSON序列化时多了一个空格肉眼看不见算出来的哈希完全不同。联调阶段建议先用日志把待签字符串打出来肉眼比对一遍再找算法问题。5.5 加解密性能与长文本问题性能方面AES加解密的速度是非常快的一个普通业务JSON走一遍AES基本在毫秒级不需要过度担心。RSA才是性能瓶颈但混合加密里RSA只处理几十字节的密钥所以总体影响也可控。真正要担心的是超大body。ECB模式下单次加密的明文长度有上限如果接口上传的是大文本或者批量数据直接套AES工具类加密会报错。常规做法是body先走GZIP压缩再走AES加密。这样既解决了长度限制还能减少传输流量。不过网关层要注意配置请求体大小限制不然解压后可能会超出预期相关参数建议提前评估。5.6 服务端时钟偏差导致请求被拒timestamp校验最烦的问题不是逻辑写错而是服务端和客户端时间不一致。容器漂移、宿主机时间没同步都会导致明明是刚发出来的请求服务端判断已经是几分钟前的事了。我们的对策有两层第一层所有生产环境统一配置NTP时间同步这个在运维侧搞定第二层时间窗口不能设得太极端。互联网场景5分钟是比较常规的配置内网服务可以把窗口放宽到10分钟同时配合nonce去重窗口大一点不会带来安全问题因为nonce一次性校验已经挡住了重放。6. 上线检查清单与个人实操体会6.1 上线前按这个清单逐项检查改造完成后上线前我们把检查项列成了一张清单这里直接分享出来很多是踩坑之后补进去的。检查项说明密钥已推送配置中心确认所有环境密钥独立生产未加载测试密钥灰度开关已就绪确认加密接口范围和灰度策略已配置旧版本客户端兼容确认老版本接口路径仍可用或已评估强制升级影响Redis连接正常防重放nonce依赖Redis提前确认集群健康日志脱敏确认应用日志和网关日志不打印明文body和密钥错误码文档同步客户端和服务端使用同一套错误码定义监控告警已配置解密失败率、验签失败率指标已纳入监控面板里面日志脱敏这条我们是在上线之后才补的过程比较狼狈。第一次全量观察时发现虽然请求是密文进来但业务代码在调试日志里主动打了解密后的明文参数等于加密做了个寂寞。所以日志脱敏的检查一定要排在前面不能指望开发自觉。6.2 两次踩坑换来的经验如果让我给还在做同类改造的团队提一条最重要的建议我会说先把整个调用链路的加解密职责画清楚再动手写代码。我们第一次就是网关做了一半、服务做了一半、客户端也不知道到底加没加签名联调阶段几乎被这些问题消耗掉了所有时间。后来把每个环节的职责写清楚代码反而很快就写完了。第二就是别追求一次全量上线灰度价值非常大。我们当时只开放了登录接口和用户信息接口的加密观察了两周没有异常才慢慢把订单、支付类接口切过来。这样就算出问题影响面也是可控的。接口加密不是越快越好是越稳越好安全改造本身也要考虑稳定性这两件事不冲突。最后加密状态要能被观测。上线之后我在监控面板上加了两个指标加解密成功率、验签失败率这两个指标后来帮我们抓到了好几波客户端SDK版本混乱的问题。如果看不到这些指标你可能连接口加密到底有没有正常工作都不知道那才是真正的隐患。