全链路加解密:在 HTTPS 之外再套一层

random-pic-api

从零开始:怎么实现一套应用层加解密

在讲实际项目之前,先把问题简化:假设你有一个前后端分离的 Web 应用,已经部署了 HTTPS,但现在要求在应用层再做一层加密。你会怎么设计?

为什么是混合加密

先想一个问题:能不能全用对称加密(比如 AES)?

可以。前端和后端约定一个固定的 AES 密钥,请求和响应都用它加解密。但问题是——密钥写在前端代码里,浏览器 Sources 面板一搜就有。密钥泄露后,所有人都能解密所有流量。而且这个密钥没法换——一换,所有前端代码都要重新构建部署。

那全用非对称加密(比如 RSA、SM2)呢?

也可以。前端用公钥加密数据,后端用私钥解密。公钥泄露无所谓,没有私钥解不开。但问题是——非对称加密很慢。RSA-2048 加密 1KB 数据的时间能做几百次 AES 加密,而且非对称加密对数据长度有限制(RSA-2048 最多加密 245 字节)。你要加密一个 JSON 请求体,可能几 KB 甚至几十 KB,根本塞不进非对称加密的明文长度上限。

所以业界标准做法是混合加密——用非对称加密保护对称密钥,用对称密钥加密业务数据:

这个流程的精妙之处在于:对称密钥 K 是前端每次会话随机生成的,不写死在代码里,不持久化到 localStorage。公钥写在前端没关系——公钥本来就是公开的,没有私钥解不开 EncryptedK。而私钥只存在于后端配置中,前端永远拿不到。

最简实现

按照这个思路,一个最简的前端实现大概长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 1. 生成 128 位随机对称密钥
function generateKey() {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}

// 2. 请求加密
function encryptRequest(config) {
const sm4Key = sessionStorage.getItem('sm4key') || generateKey();
sessionStorage.setItem('sm4key', sm4Key);

// SM2 公钥加密对称密钥,放进请求头
config.headers['X-Encrypted-Key'] = sm2Encrypt(sm4Key, PUBLIC_KEY);

// SM4 加密请求体
if (config.data) {
config.data = { encryptedPayload: sm4Encrypt(JSON.stringify(config.data), sm4Key) };
}
return config;
}

// 3. 响应解密
function decryptResponse(response) {
const sm4Key = sessionStorage.getItem('sm4key');
if (response.data) {
response.data = JSON.parse(sm4Decrypt(response.data, sm4Key));
}
return response;
}

后端对应的处理也不复杂——从 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
2
3
4
5
6
export const generateKey = (bitLength = 128) => {
const randomWordArray = CryptoJS.lib.WordArray.random(bitLength / 8);
const key = CryptoJS.enc.Hex.stringify(randomWordArray);
useUserStore().pk = key; // 存到 Pinia store,仅内存中,不持久化
return key;
};

两个关键设计决策:

  • 存在 Pinia store 而非 localStorage——页面刷新后密钥消失,会话结束。即使攻击者通过 XSS 读到了当前内存中的密钥,也只能解密当前会话的数据
  • **用 CryptoJS 的 WordArray.random 而非 Math.random**——前者使用密码学安全的随机数生成器,后者是伪随机,可以被预测

请求加密。 在 Axios 请求拦截器中,每个请求被发出前都要经过 requestEncrypt 处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export const requestEncrypt = (req) => {
if (req.skipEncrypt) return req; // 文件上传/下载等场景跳过

const pk = useUserStore().pk || generateKey();

// SM2 公钥加密对称密钥,04 前缀是国密标准非压缩格式标识
req.headers["X-Encrypted-Key"] = `04${smCrypto.sm2.doEncrypt(pk, import.meta.env.VITE_ENCRYPT_PUBLIC_KEY)}`;

// GET 请求:URL 参数序列化后加密,放入 encryptedPayload
if (req.params) {
const str = Object.keys(req.params)
.map((key) => `${key}=${window.encodeURIComponent(...)}`);
req.params = { encryptedPayload: encryptSm4(str.join("&")) };
}

// POST 请求:body 直接加密,放入 encryptedPayload
if (req.data) {
req.data = { encryptedPayload: encryptSm4(req.data) };
}
return req;
};

