配套源码:开源框架 ForgeAdmin(
forge-starter-crypto模块,3638 行 Java) 系列定位:企业级安全设施源码深扒。上一篇《数据权限拦截器:681 行 SQL 改写》讲了"谁能看",这篇讲"传输和落库都不怕被看"。
开头:三个场景,逼着你把加密做成框架能力
先说我踩过的三个真实场景:
场景一,抓包。 前两年做某政务系统的接口联调,甲方扔给我一个他们抓的 HTTP 包:登录请求里明文躺着账号密码,响应里明文躺着身份证号。对方只说了句"我们等保测评要过了"------剩下的话不用说了,全是我的活。
场景二,密评。 项目要求密码算法用国密(SM2/SM3/SM4),不能拿 AES 直接交差。改算法本身不难,难的是线上已经跑了两年的存量数据------几百万条旧 AES 密文躺在库里,你不能停机全表重写,得让新旧算法共存着平滑切过去。
场景三,字段泄漏。 全链路加密做完,接口是安全的了。结果一次日志排查发现,业务代码把整个实体 toString() 打进了日志------身份证、手机号、银行卡号,明文躺在日志文件里,比接口泄漏还狠。
三个场景拼起来,结论很明确:加密不该是某个接口的临时方案,它得是一套框架级能力------能按需开关、能加解密自动拦截、能落库自带版本管理、能顺手把脱敏也干了。
这篇文章把我设计的 3638 行加解密 starter(forge-starter-crypto)从外到内拆一遍,讲清楚一件事:传输加密、存储加密、脱敏,这三件事怎么在一个框架里统一掉。
一、先给全貌:一条加密请求的一生
先看整体架构,避免后面贴代码时迷路。
css
浏览器 / App / 第三方系统
│ ① GET /crypto/public-key 拿 RSA 公钥(明文,仅一次)
▼
┌───────────────────────────┐
│ KeyExchangeController │ ② 前端随机生成会话密钥(SM4/AES)
│ 密钥协商(握手) │ 用 RSA 公钥加密后 POST 回来
└───────────┬───────────────┘ 后端 RSA 私钥解密 → 存 Redis
▼
后续请求:POST /api/order {"data":"<对称密文>","algorithm":"SM4"}
▼
┌───────────────────────────────────────────────┐
│ DecryptRequestBodyAdvice(RequestBodyAdvice)│ ③ 请求体自动解密
│ 读密文 → 定算法 → 找会话密钥 → 解密 → │
│ 把明文流塞回 body,交给 Jackson 正常反序列化 │
└───────────────────────────────────────────────┘
▼
Controller 业务代码 ------ 全程无感知,拿到的是普通 DTO
▼
┌───────────────────────────────────────────────┐
│ EncryptResponseBodyAdvice(ResponseBodyAdvice)│ ④ 响应体自动加密
│ 业务返回对象 → 序列化成 JSON → 加密 → 包壳返回 │
└───────────────────────────────────────────────┘
▼
落库前:字段级 @CryptoField 序列化器加密 / @Desensitize 脱敏
▼
┌───────────────────────────────────────────────┐
│ VersionedPersistentCryptoService │ ⑤ 落库密文带版本
│ FPC1:{算法}:{keyId}:{密文} → 换钥/换算法可渐进迁移 │
└───────────────────────────────────────────────┘
横切:ReplayAttackFilter(防重放:X-Timestamp + X-Nonce)
从⑤往回看,你会发现一个有意思的设计:越靠近用户越"隐式",越靠近存储越"显式"。
- 传输层(③④):用
RequestBodyAdvice/ResponseBodyAdvice做 AOP 拦截,业务代码零感知; - 字段层:用 Jackson 注解 + 自定义序列化器,一个
@CryptoField解决问题; - 存储层(⑤):密文里显式带上算法和 keyId------因为落库的密文要活很多年,必须自己能解释自己。
下面按这个顺序拆。
二、算法层:一个接口 + 一个工厂,塞进三种算法
加密框架最底层是算法抽象。接口只有四个方法 + 一个标识:
java
public interface Encryptor {
/** 使用默认密钥加密 */
String encrypt(String plainText);
/** 使用指定密钥加密(会话密钥场景) */
String encrypt(String plainText, String key);
/** 使用默认密钥解密 */
String decrypt(String cipherText);
/** 使用指定密钥解密 */
String decrypt(String cipherText, String key);
/** 支持的算法类型 */
CryptoAlgorithm algorithm();
}
关键是所有密文都是 Base64 字符串------算法差异被抹平在接口里,上层永远在跟字符串打交道,不关心底层是块密码还是流密码。
算法枚举把三套都注册了:
java
SM4("SM4", "国密SM4对称加密"),
AES("AES", "AES对称加密"),
AES_GCM("AES_GCM", "AES-GCM认证加密");
注意 fromCode(null) 的兜底是 SM4------默认算法走国密,这是为密评场景设计的(等保/密评要求密码算法合规,默认就给你合规的)。
工厂用 ConcurrentHashMap 做注册表:
java
public class EncryptorFactory {
private final Map<CryptoAlgorithm, Encryptor> encryptorMap = new ConcurrentHashMap<>();
public void register(Encryptor encryptor) {
encryptorMap.put(encryptor.algorithm(), encryptor);
}
public Encryptor getEncryptor(String algorithm) {
CryptoAlgorithm algo = StrUtil.isNotBlank(algorithm)
? CryptoAlgorithm.fromCode(algorithm) // 按传入算法
: CryptoAlgorithm.fromCode(properties.getAlgorithm()); // 全局默认
return getEncryptor(algo);
}
// getEncryptor(CryptoAlgorithm) 未命中直接抛 IllegalArgumentException
}
设计要点:算法是运行时可选的,不是编译期写死的 。请求体里可以带 algorithm 字段、注解上可以指定 algorithm,框架根据调用方决定用哪套------这就是后面"多算法平滑迁移"的地基。
三、握手层:RSA 保护密钥,对称加密跑业务
传输加密最大的坑是密钥从哪来。你不可能把对称密钥写死在前端 JS 里(一抓包就泄露),也不能每次请求都用 RSA 加解密(性能扛不住)。
标准解法是"混合加密",代码里分三步:
第一步,前端拿 RSA 公钥。 KeyExchangeController 暴露一个明文接口返回公钥(Base64)。
第二步,前端生成会话密钥,用 RSA 公钥加密回传:
java
public class KeyExchangeService {
// 前端 POST 上来的、被 RSA 公钥加密过的会话密钥
public boolean exchangeKey(String sessionId, String encryptedKey) {
// RSA 私钥解密出真正的会话密钥
String sessionKey = rsaKeyPairHolder.decryptByPrivateKey(encryptedKey);
// 存 Redis,2 小时过期
sessionKeyStore.storeKey(sessionId, sessionKey);
return true;
}
}
第三步,会话密钥进 Redis 存储:
java
public class SessionKeyStore {
private static final String KEY_PREFIX = "crypto:session:";
// key = crypto:session:{会话ID},value = 对称密钥,TTL 2h
public void storeKey(String sessionId, String secretKey) {
String cacheKey = KEY_PREFIX + sessionId;
cacheService.set(cacheKey, secretKey, expireSeconds, TimeUnit.SECONDS);
}
public String getKey(String sessionId) {
Object value = cacheService.get(KEY_PREFIX + sessionId);
return value != null ? value.toString() : null;
}
}
之后业务请求里,对称密文用它解密。RSA 只干一次活(握手),日常流量全走对称加密------这就是性能和安全的平衡点。
会话标识从哪取?框架给了三级回退,兼容各种前端:
java
private String getSessionIdFromRequest(HttpServletRequest request) {
// 1. Authorization: Bearer <token>(最常见)
// 2. X-Session-Id 头
// 3. HTTP Session ID
}
补充一个容易漏的点:会话密钥在 Redis 有过期时间,过期后前端需要重新握手。所以前端 SDK 里要监听解密失败(服务端返回"密钥失效"类错误)后自动重走一次握手,这个降级链路不做,线上就会莫名冒出"偶发解密失败"。
四、请求/响应层:两个 Advice 撑起全自动加解密
这是框架的核心,也是最"隐式"的部分------业务代码完全无感。
4.1 请求解密:DecryptRequestBodyAdvice
java
@RestControllerAdvice
public class DecryptRequestBodyAdvice implements RequestBodyAdvice {
@Override
public boolean supports(MethodParameter methodParameter, Type targetType,
Class<? extends HttpMessageConverter<?>> converterType) {
// ① 总开关没开 → 不处理
if (!enabled || !enableApiCrypto) return false;
// ② 明文控制端点(密钥配置页)→ 不处理
if (isPlaintextControlEndpoint(request)) return false;
// ③ 可信内部调用(如流程客户端)→ 明文直达,跳过解密
if (internalCallRequestVerifier.isTrustedInternalCall(request)) return false;
// ④ 动态 API 配置优先;否则看类/方法上的 @ApiDecrypt
ApiConfigInfo apiConfig = apiConfigManager.getApiConfig(uri, method);
if (apiConfig != null) return apiConfig.getNeedEncrypt();
return AnnotatedElementUtils.hasAnnotation(cls, ApiDecrypt.class)
|| methodParameter.hasMethodAnnotation(ApiDecrypt.class);
}
}
这个 supports() 是整个框架的"路由中枢",四层判断值得细品:
- 总开关(enabled + enableApiCrypto)保证功能可整体下掉,灰度失败一键回滚;
- 明文端点白名单防止控制面(比如密钥配置接口本身)被加密逻辑套住形成死锁;
- 内部调用识别 是最容易被忽略的坑------服务间调用(比如流程引擎回调)走的是明文 JSON,如果被当成外部请求强行解密,直接 500。框架用一个
InternalCallRequestVerifier统一放行,避免每个内部接口都手动@IgnoreCrypto; - 动态配置优先于注解意味着运营可以在不改代码的情况下,把某个接口临时切到加密/明文。
真正的解密在 beforeBodyRead,注意算法三级优先级:
java
@Override
public HttpInputMessage beforeBodyRead(...) throws IOException {
// 读取请求体 → 解析成 { data, algorithm } 壳
EncryptedRequest request = objectMapper.readValue(encryptedBody, EncryptedRequest.class);
// 算法优先级:注解指定 > 请求体自带 > 全局默认
String algorithm = 注解有值 ? annotation.algorithm()
: (request.getAlgorithm() 非空 ? request.getAlgorithm()
: properties.getAlgorithm());
Encryptor encryptor = encryptorFactory.getEncryptor(algorithm);
// 会话密钥优先,拿不到就退回默认密钥
String sessionKey = getSessionKey();
String decryptedData = (sessionKey != null)
? encryptor.decrypt(request.getData(), sessionKey)
: encryptor.decrypt(request.getData());
// 关键:把解密后的明文包成新的 HttpInputMessage 还回去
return new DecryptedHttpInputMessage(
inputMessage.getHeaders(),
new ByteArrayInputStream(decryptedData.getBytes(StandardCharsets.UTF_8)));
}
DecryptedHttpInputMessage 是这个设计的精髓:RequestBodyAdvice 允许你在进入 JSON 反序列化之前偷换 body 流。解密后塞一个明文流进去,后面 Spring 的 Jackson 转换器完全不知道发生过什么------Controller 拿到的就是一个干干净净的 DTO。这就是"隐式"的实现方式。
4.2 响应加密:EncryptResponseBodyAdvice
响应侧对称,beforeBodyWrite 里把返回对象序列化成 JSON → 加密 → 包壳返回:
java
@Override
public Object beforeBodyWrite(Object body, ...) {
// 二进制响应(图片/文件下载 ResponseEntity<byte[]>)直接跳过
if (isBinaryResponseType(returnType)) return body;
String jsonBody = objectMapper.writeValueAsString(body);
String sessionKey = getSessionKey(request);
String encryptedData = (sessionKey != null)
? encryptor.encrypt(jsonBody, sessionKey)
: encryptor.encrypt(jsonBody);
return new EncryptedResponse(encryptedData, algorithm); // {data, algorithm}
}
值得说的是 isBinaryResponseType:文件下载接口返回 ResponseEntity<byte[]>,你要是把二进制当 JSON 序列化再加密,前端拿到的就是一堆乱码。所以 supports 阶段就要用泛型反射识别出 byte[] 类型参数,直接放行。
传输层的加密协议壳统一是 {data, algorithm},请求和响应共用同一套结构------前端 SDK 只写一次解密逻辑,所有接口通用。
五、字段层:@CryptoField 与 fail-closed 序列化器
传输加密解决"路上",但落库的敏感字段、打进日志的实体对象,光靠 Advice 管不到。字段级加密用 Jackson 注解解决:
java
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@JsonSerialize(using = CryptoFieldSerializer.class)
@JsonDeserialize(using = CryptoFieldDeserializer.class)
public @interface CryptoField {
/** 加密算法,默认用全局配置 */
String algorithm() default "";
/** 序列化时是否加密 */
boolean encrypt() default true;
/** 反序列化时是否解密 */
boolean decrypt() default true;
}
实体上标一下,出参自动加密、入参自动解密:
java
public class CustomerVO {
@CryptoField
private String phone; // 出参变密文,入参自动解密
@CryptoField(algorithm = "SM4")
private String idCard; // 指定国密
}
实现上有个细节很关键------失败必须拒绝输出,而不是降级成明文:
java
public class CryptoFieldSerializer extends JsonSerializer<String>
implements ContextualSerializer {
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider serializers)
throws IOException {
// ... 各种开关判断,不满足就走明文
try {
Encryptor encryptor = encryptorFactory.getEncryptor(annotation.algorithm());
gen.writeString(encryptor.encrypt(value));
} catch (Exception e) {
log.error("字段加密失败: algorithm={}", annotation.algorithm(), e);
throw new IOException("字段加密失败,拒绝输出原文", e); // fail-closed
}
}
}
ContextualSerializer 让你能在序列化时拿到字段上的注解(property.getAnnotation(CryptoField.class)),从而决定用哪个算法------同一个 ObjectMapper,不同字段走不同加密器,这是 Jackson 注解驱动序列化器的标准玩法。
fail-closed(加密失败宁可报错也不放明文) 这条必须刻在脑子里:安全组件最怕"静默降级",一旦密钥配错,线上悄悄开始吐明文,等保审计一抓一个准。
字段加密和文章开头说的"日志泄漏"是同一件事的两面------字段密文即使被
toString()打进日志,也是不可读的密文。这就是为什么我在开头第三个场景里说,字段级加密是日志泄漏的最后一道闸。
六、防重放:时间窗 + nonce 原子登记
加密只保证"看不懂",不保证"没被复制"。攻击者把抓到的合法密文原样重放一遍,服务端照样能解密成功------所以防重放必须跟加密配套。
ReplayAttackFilter 的思路是标准的 timestamp + nonce 双因子:
java
// 请求头带 X-Timestamp 和 X-Nonce
String timestamp = httpRequest.getHeader("X-Timestamp");
String nonce = httpRequest.getHeader("X-Nonce");
// 1. 时间戳必须在窗口内(默认 60s,可配)
long requestTime = Long.parseLong(timestamp);
if (Math.abs(currentTime - requestTime) > timeWindow) {
sendError(httpResponse, "请求已过期"); // 老请求直接拒
return;
}
// 2. nonce 必须没被用过 ------ 原子登记
// TTL = 2×窗口:覆盖时间戳允许的完整正负窗口,避免边界重放
long nonceTtlSeconds = replayWindowSeconds * 2L;
if (!tokenCache.markIfAbsent(nonce, nonceTtlSeconds)) {
log.warn("检测到重复请求, nonce: {}", nonce);
sendError(httpResponse, "重复的请求"); // 同一请求第二次来直接拒
return;
}
两个细节是实践里踩出来的:
- 时间戳窗口要允许正负偏差 ------
Math.abs(currentTime - requestTime) > timeWindow,不然客户端时钟慢 30 秒,合法请求全被误杀; - nonce 的 TTL 要是窗口的 2 倍------注释里写得很清楚:"缓存覆盖时间戳允许的完整正负窗口,避免边界重放"。窗口 60s,那 timestamp 合法区间是 -60s, +60s,nonce 只存 60s 的话,一个在 -59s 发出的请求,重放到 +59s 时 nonce 已过期、但时间戳还在窗口内------就漏了。存 120s 才能把整个合法区间盖住。
配套的白名单机制:WebSocket 长连接(path.startsWith("/ws"))、内部调用、控制端点都不做防重放------WebSocket 本来就是长连,每帧都带 nonce 会直接把服务端 Redis 打爆。
七、存储层:密文版本化------换算法不用全表重写(全文最值钱的一段)
这是整个 starter 里我最想讲的设计,也是回答开头"密评改造怎么处理存量数据"的答案。
问题:假设线上跑着 200 万条 AES 密文,现在密评要求换成 SM4。怎么办?
- 停机全表重写?业务不允许。
- 双写 + 定时任务迁移?你得先能区分"哪条是 AES、哪条是 SM4",否则解密时不知道该用哪个密钥哪个算法。
解法是密文自描述(self-describing)------密文里带上算法和 keyId,让每一段密文都能解释自己:
java
public record PersistentCiphertext(
Format format, // LEGACY / ACTIVE / HISTORICAL / UNKNOWN_KEY / UNKNOWN
String algorithm, // SM4 / AES / AES_GCM
String keyId, // 用哪把 key 加密的
String payload, // 密文本体
String failureReason
) {
public static final String VERSION = "FPC1";
}
版本化密文的存储格式是:
css
FPC1:{算法}:{keyId}:{密文Base64}
inspect() 是核心分类器,每次读库都先跑一遍:
java
public static PersistentCiphertext inspect(String value,
String legacyAlgorithm,
String activeKeyId,
Set<String> readableKeyIds) {
// 没有 FPC1: 前缀 → 旧格式密文,用 legacyAlgorithm(默认 SM4)解
if (!value.startsWith(VERSION + ":")) {
return new PersistentCiphertext(Format.LEGACY, legacyAlgorithm, null, value, null);
}
// 解析四段
String[] parts = value.split(":", 4);
String algorithm = parts[1]; // 这段密文用啥算法
String keyId = parts[2]; // 这段密文用哪把 key
String payload = parts[3];
if (keyId.equals(activeKeyId)) {
return ...(Format.ACTIVE, ...); // 当前激活 key → 正常解
}
if (readableKeyIds.contains(keyId)) {
return ...(Format.HISTORICAL, ...); // 历史 key 还能读 → 可解,提示重加密
}
return ...(Format.UNKNOWN_KEY, ...); // key 已销毁 → 明确报"读不了"
}
解密服务按分类走不同路径:
java
public String decrypt(String ciphertext, String legacyAlgorithm) {
PersistentCiphertext parsed = inspect(ciphertext, legacyAlgorithm);
return switch (parsed.format()) {
case ACTIVE, HISTORICAL -> decryptVersioned(parsed); // 用密文自带的算法+keyId 解
case LEGACY -> decryptLegacy(parsed); // 走旧 key 解
default -> throw new IllegalArgumentException("无法解密的密文: " + parsed.failureReason());
};
}
轮换(rotation)和迁移(migration)被彻底解耦了:
- 换算法/换 key,老数据不用动------因为它带着自己的算法和 keyId,新代码照样能解;
- 想要渐进迁移,写个定时任务读一批 →
decrypt老密文 → 用新算法encrypt→ 覆盖写回,随时可停可续,不用停机窗口; - 甚至能精确盘点:
CryptoMigrationReport统计出库里还有多少条 LEGACY、多少条已迁移------迁移进度是可视化的,不是黑盒。
encrypt 侧还支持"读时重加密"模式(writeVersioned=true 时每次写都用当前激活 key),保证新数据永远用最新配置。
这个思路对任何"线上有存量密文"的系统都成立:密文必须自描述,算法和 keyId 跟着密文走,而不是跟着代码走。代码可以随时升级,密文要活很多年。
八、脱敏:一套注解,八种策略
最后补一块跟加密配套但不属于加密的:脱敏。加密是"能还原",脱敏是"不可逆",场景不同------手机号展示给客服看,你不需要给她真实号码,脱敏就行。
java
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = DesensitizeSerializer.class)
public @interface Desensitize {
DesensitizeType type() default DesensitizeType.CUSTOM; // PHONE/ID_CARD/EMAIL/...
int prefixKeep() default 0; // 自定义脱敏:前缀保留长度
int suffixKeep() default 0; // 后缀保留长度
char replaceChar() default '*';
}
策略工厂在构造时一次性注册 8 种内置策略:手机号、身份证、邮箱、银行卡、姓名、地址、密码、车牌,外加 CUSTOM 自由组合 prefixKeep/suffixKeep/replaceChar。同一套 Jackson 注解体系,字段上标 @Desensitize(type = PHONE) 出参自动变 138****1234。
九、踩坑清单(都是线上踩出来的)
- 会话密钥过期没做自动重协商:Redis 里的会话密钥 2 小时过期,前端不知道,还在拿旧密钥加密 → 偶发解密失败。前端 SDK 必须监听失败自动重走握手。
- 二进制响应被当 JSON 加密 :文件下载
ResponseEntity<byte[]>没做泛型识别就进加密链路,前端收到乱码。supports 阶段用 ParameterizedType 反射跳过 byte\[\]。 - 防重放 nonce TTL 只设了窗口长度:60s 窗口配 60s TTL,边界重放漏网(-59s 发出的请求 +59s 重放)。TTL 必须 2×窗口。
- 内部服务调用被强行解密 :服务间回调是明文 JSON,被全局 Advice 拦下后 500。必须有
InternalCallRequestVerifier白名单机制。 - 加密失败静默降级成明文:密钥配错时安全组件悄悄吐原文,等保审计一抓一个准。序列化器必须 fail-closed(失败抛异常拒绝输出)。
- 控制面接口被加密逻辑套死:密钥配置接口本身走了加密 → 死锁(没法改配置了)。控制端点必须进明文白名单。
十、能带走的三条设计诀窍
| 层 | 诀窍 |
|---|---|
| 传输层 | Advice 拦截 + {data, algorithm} 统一壳协议,业务零感知、前端 SDK 只写一遍 |
| 算法层 | Encryptor 接口 + 工厂注册表 + 运行时可选算法,多算法共存是平滑迁移的前提 |
| 存储层 | 密文自描述:算法和 keyId 跟密文走,不跟代码走------换钥/换算法不用全表重写 |
总结
回头看开头三个场景:抓包问题被 Advice 全链路加密解决;密评改造的存量数据被密文版本化解决(新数据默认 SM4、旧 AES 密文自描述可解、渐进重加密);日志泄漏被字段级加密 + 脱敏双重兜底。
安全能力的正确形态是"框架级 + 可开关":注解一标、配置一开,全站生效;出问题时总开关一关,立刻回到明文模式------而不是散落在每个业务接口里的临时方案。
下一篇想拆 forge-starter-idempotent:下单接口被连点 10 次会发生什么------SpEL 动态 key + 三种幂等策略(严格模式/Token 预申请/结果缓存)+ Redisson 分布式锁,支付场景必踩的坑,源码 1279 行。想看的评论区扣个 1。
文章涉及的源码模块(全部开源):
| 模块 | 作用 |
|---|---|
forge-starter-crypto |
本文主角:加解密/防重放/密文迁移/脱敏 |
forge-starter-core |
注解定义:@ApiEncrypt / @ApiDecrypt / CryptoProperties |
项目地址:github.com/yaomindong1... 在线演示:www.dlforgelab.com:8084/forge/login... / 123456) 标签:#Spring Boot #加密 #安全 #国密 #架构设计 #Java