1. 为什么需要全链路加解密?
在传统HTTP接口开发中,我们经常会遇到一个尴尬的局面:业务逻辑写得飞起,却忽略了数据传输过程中的安全隐患。想象一下,你的用户密码、身份证号、银行卡信息就这样赤裸裸地在网络上传输,就像把自家钥匙挂在门口一样危险。
我去年参与过一个电商项目,在安全审计阶段,我们用Wireshark抓包工具轻松获取到了所有API请求和响应的明文数据。那一刻,整个团队后背发凉——这意味着任何中间人攻击都能轻易获取敏感信息,甚至篡改交易金额。
1.1 常见安全威胁剖析
数据泄露风险:明文传输就像用明信片寄送机密文件,途径的每个节点(路由器、代理服务器等)都能查看内容。去年某知名APP的数据泄露事件,根源就是传输未加密。
篡改攻击:攻击者拦截请求后,可以修改参数值。比如把转账金额从100元改成10000元,如果后端没有校验机制,后果不堪设想。
重放攻击:黑客录制合法请求后重复发送。例如重复执行支付操作,即使接口需要登录认证也无法防范。
1.2 混合加密方案选型
经过多次技术论证,我们最终选择RSA+AES混合方案,这是目前金融级应用的主流做法:
-
RSA:非对称加密,用于安全交换密钥。特点是加密速度慢但安全性高,特别适合传递AES密钥。
-
AES:对称加密,用于加密业务数据。特点是加密速度快,适合大数据量加密。
这种组合既解决了对称加密密钥传输的安全问题,又规避了非对称加密性能差的缺陷。实测表明,相比纯RSA加密,混合方案的吞吐量能提升20倍以上。
关键经验:不要使用固定密钥!我们初期犯过的错是写死RSA密钥对,导致所有环境共用同一套密钥。正确做法是每个服务实例启动时动态生成密钥对。
2. 整体架构设计
2.1 技术组件拆解
实现全链路加解密需要以下核心组件协同工作:
| 组件 | 职责说明 | 技术实现选择 |
|---|---|---|
| 密钥管理中心 | 生成/存储RSA密钥对 | 建议使用HSM硬件加密机 |
| 加密过滤器 | 拦截请求进行解密 | Spring的OncePerRequestFilter |
| 解密过滤器 | 拦截响应进行加密 | ResponseBodyAdvice |
| 签名验签模块 | 防篡改和防重放 | 自定义注解+AOP |
| 密钥交换协议 | 客户端获取服务端公钥 | 单独的/key接口暴露公钥 |
2.2 核心流程时序图
java复制// 伪代码展示核心交互流程
客户端 -> 服务端: 请求获取RSA公钥
服务端 -> 客户端: 返回公钥PEM格式
客户端 -> 服务端: 用公钥加密AES密钥+参数
服务端 -> 服务端: 用私钥解密获取AES密钥
服务端 -> 服务端: 用AES密钥解密业务参数
服务端 -> 服务端: 执行业务逻辑
服务端 -> 客户端: 用AES密钥加密响应数据
2.3 性能优化要点
高安全方案往往伴随性能损耗,我们通过以下手段将影响控制在5%以内:
- 缓存AES密钥:客户端在header携带密钥ID,服务端缓存映射关系,避免每次解密
- 连接复用:HTTP Keep-Alive减少TLS握手开销
- 异步加密:耗时操作放入专用线程池,不阻塞主线程
- 选择性加密:通过注解标记需要加密的字段,非敏感字段不处理
3. 防篡改与防重放机制
3.1 数字签名实现
单纯加密无法防止参数篡改,我们引入SHA256withRSA签名算法:
java复制// 签名生成示例
String data = "amount=100&receiver=alice";
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initSign(privateKey);
signature.update(data.getBytes());
byte[] digitalSignature = signature.sign();
客户端需要在header携带:
- X-Sign-Timestamp:当前时间戳(防重放)
- X-Sign-Nonce:随机字符串(防重放)
- X-Signature:签名结果(防篡改)
服务端验证逻辑:
- 检查时间戳在5分钟内(防重放)
- 检查nonce未被使用过(防重放)
- 用公钥验证签名是否匹配(防篡改)
3.2 重放攻击防护
我们采用Redis实现nonce校验:
java复制// 伪代码:nonce校验
String nonceKey = "nonce:" + nonce;
if (redisTemplate.opsForValue().setIfAbsent(nonceKey, "1", 5, TimeUnit.MINUTES)) {
// 通过校验
} else {
throw new ApiException("请求重复");
}
踩坑记录:初期我们没考虑集群环境,导致不同节点的Redis数据不同步。解决方案是改用中央Redis服务,或者使用Spring Session实现会话共享。
4. 完整代码实现
4.1 密钥对生成
java复制// RSA密钥对生成工具类
public class KeyPairGenerator {
public static KeyPair genKeyPair() throws NoSuchAlgorithmException {
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048); // 推荐2048位密钥
return keyGen.generateKeyPair();
}
public static String getPublicKeyStr(PublicKey publicKey) {
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
}
}
4.2 加解密过滤器
java复制// 请求解密过滤器
public class DecryptFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException {
// 获取加密报文
String encrypted = request.getReader().lines()
.collect(Collectors.joining());
// 获取客户端加密的AES密钥
String encryptedKey = request.getHeader("X-Encrypt-Key");
// RSA解密获取AES密钥
byte[] aesKeyBytes = RSAUtil.decrypt(encryptedKey, privateKey);
SecretKeySpec aesKey = new SecretKeySpec(aesKeyBytes, "AES");
// AES解密业务数据
String decrypted = AESUtil.decrypt(encrypted, aesKey);
// 包装请求体继续传递
chain.doFilter(new RequestWrapper(request, decrypted), response);
}
}
4.3 响应加密处理
java复制// 响应加密处理器
@ControllerAdvice
public class EncryptResponse implements ResponseBodyAdvice {
@Override
public boolean supports(MethodParameter returnType,
Class converterType) {
return returnType.hasMethodAnnotation(Encrypt.class);
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType,
MediaType selectedContentType,
Class selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response) {
// 从请求属性获取AES密钥(解密过滤器存入)
SecretKeySpec aesKey = (SecretKeySpec)request.getAttribute("aesKey");
// 加密响应体
String encrypted = AESUtil.encrypt(JsonUtils.toJson(body), aesKey);
return new EncryptedResponse(encrypted);
}
}
5. 测试与上线要点
5.1 自动化测试方案
使用WireMock模拟客户端行为:
java复制@Test
public void testFullFlow() throws Exception {
// 1. 获取服务端公钥
String publicKey = mockMvc.perform(get("/api/key"))
.andReturn().getResponse().getContentAsString();
// 2. 生成AES密钥并加密
SecretKey aesKey = AESUtil.generateKey();
String encryptedKey = RSAUtil.encrypt(
Base64.getEncoder().encodeToString(aesKey.getEncoded()),
publicKey);
// 3. 加密请求数据
String encryptedBody = AESUtil.encrypt("{\"amount\":100}", aesKey);
// 4. 发送请求
mockMvc.perform(post("/api/transfer")
.header("X-Encrypt-Key", encryptedKey)
.content(encryptedBody))
.andExpect(status().isOk());
}
5.2 灰度发布策略
由于加密方案涉及客户端改造,我们采用分阶段发布:
- 兼容模式运行:服务端同时支持加密和明文请求,通过header区分
- 客户端双写:新版本客户端同时发送明文和加密请求
- 服务端校验:对比明文和密文结果是否一致
- 全量切换:验证无误后关闭明文通道
这种方案保证了零停机升级,即使新版本有问题也能快速回滚。
6. 生产环境问题排查
6.1 常见异常处理
问题一:javax.crypto.BadPaddingException: Decryption error
排查步骤:
- 检查客户端和服务端RSA密钥是否匹配
- 验证密钥是否被意外硬编码
- 确认没有跨环境使用密钥(如测试密钥用于生产)
问题二:AES加密后解密失败
解决方案:
- 确保IV参数(初始化向量)在加解密时一致
- 检查是否使用了不同的填充模式(如PKCS5Padding和PKCS7Padding不兼容)
- 验证Base64编码/解码是否正确
6.2 监控指标配置
我们在Prometheus中配置了以下关键指标:
yaml复制# 加密相关指标
- name: api_encrypt_seconds
help: API加解密耗时
labels: [type]
- name: api_decrypt_fail_total
help: 解密失败次数
labels: [cause]
- name: api_replay_attack_total
help: 重放攻击拦截次数
这些指标通过Grafana展示,当解密失败率超过1%时会触发告警。
在实际项目中,这套方案成功抵御了多次渗透测试攻击。有个印象深刻的情况:安全团队尝试重放支付请求时,系统在3秒内就识别并阻断了攻击,同时触发了短信告警。这让我深刻认识到,好的安全方案应该是既严谨又实用的防御体系,而不是开发完成后草草加上的补丁。