这里有几个值得注意的细节:

  • 04 前缀——这是国密 SM2 非压缩公钥格式的标准前缀,表示椭圆曲线上的点使用非压缩表示(04 || x || y)。加密结果以 04 开头,后端解析时需要知道这个格式
  • VITE_ENCRYPT_PUBLIC_KEY 是环境变量——编译时会被 Vite 替换为实际的 SM2 公钥字符串。这意味着公钥写死在前端构建产物中,任何人都能在浏览器 Sources 面板搜到
  • encryptedPayload 是统一的密文容器——不管是 URL 参数还是 JSON body,加密后都塞进这个字段。后端看到 encryptedPayload 就知道这是加密数据

响应解密。 在 Axios 响应拦截器中:

1
2
3
4
5
6
7
8
9
10
11
export const responseDecrypt = (response) => {
if (response.config.skipEncrypt) return response;
if (response.status === 200) {
response.data = response.data ? JSON.parse(decryptSm4(response.data)) : response.data;
} else {
response.response.data = response.response.data
? JSON.parse(decryptSm4(response.response.data))
: response.response.data;
}
return response;
};

处理了两种情况:正常响应(200)的 data 在 response.data,错误响应的 data 在 response.response.data(Axios 错误拦截器的结构不同)。

开关控制。 加解密不是一直开的——通过环境变量 VITE_ENCRYPT_ENABLED 控制,请求配置中的 skipEncrypt: true 也能跳过单个请求:

1
2
3
4
5
6
7
8
9
10
11
const isEncrypt = import.meta.env.VITE_ENCRYPT_ENABLED === "true";

request.interceptors.request.use((config) => {
// ... 添加 Token、Tenant-Id 等
return isEncrypt ? requestEncrypt(config) : config;
});

request.interceptors.response.use(
(response) => isEncrypt ? responseDecrypt(response) : response,
(err) => { err = isEncrypt ? responseDecrypt(err) : err; /* ... */ }
);

后端:网关 Filter 链

后端加解密在网关层完成,以 Spring Cloud Gateway 的 GlobalFilter 形式实现。整个处理链如下:

请求解密(RequestDecryptFilter)。 优先级设为 Ordered.HIGHEST_PRECEDENCE,确保在其他 Filter 之前执行——因为后续 Filter 和 Controller 都依赖解密后的明文。

核心流程:

  1. 检查请求路径是否在白名单中(白名单内的路径直接放行)
  2. 检查 Content-Type 是否为 application/jsonapplication/x-www-form-urlencoded(不是这两者的跳过,比如文件上传的 multipart)
  3. X-Encrypted-Key 请求头取出加密后的对称密钥,SM2 解密得到真正的 SM4 密钥
  4. encryptedPayload 参数/字段取出密文,SM4 解密
  5. DecryptedExchangeDecorator 包装请求,后续链路读取到的就是明文

响应加密(ResponseEncryptFilter)。 优先级设为 NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 1,在响应写出之前最后一环拦截:

  1. 同样检查白名单
  2. EncryptedResponseDecorator 包装响应,捕获原始响应字节
  3. 根据响应 Content-Type 判断是否加密
  4. 用请求阶段解出的 SM4 密钥加密响应体
  5. 将密文写回客户端

EncryptServiceImpl——加解密的核心。 这是密钥解析和加解密操作的中心:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 对称加密 —— 根据配置选择 SM4 或 AES
public String encrypt(String rawText, String symmetricKey) {
if (AlgorithmType.SM4.getCode().equals(algorithmType)) {
return SMUtil.SM4Encrypt(rawText, symmetricKey, codeType);
}
return AesUtil.encryptECB(rawText, symmetricKey, codeType);
}

// 对称解密
public String decrypt(String cipherText, String symmetricKey) {
if (AlgorithmType.SM4.getCode().equals(algorithmType)) {
return SMUtil.SM4Decrypt(cipherText, symmetricKey);
}
return AesUtil.decryptECB(cipherText, symmetricKey);
}

// 从请求中解析对称密钥 —— 核心逻辑
public String resolveSymmetricKey(ServerWebExchange exchange) {
// 1. 读取 X-Encrypted-Key 请求头
String encryptedKeyHeader = exchange.getRequest().getHeaders()
.getFirst("X-Encrypted-Key");

// 2. 尝试从缓存中获取(用 token 做 key)
String token = tokenExtractor.extractToken(exchange);
String cachedKey = loadCachedKey(token, encryptedKeyHeader);
if (cachedKey != null) return cachedKey;

// 3. 缓存未命中,SM2 解密 X-Encrypted-Key
String symmetricKey = SMUtil.SM2Decrypt(encryptedKeyHeader, privateKey);

// 4. 写入缓存(1 小时)
CacheUtil.setObject("encrypt:header:" + token, encryptedKeyHeader, 3600000);
CacheUtil.setObject("encrypt:key:" + token, symmetricKey, 3600000);
return symmetricKey;
}

