最近在给一个SpringCloud项目做接口加密改造,趁着复盘把完整的思路和落地细节整理出来。起因其实很直白:安全评审时发现核心接口全是明文传输,登录密码、用户手机号、订单金额都裸露在请求体里,内网机器只要被突破,这些数据就能被批量拖走。于是我们定了个目标,所有敏感接口必须满足三件事——传输内容不可读、请求参数不可改、重复请求不可用。
这篇文章会把那次改造完整复盘一遍,从为什么加密、选什么方案、到网关和业务服务的代码落地,以及上线后踩过的几个大坑都写清楚。内容适合正在做SpringCloud接口改造的开发者,也适合需要给团队定加密规范的架构师。新手读完能知道从哪儿下手,老手可以直接跳到代码和排查部分对照。
1. 为什么做接口加密:先想清楚防谁,再决定怎么防
1.1 明文接口的真实风险,不只在抓包
先说一个容易误导的观点:很多人觉得系统部署在内网,外部抓不到包,接口就是安全的。但做过安全评审的都明白,内网从来不是铁板一块。以我们当时的系统为例,登录接口、用户查询接口、订单详情接口全部走HTTP明文,网关访问日志会记录完整URL,业务服务的应用日志还会把请求body打出来。任何一台边缘机器被攻破,或者运维误操作把日志同步到第三方,用户手机号、家庭住址这些高敏字段就等于直接摆在明面上。
更麻烦的是重放攻击。假设攻击者拿到了一个转账请求的报文,虽然不知道密码,但他可以把同样的报文原封不动地再发一次。系统如果没有幂等控制和防重放机制,就会出现重复扣款。这类问题在支付、订单这类场景里尤其致命。所以接口加密不只是“防抓包”,它的本质是把风险边界缩短到可控范围内:即使请求被截获,内容不可读;即使内容被篡改,验签会失败;即使内容有效,过期时间一到直接作废。
这里可以打个比方:明文请求像寄明信片,每一个经手的中转站都能看到卡面内容;接口加密加签之后,相当于把卡片装进带锁的信封,锁是AES,钥匙是RSA,封口处还按了签名,任何一道中转站想改动内容,收件人一验指纹就露馅。
1.2 加密不是把系统变成堡垒,而是把风险边界划出来
我在项目里反复强调一个观念:接口加密解决的是“数据在传输和存储链路中被不该看的人看到”的问题,不是要解决所有安全问题。系统安全是个分层模型,网络层有防火墙,传输层有HTTPS/TLS,应用层才轮到接口加密。你不可能靠一段AES代码挡住所有攻击,这不现实,也没必要。
所以做改造之前,我们先把加密范围划了出来:凡是包含用户身份、资金、联系方式三类数据的接口,一律走加解密流程;纯静态配置、公共服务数据、内部健康检查接口保持原样。这样做的直接收益是,性能敏感的低风险接口完全不受影响,加解密引入的延迟只出现在少数关键链路上。另一个隐形收益是排查问题更方便,日志里需要重点保护的字段少了,出问题反而容易定位。
1.3 先定需求边界,避免方案无限复杂
真正动手之前,团队坐下来把需求边界对了一遍,主要确认几个问题:调用方有哪些,是自家App、Web前端还是第三方开放平台;是否需要兼容已经上线的旧版本客户端;密钥的生成、下发、轮换由谁负责;加密失败的响应格式怎么统一。这几个问题看似琐碎,但它们直接影响技术选型。
比如当时我们有一批老旧客户端没法在一周内完成升级,如果直接上线强制加密,老版本全部不可用。所以我们定了灰度策略:新增一个加密开关,按接口维度控制,新版本客户端通过请求头标明自己支持加密,网关和业务服务对这类请求走解密逻辑,老版本先放行,等客户端覆盖率上去了再逐步收紧。很多人没做这个边界分析,一上来就追求全量加密,结果联调阶段被兼容性拖死。这个坑建议别踩。
2. 方案选型:为什么最后选了AES+RSA混合加密
2.1 HTTPS已经加密了,为什么还要做接口层加密
选型时第一个被问到的问题:我们不是有HTTPS吗,再加一层加密不是多余?这是很正常的质疑。HTTPS解决的是传输管道安全,它保证数据在客户端和服务端之间的网络上不被窃听。但它有两个盲区:一是服务端拿到明文请求后,可能会被应用日志、网关访问日志、数据库慢查询日志记录下来,HTTPS对链路之外的数据落地无能为力;二是有不少内网架构,服务之间走的是HTTP,网关做了一次TLS终止后,内部链路就没有加密了。
接口层加密的意义恰恰在这里:它加密的是消息内容本身,而不是传输通道。就算请求日志被全文打印出来,看到的也是密文,敏感字段不会被轻易拖走。所以HTTPS和接口加密不是二选一,它们是叠加关系。我们当时的标准做法是:对外统一走HTTPS,敏感接口在HTTP body层再做一次业务加密,两边同时要求,不把鸡蛋放在一个篮子里。
2.2 AES+RSA混合加密的设计思路
接下来是选加密算法。可能有人第一反应是直接用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 | 部署简单,应用无感知 | 服务端日志仍可能记录明文 | 低敏接口、统一传输加密底线 |
| RSA+AES混合加密 | 密钥不落盘、抗泄露能力强 | 客户端实现较复杂,需要管理RSA密钥对 | 面向公网开放的登录、支付等高敏接口 |
| 配置中心下发AES密钥 | 实现简单、加解密快 | 密钥存于两端,泄露风险需要控制 | 可信内网的服务间调用 |
当前项目同时保留了这两套,对外的敏感接口走RSA+AES,内部服务走共享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 | 随机字符串,一次性使用 |
| sign | HMAC-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 = "appId=app_001×tamp=" + 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 Mono<Void> 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 ResponseBodyAdvice<Object> { @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版本混乱的问题。如果看不到这些指标,你可能连接口加密到底有没有正常工作都不知道,那才是真正的隐患。