全链路加解密:在 HTTPS 之外再套一层
全链路加解密:在 HTTPS 之外再套一层
dong4j从零开始:怎么实现一套应用层加解密
在讲实际项目之前,先把问题简化:假设你有一个前后端分离的 Web 应用,已经部署了 HTTPS,但现在要求在应用层再做一层加密。你会怎么设计?
为什么是混合加密
先想一个问题:能不能全用对称加密(比如 AES)?
可以。前端和后端约定一个固定的 AES 密钥,请求和响应都用它加解密。但问题是——密钥写在前端代码里,浏览器 Sources 面板一搜就有。密钥泄露后,所有人都能解密所有流量。而且这个密钥没法换——一换,所有前端代码都要重新构建部署。
那全用非对称加密(比如 RSA、SM2)呢?
也可以。前端用公钥加密数据,后端用私钥解密。公钥泄露无所谓,没有私钥解不开。但问题是——非对称加密很慢。RSA-2048 加密 1KB 数据的时间能做几百次 AES 加密,而且非对称加密对数据长度有限制(RSA-2048 最多加密 245 字节)。你要加密一个 JSON 请求体,可能几 KB 甚至几十 KB,根本塞不进非对称加密的明文长度上限。
所以业界标准做法是混合加密——用非对称加密保护对称密钥,用对称密钥加密业务数据:
sequenceDiagram
participant Browser as 浏览器
participant Server as 服务端
Note over Browser: 1. 生成随机对称密钥 K
Browser->>Browser: K = generateRandomKey(128bit)
Note over Browser: 2. 用公钥加密 K
Browser->>Browser: EncryptedK = SM2_Encrypt(K, PublicKey)
Note over Browser: 3. 用 K 加密请求数据
Browser->>Browser: EncryptedBody = SM4_Encrypt(requestBody, K)
Note over Browser: 4. 发送:加密的密钥放在请求头,加密的数据放在请求体
Browser->>Server: POST /api/xxx<br/>Header: X-Encrypted-Key = EncryptedK<br/>Body: { "encryptedPayload": EncryptedBody }
Note over Server: 5. 用私钥解密出 K
Server->>Server: K = SM2_Decrypt(EncryptedK, PrivateKey)
Note over Server: 6. 用 K 解密请求体
Server->>Server: plainBody = SM4_Decrypt(EncryptedBody, K)
Note over Server: 7. 处理业务,用同一个 K 加密响应
Server->>Server: EncryptedResponse = SM4_Encrypt(responseBody, K)
Server->>Browser: Response: EncryptedResponse
Note over Browser: 8. 用 K 解密响应
Browser->>Browser: plainResponse = SM4_Decrypt(EncryptedResponse, K)
这个流程的精妙之处在于:对称密钥 K 是前端每次会话随机生成的,不写死在代码里,不持久化到 localStorage。公钥写在前端没关系——公钥本来就是公开的,没有私钥解不开 EncryptedK。而私钥只存在于后端配置中,前端永远拿不到。
最简实现
按照这个思路,一个最简的前端实现大概长这样:
1 | // 1. 生成 128 位随机对称密钥 |
后端对应的处理也不复杂——从 X-Encrypted-Key 头取出加密的对称密钥,SM2 解密得到 K,再用 K 去 SM4 解密 encryptedPayload 字段。在网关层做这件事,后续的 Controller 拿到的就是明文,完全无感知。
但这个”最简实现”有几个明显的问题。密钥存在 sessionStorage 里,关了浏览器就没了——这意味着每次打开页面都要重新生成。而对称密钥一变,后端不认识新密钥,就解不开请求。所以后端必须每次从 X-Encrypted-Key 头里重新解密出对称密钥,每次请求都要做一次 SM2 非对称解密——这就带来了性能问题。
要解决这个问题,就需要密钥缓存——后端解密一次后,把这个对称密钥存起来,下次同一个用户再来请求时直接用。这就引出了我们实际项目中的完整实现。
实际项目中的实现
技术选型:为什么是 SM2/SM4
这个项目是政企方向的,客户明确要求使用国密算法。所以技术选型上没有纠结——SM2 做非对称加密(等效 RSA-2048,256 位),SM4 做对称加密(等效 AES-128,128 位)。前端国密库用的是 sm-crypto,随机数生成用 crypto-js。
另外还有两个小场景用了 AES:
- 验证码坐标加密——因为验证码组件是第三方的(AJ-Captcha),内部约定就是 AES
- 配置回退——当
algorithm-type不是 SM4 时,降级到 AES-ECB
前端:密钥生成与请求拦截器
前端核心代码在 encrypt.js 中。完整流程分三部分:密钥生成、请求加密、响应解密。
密钥生成。 每次会话开始时,前端生成一个 128 位的随机十六进制字符串作为 SM4 对称密钥:
1 | export const generateKey = (bitLength = 128) => { |
两个关键设计决策:
- 存在 Pinia store 而非 localStorage——页面刷新后密钥消失,会话结束。即使攻击者通过 XSS 读到了当前内存中的密钥,也只能解密当前会话的数据
- **用 CryptoJS 的
WordArray.random而非Math.random**——前者使用密码学安全的随机数生成器,后者是伪随机,可以被预测
请求加密。 在 Axios 请求拦截器中,每个请求被发出前都要经过 requestEncrypt 处理:
1 | export const requestEncrypt = (req) => { |
这里有几个值得注意的细节:
04前缀——这是国密 SM2 非压缩公钥格式的标准前缀,表示椭圆曲线上的点使用非压缩表示(04 || x || y)。加密结果以04开头,后端解析时需要知道这个格式VITE_ENCRYPT_PUBLIC_KEY是环境变量——编译时会被 Vite 替换为实际的 SM2 公钥字符串。这意味着公钥写死在前端构建产物中,任何人都能在浏览器 Sources 面板搜到encryptedPayload是统一的密文容器——不管是 URL 参数还是 JSON body,加密后都塞进这个字段。后端看到encryptedPayload就知道这是加密数据
响应解密。 在 Axios 响应拦截器中:
1 | export const responseDecrypt = (response) => { |
处理了两种情况:正常响应(200)的 data 在 response.data,错误响应的 data 在 response.response.data(Axios 错误拦截器的结构不同)。
开关控制。 加解密不是一直开的——通过环境变量 VITE_ENCRYPT_ENABLED 控制,请求配置中的 skipEncrypt: true 也能跳过单个请求:
1 | const isEncrypt = import.meta.env.VITE_ENCRYPT_ENABLED === "true"; |
后端:网关 Filter 链
后端加解密在网关层完成,以 Spring Cloud Gateway 的 GlobalFilter 形式实现。整个处理链如下:
graph TB
subgraph Gateway["Spring Cloud Gateway"]
direction TB
Client["客户端请求"] --> DecryptFilter["RequestDecryptFilter<br/>Order: HIGHEST_PRECEDENCE"]
DecryptFilter --> CheckWhitelist{"路径在白名单?"}
CheckWhitelist -->|是| SkipDecrypt["跳过加解密"]
CheckWhitelist -->|否| ExtractKey["从 X-Encrypted-Key 头<br/>提取加密的对称密钥"]
ExtractKey --> ResolveKey["EncryptServiceImpl<br/>SM2 解密得到 SM4 密钥"]
ResolveKey --> DecryptParams["解密 URL 参数<br/>encryptedPayload → 明文参数"]
DecryptParams --> DecryptBody["解密请求体<br/>encryptedPayload → 明文 JSON"]
DecryptBody --> Route["路由到下游服务"]
SkipDecrypt --> Route
Route --> Controller["业务 Controller<br/>(拿到的是明文)"]
Controller --> EncryptFilter["ResponseEncryptFilter<br/>Order: WRITE_RESPONSE - 1"]
EncryptFilter --> CheckWhitelist2{"路径在白名单?"}
CheckWhitelist2 -->|是| SkipEncrypt["原样返回"]
CheckWhitelist2 -->|否| EncryptResp["SM4 加密响应体"]
EncryptResp --> Return["返回密文给客户端"]
SkipEncrypt --> Return
end
style DecryptFilter fill:#4A90D9,color:#fff
style EncryptFilter fill:#4A90D9,color:#fff
style ResolveKey fill:#E8A838,color:#fff
style Controller fill:#5CB85C,color:#fff
请求解密(RequestDecryptFilter)。 优先级设为 Ordered.HIGHEST_PRECEDENCE,确保在其他 Filter 之前执行——因为后续 Filter 和 Controller 都依赖解密后的明文。
核心流程:
- 检查请求路径是否在白名单中(白名单内的路径直接放行)
- 检查 Content-Type 是否为
application/json或application/x-www-form-urlencoded(不是这两者的跳过,比如文件上传的 multipart) - 从
X-Encrypted-Key请求头取出加密后的对称密钥,SM2 解密得到真正的 SM4 密钥 - 从
encryptedPayload参数/字段取出密文,SM4 解密 - 用
DecryptedExchangeDecorator包装请求,后续链路读取到的就是明文
响应加密(ResponseEncryptFilter)。 优先级设为 NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 1,在响应写出之前最后一环拦截:
- 同样检查白名单
- 用
EncryptedResponseDecorator包装响应,捕获原始响应字节 - 根据响应 Content-Type 判断是否加密
- 用请求阶段解出的 SM4 密钥加密响应体
- 将密文写回客户端
EncryptServiceImpl——加解密的核心。 这是密钥解析和加解密操作的中心:
1 | // 对称加密 —— 根据配置选择 SM4 或 AES |
配置结构
1 | encrypt: |
algorithm-type 支持 SM4 和 AES 切换——SM4 是国密标准,AES 是国际标准。encrypt-code 控制密文的编码格式(hex 或 base64)。白名单里的路径整个加解密流程都不走——文件上传下载、图片查看、验证码获取等接口必须加进去,否则二进制数据会被当作文本加密,直接损坏。
登录密码的特殊处理
登录接口的加密方式和普通接口不同。普通接口的加密密钥是前端生成的,但登录时前端还没有 token,也没有和”后端建立会话”。所以登录密码使用 RSA 非对称加密,由独立的 PasswordDecryptFilter 处理,优先级高于全链路加解密 Filter。
流程是:
- 前端使用 RSA 公钥加密密码
- 请求到达网关,
PasswordDecryptFilter拦截(路径匹配 OAuth2 token 地址) - 用 RSA 私钥解密 body
- 解密成功则将明文密码交给后续的认证流程
sequenceDiagram
participant Browser as 浏览器
participant Gateway as 网关
participant Auth as 认证服务
Note over Browser: 输入用户名密码
Browser->>Browser: RSA 公钥加密密码
Browser->>Gateway: POST /auth/oauth2/token<br/>Authorization: Basic xxx<br/>Body: RSA_Encrypted(password)
Gateway->>Gateway: PasswordDecryptFilter 拦截
Gateway->>Gateway: RSA 私钥解密 → 明文密码
Gateway->>Auth: 转发认证请求(明文)
Auth->>Gateway: access_token + refresh_token
Gateway->>Browser: Token 响应
Note over Browser: 存储 token,后续请求走全链路加密
特殊请求的跳过策略
不是所有请求都需要加密。以下场景通过 skipEncrypt: true 或白名单跳过:
| 场景 | 原因 |
|---|---|
文件上传 (multipart/form-data) | 二进制数据加密会损坏文件 |
文件下载 (responseType: blob) | 响应是二进制流,不是文本 |
| 图片/附件查看 | 同上 |
| 验证码获取 | 验证码组件内部有独立的 AES 加密 |
| 登录接口 | 有独立的 RSA 加密处理 |
密钥缓存:设计对了,实现错了
设计意图
按设计,同一个用户会话中前端不会换对称密钥(存在 Pinia store 中,页面刷新前一直有效)。所以后端的理想行为是:每个会话只做一次 SM2 解密,后续请求直接从缓存中拿对称密钥。
缓存设计是这样的:
sequenceDiagram
participant F as 前端
participant G as 网关
participant R as Redis
Note over F: 首次请求:生成 SM4 密钥 K1
F->>G: X-Encrypted-Key: SM2_Encrypt(K1)<br/>Authorization: Bearer <token>
G->>G: 提取 token = "xxx"
G->>R: GET encrypt:key:xxx
R->>G: null(缓存未命中)
G->>G: SM2_Decrypt(X-Encrypted-Key) → K1
G->>R: SET encrypt:key:xxx = K1 (1h)
G->>G: 用 K1 解密请求 ✓
Note over F: 第二次请求:密钥仍是 K1
F->>G: X-Encrypted-Key: SM2_Encrypt(K1)(相同)<br/>Authorization: Bearer <token>
G->>G: 提取 token = "xxx"
G->>R: GET encrypt:key:xxx, GET encrypt:header:xxx
R->>G: K1 + 上次的 X-Encrypted-Key
G->>G: header 相同 → 缓存命中!直接用 K1 ✓
设计逻辑是合理的——用 token 作为缓存的维度,同用户同会话的 X-Encrypted-Key 不变,缓存就能命中。
实际 bug
但实现上有一个隐蔽的问题。看 TokenExtractor 的代码:
1 | // TokenExtractor —— 从 Authorization 头提取 token |
关键在那一行 startsWithIgnoreCase(authorization, "Bearer")——它只认 Bearer 前缀。
而登录接口的 Authorization 头是这样的:
1 | // 登录请求 |
登录成功后,后续请求确实会用 Bearer <access_token>,TokenExtractor 也就能正常提取 token。但问题出在登录请求本身也启用了加密的情况下——前端为登录请求生成了 SM4 密钥、SM2 加密后放入 X-Encrypted-Key,但后端从 Basic 认证头中提取不到 token,loadCachedKey(null, encryptedKeyHeader) 永远返回 null。
结果就是:
- 缓存永远不命中(token 为 null 时直接跳过缓存查询)
- 每个请求都做一次 SM2 非对称解密(CPU 开销比缓存命中高几个数量级)
- 每次都写缓存,key 里带 null——Redis 里攒了一堆
encrypt:key:null的脏数据
这个 bug 的修复也不复杂——要么让 TokenExtractor 兼容 Basic 和 Bearer 两种格式,要么根据实际情况用其他维度(如 sessionId、客户端 IP + UserAgent 哈希)做缓存 key。
为什么有了 HTTPS 还要加一层
上面花了大量篇幅讲”怎么实现”。但回到一个更根本的问题:HTTPS 已经提供了传输层加密,为什么还要在应用层再做一套?
这个问题要从三个维度来看。
一、技术维度:TLS 卸载带来的内网明文问题
很多企业网络架构中有这样一个环节——SSL 卸载(TLS Termination):
graph LR
Browser["浏览器"] -->|"HTTPS (TLS 加密)"| LB["负载均衡 / 网关<br/>(SSL 卸载)"]
LB -->|"HTTP (明文)"| Service1["内网服务 A"]
LB -->|"HTTP (明文)"| Service2["内网服务 B"]
LB -->|"HTTP (明文)"| Service3["内网服务 C"]
style LB fill:#E8A838,color:#fff
style Browser fill:#4A90D9,color:#fff
外部流量到网关这一跳是 HTTPS 加密的,但网关到内部服务是明文的 HTTP。这么做的原因很现实——内网服务数量多,如果每个服务都配 TLS 证书,证书管理和性能开销都不小。而内网被认为是”安全的”,所以网关解密后直接明文转发。
但”内网安全”这个假设在攻防演练中经常被打脸。内网的抓包工具、日志采集 agent、网络监控设备,理论上都能看到明文数据。如果一个内网机器被拿下,攻击者就可以在内网监听所有明文流量。
在应用层再做一层加密,即使内网流量被监听,攻击者拿到的也是 encryptedPayload 密文。
二、合规维度:审计要的不是”安全”,是”加密”这个动作
这是最现实的原因。政企项目的安全审计中,”数据传输加密”往往是一个硬性条款。审计人员不一定理解 TLS 的工作原理,但他们认”你们做了加密”这个事实。
说白了,安全审计要的不是技术上的”安全”,而是合规上的”已加密”这个状态。 你可以跟他们解释 HTTPS 已经加了密,但他们需要看到的是——应用系统层面有加密机制、有密钥管理、有算法选型。TLS 是基础设施层面的,不在应用系统审计的范围内。
这就跟”为什么有了防火墙还要在代码里做输入校验”一个道理——分层防护,各管各的。
三、安全边界:这层加密到底能防什么
诚实地说,这套方案的防护边界很窄。
它能防的:
- 内网被动窃听。 TLS 卸载后的内网明文传输、日志系统意外采集的请求体、网络监控工具的流量抓包——这些场景下,攻击者拿到的是密文
- 合规审计。 如前所述,这是它存在的主要意义
- 日志脱敏的被动保护。 网关 access log 如果不小心记录了请求体,记录的是
encryptedPayload密文而不是明文手机号/身份证号,算是多一层兜底
它不能防的:
- 前端伪造。 SM2 公钥写在前端代码里,加密逻辑完全公开。攻击者可以用相同的逻辑构造任意请求——公钥加密一个新的 SM4 密钥,SM4 加密伪造的请求体,后端照单全收
- 中间人劫持。 如果 TLS 被攻破,攻击者可以篡改前端 JS、替换公钥、注入恶意代码。应用层加密依赖前端的完整性,前端的完整性又依赖 TLS——这是一个循环依赖。TLS 一旦破了,整条链都断了
- 重放攻击。 加密后的请求可以被原样重放。没有时间戳校验、没有 nonce、没有签名,同一个加密请求可以被无限重放
总结下来:这套方案防护的是”被动窃听”,对”主动攻击”基本无效。 它的安全模型建立在 TLS 正常工作、只是在内网段多一层防护的前提上。
改进方向
如果要让这套方案在合规之外提供真正的安全增量,有几个方向值得考虑:
1. 签名优于加密。 在加密的基础上加一层签名机制——前端用 SM2 私钥(或 HMAC)对 timestamp + nonce + body_hash 做签名,后端验证。这能防御伪造和重放,因为攻击者没有签名密钥,无法生成有效签名。
2. 密钥通过 CI/CD 注入而非写死。 当前 SM2 公钥在前端环境变量中,编译后就是字符串常量。如果改成构建时通过 CI/CD 注入,每天定时构建一次自动轮换密钥,前端代码中的公钥只有 24 小时有效窗口——攻击者即使找到公钥,第二天也失效了。
3. 蜜罐检测。 在前端添加一两个”无关紧要”的请求头,正常用户永远不会携带。如果后端监控到有请求携带了这些蜜罐请求头的异常值,说明有人在用脚本分析协议、构造请求——触发告警,拉黑 IP。
4. 修缓存。 把 TokenExtractor 的提取逻辑修好,让 SM2 解密只在会话初次请求时执行一次。
5. 区分场景。 核心敏感接口(登录、支付、个人信息查询)走完整加解密+签名,普通列表查询可以只用签名不做加密,文件上传下载跳过所有处理。安全是有成本的,把成本花在刀刃上。
写在最后
应用层全链路加密是一个典型的”技术 vs 合规”交叉案例。从纯技术角度看,HTTPS + 请求签名可以解决绝大部分安全问题;从合规审计角度看,”你们做了加密”本身就是一个交付物。
两者并不矛盾。承认合规的现实必要性,然后在合规的框架内把方案做得更务实——签名校验防伪造、密钥轮换降风险、蜜罐监控做预警——这些增量改进不会显著增加复杂度,但能让安全水位实打实地提升。
安全不是一劳永逸的事。明天换个密钥,比相信”没人能找到我的公钥”靠谱得多。

