// SM2 解密 —— 最终调用
private String decryptSymmetricKey(String encryptedKeyHeader) {
return SMUtil.SM2Decrypt(encryptedKeyHeader, keyProperties.getPrivateKey());
}

配置结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
encrypt:
request:
enable: true # 总开关
algorithm-type: SM4 # SM4 或 AES
encrypt-code: base64 # 密文编码:hex 或 base64
fallback-key: "" # 回退密钥(无 X-Encrypted-Key 时使用)
whites: # 白名单路径,跳过加解密
- /auth/oauth2/token
- /api/captcha
- /api/attachment/**

sm2:
public-key: "" # SM2 公钥(前端持有,用于加密)
private-key: "" # SM2 私钥(仅后端持有,用于解密)

algorithm-type 支持 SM4 和 AES 切换——SM4 是国密标准,AES 是国际标准。encrypt-code 控制密文的编码格式(hex 或 base64)。白名单里的路径整个加解密流程都不走——文件上传下载、图片查看、验证码获取等接口必须加进去,否则二进制数据会被当作文本加密,直接损坏。

登录密码的特殊处理

登录接口的加密方式和普通接口不同。普通接口的加密密钥是前端生成的,但登录时前端还没有 token,也没有和”后端建立会话”。所以登录密码使用 RSA 非对称加密,由独立的 PasswordDecryptFilter 处理,优先级高于全链路加解密 Filter。

流程是:

  1. 前端使用 RSA 公钥加密密码
  2. 请求到达网关,PasswordDecryptFilter 拦截(路径匹配 OAuth2 token 地址)
  3. 用 RSA 私钥解密 body
  4. 解密成功则将明文密码交给后续的认证流程

特殊请求的跳过策略

不是所有请求都需要加密。以下场景通过 skipEncrypt: true 或白名单跳过:

场景原因
文件上传 (multipart/form-data)二进制数据加密会损坏文件
文件下载 (responseType: blob)响应是二进制流,不是文本
图片/附件查看同上
验证码获取验证码组件内部有独立的 AES 加密
登录接口有独立的 RSA 加密处理

密钥缓存:设计对了,实现错了

设计意图

按设计,同一个用户会话中前端不会换对称密钥(存在 Pinia store 中,页面刷新前一直有效)。所以后端的理想行为是:每个会话只做一次 SM2 解密,后续请求直接从缓存中拿对称密钥。

缓存设计是这样的:

设计逻辑是合理的——用 token 作为缓存的维度,同用户同会话的 X-Encrypted-Key 不变,缓存就能命中。

实际 bug

但实现上有一个隐蔽的问题。看 TokenExtractor 的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// TokenExtractor —— 从 Authorization 头提取 token
public String extractToken(ServerWebExchange exchange) {
String authorization = httpHeaders.getFirst(HttpHeaders.AUTHORIZATION);
// ...
// 必须以 Bearer 开头
if (!StringUtils.startsWithIgnoreCase(authorization, "Bearer")) {
return serverHttpRequest.getQueryParams().getFirst("access_token");
}
// 正则匹配提取 token
Matcher matcher = AUTHORIZATION_PATTERN.matcher(authorization);
// ...
return matcher.group("token");
}

关键在那一行 startsWithIgnoreCase(authorization, "Bearer")——它只认 Bearer 前缀

而登录接口的 Authorization 头是这样的:

1
2
// 登录请求
headers: { Authorization: "Basic base64EncodedClientCredentials==" }

登录成功后,后续请求确实会用 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)

外部流量到网关这一跳是 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 + 请求签名可以解决绝大部分安全问题;从合规审计角度看,”你们做了加密”本身就是一个交付物。

两者并不矛盾。承认合规的现实必要性,然后在合规的框架内把方案做得更务实——签名校验防伪造、密钥轮换降风险、蜜罐监控做预警——这些增量改进不会显著增加复杂度,但能让安全水位实打实地提升。

安全不是一劳永逸的事。明天换个密钥,比相信”没人能找到我的公钥”靠谱得多。