从 AES-GCM、信封加密、盲索引到密钥轮转的企业级落地实践
数据库开启了 TDE,不代表 DBA 看不到明文;字段加了 AES,也不代表系统就具备密钥治理能力;代码里用了 GCM,更不代表实现一定安全。
真正可落地的字段级加密,必须同时解决威胁模型、算法误用、密钥分层、密文格式、查询索引、权限隔离、历史迁移、轮转回滚、容器注入、审计监控与故障降级等问题。
目录
-
一、从一次“已经加密”的数据泄漏说起
-
二、先建立正确认知:字段加密到底防什么
-
三、算法选型:生产环境为什么优先使用 AEAD
-
四、GCM 最容易被忽略的安全边界
-
五、企业级密钥体系:KEK、DEK、BIK 必须分离
-
六、三种信封加密粒度,应该如何选择
-
七、推荐架构:把加密能力变成受控安全边界
-
八、密文格式设计:不要只存一段 Base64
-
九、数据库表结构与字段容量如何设计
-
十、生产级 Java AES-GCM 实现
-
十一、盲索引:加密手机号如何支持等值查询
-
十二、MyBatis 集成:透明加密并不总是最佳答案
-
十三、密钥轮转:不是替换配置然后重启服务
-
十四、历史明文数据如何平滑迁移
-
十五、Kubernetes 与 Vault/KMS 集成
-
十六、高并发性能优化与容量评估
-
十七、异常处理、可用性与故障降级
-
十八、日志、脱敏、审计与权限隔离
-
十九、测试体系:不仅要验证能解密
-
二十、常见错误清单
-
二十一、分阶段演进路线
-
二十二、上线检查清单
-
二十三、总结
-
参考资料
-
一、从一次“已经加密”的数据泄漏说起
某电商平台的订单系统保存了手机号、收货地址、证件号码等个人信息。为了满足安全审计要求,团队完成了三项改造:
- 云磁盘开启加密。
- MySQL 开启透明数据加密 TDE。
- 数据库备份文件开启服务端加密。
从基础设施视角看,数据似乎已经被保护得很完整。
但一次线上排障中,具有只读权限的运维人员直接执行:
SELECT order_no, receiver_phone, receiver_address
FROM trade_order
WHERE order_no = '202608030001';
查询结果仍然是完整明文。
与此同时,应用为了排查接口超时,临时增加了下面这行日志:
log.info("create order request={}", request);
由于请求对象由 Lombok 自动生成 toString(),手机号、证件号和详细地址全部进入日志平台。最终,数据库查询权限与日志查询权限共同形成了敏感数据暴露面。
问题的根源在于:
磁盘加密、TDE、备份加密主要保护存储介质、文件和备份副本,并不会天然阻止拥有数据库查询权限的主体读取列明文。
如果目标是降低数据库账号泄漏、备份泄漏、只读 DBA 越权、数据导出和离线分析带来的风险,就需要让数据在进入数据库前已经变成应用层密文。
但“应用层字段加密”也不是万能药。只要业务应用被完全攻陷,攻击者仍可能在应用解密后读取内存中的明文。因此,字段加密必须建立在明确的威胁模型之上,而不是被包装成“加密后绝对安全”。
二、先建立正确认知:字段加密到底防什么
2.1 四类数据保护手段不是同一件事
| 能力 |
主要保护对象 |
能否阻止普通 SQL 查询看到明文 |
是否可恢复原文 |
典型用途 |
| 磁盘/文件系统加密 |
磁盘、快照、物理介质 |
否 |
是 |
防磁盘或快照被盗 |
| 数据库 TDE |
表空间、数据文件、日志文件、备份 |
否 |
是 |
防数据库文件离线泄漏 |
| 应用层字段加密 |
指定业务字段 |
是 |
是 |
手机号、证件号、银行卡号等 |
| 脱敏 |
页面、接口、日志展示 |
不适用 |
通常不改变底层原文 |
138****1234 |
| 哈希 |
不需要恢复的秘密 |
是 |
否 |
密码、不可逆比对 |
| Tokenization |
原值与令牌映射 |
是 |
由令牌服务控制 |
支付卡、跨系统敏感标识 |
密码不应通过可逆 AES 加密保存,而应使用专门的密码哈希算法。字段加密适用于业务必须恢复原文的数据。
2.2 推荐建立威胁矩阵
| 威胁场景 |
TDE |
字段加密 |
仍需补充的控制 |
| 云盘快照泄漏 |
有效 |
有效 |
密钥与快照隔离 |
| MySQL 物理备份泄漏 |
有效 |
有效 |
备份访问审计 |
| 只读数据库账号泄漏 |
无效 |
有效 |
最小权限、审计 |
| DBA 越权查询 |
无效 |
有效 |
解密权限隔离 |
| 应用日志打印明文 |
无效 |
无效 |
日志脱敏、禁止对象直出 |
| 应用进程被完全控制 |
无效 |
保护有限 |
运行时隔离、令牌化、最小解密权限 |
| KMS 管理员越权 |
不确定 |
保护有限 |
双人审批、HSM、职责分离 |
| 密文被篡改 |
不一定 |
AEAD 可检测 |
完整性校验、失败告警 |
2.3 字段分类决定方案复杂度
建议至少按以下等级分类:
- L0 公开数据:商品名称、公开活动信息,不加密。
- L1 内部数据:内部备注、运营标签,依赖访问控制与审计。
- L2 敏感数据:手机号、邮箱、地址,字段加密加动态脱敏。
- L3 高敏感数据:证件号、银行卡号、医疗信息,字段加密、独立密钥域、严格解密审批。
- L4 凭证类秘密:密码、令牌、私钥,不应按普通业务字段处理,使用密码哈希或专用 Secrets Manager。
不要为了“统一”而加密所有字段。过度加密会破坏索引、排序、统计、排障和数据分析能力,并显著增加密钥治理成本。
三、算法选型:生产环境为什么优先使用 AEAD
3.1 不只需要保密,还需要防篡改
传统加密只回答“别人能不能看懂”。生产系统还必须回答:
- 密文是否被修改过?
- 密文是否被复制到了错误字段?
- 密文是否来自错误租户或错误业务域?
- 元数据是否被替换?
因此,字段加密应优先选择 AEAD,Authenticated Encryption with Associated Data,即带关联数据的认证加密。
常见选择:
- AES-256-GCM:生态成熟,主流 CPU 通常具备硬件加速,适合服务端字段加密。
- ChaCha20-Poly1305:在缺乏 AES 硬件加速的设备上可能更有优势。
- Google Tink AEAD:提供更难误用的高层 API,适合不希望自行维护密码学封装的团队。
本文的 Java 示例使用 AES/GCM/NoPadding。
3.2 为什么不推荐 ECB
ECB 的核心问题是:同一密钥下,相同明文块会产生相同密文块,从而泄漏数据模式。
手机号 A -> 固定密文 X
手机号 A -> 固定密文 X
手机号 B -> 固定密文 Y
即使攻击者不知道明文,也能分析重复率、关联关系和数据分布。
3.3 CBC 不是绝对不能用,但不应作为新系统默认方案
CBC 只提供机密性,不天然提供完整性。安全使用通常还需要正确组合独立 MAC,并严格遵守 Encrypt-then-MAC。组合错误、异常差异和填充处理不当都可能引入攻击面。
新系统没有兼容性负担时,直接使用成熟 AEAD 更合理。
3.4 AES-128 与 AES-256 如何选择
AES-128 和 AES-256 在正确实现下都具备很高安全强度。企业字段加密常选择 AES-256,主要是为了统一安全基线、合规表达和长期治理,而不是因为 AES-128 已经“不安全”。
真正更常见的风险不是密钥位数不够,而是:
-
GCM nonce 重复。
-
密钥硬编码。
-
同一个密钥跨环境、跨租户、跨用途复用。
-
解密失败后回退明文。
-
密钥轮转不可执行。
-
日志或监控暴露原文。
-
四、GCM 最容易被忽略的安全边界
4.1 96 位 nonce 的核心要求是唯一性
AES-GCM 常使用 12 字节,即 96 位 nonce。它不要求保密,但要求在同一密钥下不得重复。
使用高质量随机数生成 96 位 nonce 是常见方案,但“随机”不等于数学意义上的绝对唯一。随着同一密钥加密次数增加,碰撞概率会累积。因此应同时控制:
- 单个 DEK 的最大加密量。
- DEK 使用周期。
- 密钥轮转频率。
- 高并发实例的随机数实现。
- 是否存在虚拟机快照恢复导致随机状态回退的风险。
对于极高调用量或严格安全场景,可使用“实例唯一前缀加单调计数器”等结构化 nonce 方案,但其持久化、并发和故障恢复设计必须非常严谨。一般业务系统更适合使用成熟密码库并实施合理的密钥分片和轮转。
4.2 不要每次调用 SecureRandom.getInstanceStrong()
getInstanceStrong() 可能选择阻塞或成本较高的随机源。字段加密通常只需要复用一个线程安全的 SecureRandom 实例:
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
然后为每次加密生成新的 12 字节 nonce。
4.3 使用 128 位认证标签
GCM 的认证标签用于检测密文或关联数据被篡改。字段加密没有强烈的带宽压缩需求,通常直接使用 128 位标签:
private static final int GCM_TAG_BITS = 128;
不要为了节省几个字节随意截短标签。
4.4 AAD 用来绑定业务上下文
AAD 会参与认证但不会被加密,可用于阻止“密文搬运攻击”。例如将 user_profile.phone 字段的密文复制到 bank_account.card_no 字段,即使两个字段使用了相同 DEK,也应因为 AAD 不匹配而解密失败。
推荐的稳定 AAD:
应用标识 | 数据域 | 表名 | 字段名 | 密文格式版本
例如:
member-service|user_profile|phone|format-v1
可以加入租户 ID,但必须确保解密时始终能够得到完全相同的租户值。不要把会修改的昵称、状态、时间戳等动态字段放入 AAD,否则业务更新后旧密文将无法解密。
对于主键绑定,应谨慎处理:插入前主键可能尚未生成,数据迁移或跨库复制也会更复杂。
五、企业级密钥体系:KEK、DEK、BIK 必须分离
5.1 三类密钥的职责
KEK:Key Encryption Key
KEK 用来加密或封装 DEK。它应由 Vault、云 KMS 或 HSM 托管,并尽量不离开受控安全边界。
DEK:Data Encryption Key
DEK 直接用于 AES-GCM 字段加解密。应用可在授权后短期获得明文 DEK,并缓存在进程内存中。
BIK:Blind Index Key
BIK 用于计算可查询盲索引:
blind_index = HMAC-SHA-256(BIK, normalized_value)
BIK 必须与 DEK 分离,原因包括:
- 加密和索引是不同用途,应进行密钥用途隔离。
- DEK 轮转不应迫使所有查询索引同步变化。
- 泄漏盲索引密钥不应直接获得解密能力。
- 不同密钥可应用不同轮转频率和权限策略。
5.2 严禁出现的密钥存储方式
crypto:
aes-key: 9v0mR6... # 错误:明文放在 application.yml
private static final String AES_KEY = "0123456789abcdef"; // 错误
Nacos 配置中心保存 Base64(AES_KEY) // Base64 不是加密
即使配置中心开启权限控制,也不应把长期明文业务密钥当作普通配置分发。
5.3 密钥元数据可以存什么
配置中心或数据库可以保存:
- KMS 地址与命名空间。
- KEK 标识或资源名称。
- 当前 DEK 版本号。
- 密文格式版本。
- 轮转策略。
- 允许的算法列表。
- 缓存 TTL 和刷新周期。
不应保存:
-
明文 DEK。
-
明文 BIK。
-
可直接换取长期密钥的永久 Token。
-
未经二次保护的 KMS 根凭证。
-
六、三种信封加密粒度,应该如何选择
信封加密不是只有一种实现。
6.1 每条记录一个 DEK
业务记录
├── ciphertext
├── wrapped_dek
├── kek_id
└── crypto_metadata
优点:
- 单条 DEK 泄漏的影响范围小。
- 便于按记录执行加密删除或权限隔离。
- 不需要定期重新加密所有业务数据来轮转 DEK。
缺点:
- 每条记录都需要保存 wrapped DEK。
- 解密缓存命中率较低。
- 写入链路和存储开销更大。
适合极高敏感数据、小对象加密和强隔离场景。
6.2 每租户或每数据域一个 DEK
tenant-1001 + phone-domain -> DEK v7
tenant-1001 + identity-domain -> DEK v3
tenant-1002 + phone-domain -> DEK v2
优点:
- 隔离粒度与性能较平衡。
- 本地缓存命中率高。
- 单个租户或数据域可独立轮转。
缺点:
- 某个 DEK 泄漏会影响一个租户或一个数据域。
- 密钥数量随租户数增长。
适合 SaaS、多租户中台和大型微服务体系。
6.3 每应用或每业务域一个 DEK
优点:实现简单、性能高、缓存容易。
缺点:影响半径最大,跨租户隔离较弱,历史重加密成本高。
适合中低敏感字段、早期系统或作为第一阶段演进方案。
6.4 选型建议
| 场景 |
推荐粒度 |
| 普通内部敏感字段 |
每应用或每业务域 DEK |
| 多租户手机号、证件号 |
每租户加每数据域 DEK |
| 支付卡、医疗核心数据 |
每记录 DEK 或 Tokenization |
| 超高 QPS 且可接受域级影响 |
每业务域 DEK + 本地缓存 |
| 低 QPS、强调密钥不出 Vault |
Vault Transit 远程加解密 |
不要机械地认为“每条数据一个 DEK 一定最好”。安全隔离、吞吐、KMS 配额、延迟、存储和运维复杂度必须一起评估。
七、推荐架构:把加密能力变成受控安全边界
7.1 总体架构
7.2 各组件职责
Sensitive Data Policy
负责判断:
- 当前调用者是否允许解密。
- 当前场景返回完整明文还是掩码。
- 是否需要记录解密原因。
- 哪些字段必须使用独立密钥域。
Field Crypto SDK
负责:
- AEAD 加解密。
- 密文封装与解析。
- AAD 生成。
- 盲索引计算。
- 兼容不同密文格式。
- 统一异常分类。
Key Provider
负责:
- 获取当前写密钥版本。
- 按历史版本加载解密密钥。
- 缓存明文 DEK/BIK。
- KMS 失败时执行明确的失败策略。
- 处理密钥轮转通知。
KMS Gateway
屏蔽 Vault、AWS KMS、Google Cloud KMS、Azure Key Vault 或企业 HSM 差异。
Rotation Controller
负责创建新版本、切换写版本、触发重加密、验证旧版本残留并最终退役旧密钥。
7.3 不推荐把解密能力下放给所有服务
更安全的模式是:
- 订单列表服务只拿到脱敏手机号。
- 短信服务只获得一次性发送能力,不一定获得永久明文读取能力。
- 客服系统必须在授权、工单和审计上下文中才能查看完整号码。
- 数据分析平台只使用脱敏值或不可逆标识。
字段加密的价值,不只是让数据库变成密文,更重要的是缩小“谁能够解密”的范围。
八、密文格式设计:不要只存一段 Base64
8.1 为什么必须版本化
算法、密钥版本、编码方式和 AAD 规则未来都可能变化。如果数据库只保存:
Base64(nonce + ciphertext + tag)
几年后很难判断:
- 使用了哪个密钥版本。
- 使用的是 AES-GCM 还是其他算法。
- AAD 规则是什么。
- 是否属于历史兼容格式。
8.2 推荐逻辑格式
magic | format_version | algorithm | key_version | nonce | ciphertext_with_tag
可序列化为:
enc:v1:aes256gcm:k42:<base64url(nonce|ciphertext|tag)>
其中:
enc:标识为受支持密文。
v1:密文格式版本。
aes256gcm:算法标识。
k42:DEK 版本。
- 最后一段:nonce、密文与认证标签。
8.3 元数据也应参与认证
format_version、algorithm、key_version 等元数据应作为 AAD 的一部分参与认证,避免攻击者随意修改密文头部。
8.4 Base64 还是 VARBINARY
Base64 会带来约三分之一的体积膨胀。数据库内部存储优先考虑:
VARBINARY(512)
只有在 JSON、消息或配置等文本协议中传递时,再使用 Base64URL 编码。
九、数据库表结构与字段容量如何设计
以用户手机号为例:
CREATE TABLE user_sensitive_profile (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
phone_cipher VARBINARY(512) NULL COMMENT '手机号密文封装',
phone_blind_index BINARY(32) NULL COMMENT 'HMAC-SHA-256 盲索引',
phone_masked VARCHAR(32) NULL COMMENT '可选,受控脱敏展示值',
crypto_format_version TINYINT UNSIGNED NULL,
crypto_key_version INT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
KEY idx_tenant_phone_bidx (tenant_id, phone_blind_index),
KEY idx_crypto_key_version (crypto_key_version)
) ENGINE = InnoDB;
9.1 是否需要单独保存 crypto_key_version
如果密文头部已经包含版本号,额外列不是解密必需,但有以下运维价值:
- 快速统计各版本数据量。
- 迁移任务无需解析整段密文即可筛选旧版本。
- 监控轮转进度。
- 发现异常写入版本。
代价是需要保证列值和密文头一致。可由 SDK 统一生成,禁止业务代码分别赋值。
9.2 不要低估密文长度
AES-GCM 密文长度大致为:
明文字节数 + 12 字节 nonce + 16 字节 tag + 格式头
如果使用 Base64,还要乘以约 4/3。
中文地址按 UTF-8 编码后,字节数可能远大于 Java 字符数。设计字段容量时应按最大字节数计算,而不是只看明文字符长度。
9.3 脱敏列不是绝对无风险
phone_masked、last4、证件后四位等字段仍可能与其他信息组合识别个人,应纳入数据分类、访问控制和审计范围。
十、生产级 Java AES-GCM 实现
下面示例基于 Java 17+,重点展示:
- 12 字节 nonce。
- 128 位认证标签。
- AAD 绑定。
- 密钥版本化。
- 严格格式校验。
- 篡改异常分类。
- 不进行明文回退。
10.1 数据模型
package com.example.crypto;
import javax.crypto.SecretKey;
import java.util.Objects;
public record DataKey(int version, SecretKey secretKey) {
public DataKey {
if (version <= 0) {
throw new IllegalArgumentException("key version must be positive");
}
Objects.requireNonNull(secretKey, "secretKey");
if (!"AES".equalsIgnoreCase(secretKey.getAlgorithm())) {
throw new IllegalArgumentException("only AES key is supported");
}
}
}
package com.example.crypto;
public interface DataKeyProvider {
/** 当前仅用于新数据写入的密钥。 */
DataKey currentEncryptionKey();
/** 按版本获取历史解密密钥。 */
DataKey keyByVersion(int version);
}
10.2 稳定的业务上下文
package com.example.crypto;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
public record CryptoContext(
String application,
String table,
String column
) {
public CryptoContext {
requireSafe(application, "application");
requireSafe(table, "table");
requireSafe(column, "column");
}
public byte[] aad(int formatVersion, int keyVersion, String algorithm) {
String value = String.join("|",
application,
table,
column,
"fmt=" + formatVersion,
"key=" + keyVersion,
"alg=" + algorithm
);
return value.getBytes(StandardCharsets.UTF_8);
}
private static void requireSafe(String value, String name) {
Objects.requireNonNull(value, name);
if (value.isBlank() || value.indexOf('|') >= 0) {
throw new IllegalArgumentException(name + " is invalid");
}
}
}
10.3 密文封装
package com.example.crypto;
import java.util.Base64;
import java.util.Objects;
public record CipherEnvelope(
int formatVersion,
String algorithm,
int keyVersion,
byte[] nonce,
byte[] ciphertextWithTag
) {
private static final String PREFIX = "enc";
private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder DECODER = Base64.getUrlDecoder();
public CipherEnvelope {
if (formatVersion <= 0) {
throw new IllegalArgumentException("formatVersion must be positive");
}
Objects.requireNonNull(algorithm, "algorithm");
if (keyVersion <= 0) {
throw new IllegalArgumentException("keyVersion must be positive");
}
Objects.requireNonNull(nonce, "nonce");
Objects.requireNonNull(ciphertextWithTag, "ciphertextWithTag");
nonce = nonce.clone();
ciphertextWithTag = ciphertextWithTag.clone();
}
@Override
public byte[] nonce() {
return nonce.clone();
}
@Override
public byte[] ciphertextWithTag() {
return ciphertextWithTag.clone();
}
public String serialize() {
return String.join(":",
PREFIX,
"v" + formatVersion,
algorithm,
"k" + keyVersion,
ENCODER.encodeToString(nonce) + "." + ENCODER.encodeToString(ciphertextWithTag)
);
}
public static CipherEnvelope parse(String value) {
if (value == null || value.isBlank()) {
throw new CryptoFormatException("ciphertext is blank");
}
String[] parts = value.split(":", -1);
if (parts.length != 5 || !PREFIX.equals(parts[0])) {
throw new CryptoFormatException("unsupported ciphertext format");
}
try {
int formatVersion = parsePositive(parts[1], 'v');
String algorithm = parts[2];
int keyVersion = parsePositive(parts[3], 'k');
String[] payload = parts[4].split("\\.", -1);
if (payload.length != 2) {
throw new CryptoFormatException("invalid payload structure");
}
byte[] nonce = DECODER.decode(payload[0]);
byte[] ciphertext = DECODER.decode(payload[1]);
return new CipherEnvelope(
formatVersion,
algorithm,
keyVersion,
nonce,
ciphertext
);
} catch (CryptoFormatException ex) {
throw ex;
} catch (IllegalArgumentException ex) {
throw new CryptoFormatException("invalid ciphertext encoding", ex);
}
}
private static int parsePositive(String value, char prefix) {
if (value.length() < 2 || value.charAt(0) != prefix) {
throw new CryptoFormatException("invalid version field");
}
int parsed = Integer.parseInt(value.substring(1));
if (parsed <= 0) {
throw new CryptoFormatException("version must be positive");
}
return parsed;
}
}
10.4 异常定义
package com.example.crypto;
public class CryptoException extends RuntimeException {
public CryptoException(String message) {
super(message);
}
public CryptoException(String message, Throwable cause) {
super(message, cause);
}
}
package com.example.crypto;
public final class CryptoFormatException extends CryptoException {
public CryptoFormatException(String message) {
super(message);
}
public CryptoFormatException(String message, Throwable cause) {
super(message, cause);
}
}
package com.example.crypto;
public final class CryptoIntegrityException extends CryptoException {
public CryptoIntegrityException(String message, Throwable cause) {
super(message, cause);
}
}
package com.example.crypto;
public final class CryptoKeyUnavailableException extends CryptoException {
public CryptoKeyUnavailableException(String message, Throwable cause) {
super(message, cause);
}
}
10.5 AES-GCM 核心服务
package com.example.crypto;
import javax.crypto.AEADBadTagException;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Objects;
public final class AesGcmFieldCrypto {
private static final String JCA_TRANSFORMATION = "AES/GCM/NoPadding";
private static final String ALGORITHM_ID = "aes256gcm";
private static final int FORMAT_VERSION = 1;
private static final int NONCE_LENGTH_BYTES = 12;
private static final int TAG_LENGTH_BITS = 128;
private static final int AES_256_KEY_BYTES = 32;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final DataKeyProvider keyProvider;
public AesGcmFieldCrypto(DataKeyProvider keyProvider) {
this.keyProvider = Objects.requireNonNull(keyProvider, "keyProvider");
}
public String encrypt(String plaintext, CryptoContext context) {
if (plaintext == null) {
return null;
}
Objects.requireNonNull(context, "context");
DataKey dataKey = keyProvider.currentEncryptionKey();
validateAes256Key(dataKey.secretKey());
byte[] nonce = new byte[NONCE_LENGTH_BYTES];
SECURE_RANDOM.nextBytes(nonce);
byte[] aad = context.aad(FORMAT_VERSION, dataKey.version(), ALGORITHM_ID);
byte[] plaintextBytes = plaintext.getBytes(StandardCharsets.UTF_8);
try {
Cipher cipher = Cipher.getInstance(JCA_TRANSFORMATION);
cipher.init(
Cipher.ENCRYPT_MODE,
dataKey.secretKey(),
new GCMParameterSpec(TAG_LENGTH_BITS, nonce)
);
cipher.updateAAD(aad);
byte[] ciphertextWithTag = cipher.doFinal(plaintextBytes);
return new CipherEnvelope(
FORMAT_VERSION,
ALGORITHM_ID,
dataKey.version(),
nonce,
ciphertextWithTag
).serialize();
} catch (GeneralSecurityException ex) {
throw new CryptoException("field encryption failed", ex);
} finally {
java.util.Arrays.fill(plaintextBytes, (byte) 0);
}
}
public String decrypt(String serializedCiphertext, CryptoContext context) {
if (serializedCiphertext == null) {
return null;
}
Objects.requireNonNull(context, "context");
CipherEnvelope envelope = CipherEnvelope.parse(serializedCiphertext);
validateEnvelope(envelope);
final DataKey dataKey;
try {
dataKey = keyProvider.keyByVersion(envelope.keyVersion());
} catch (RuntimeException ex) {
throw new CryptoKeyUnavailableException(
"data key is unavailable, version=" + envelope.keyVersion(),
ex
);
}
validateAes256Key(dataKey.secretKey());
byte[] aad = context.aad(
envelope.formatVersion(),
envelope.keyVersion(),
envelope.algorithm()
);
try {
Cipher cipher = Cipher.getInstance(JCA_TRANSFORMATION);
cipher.init(
Cipher.DECRYPT_MODE,
dataKey.secretKey(),
new GCMParameterSpec(TAG_LENGTH_BITS, envelope.nonce())
);
cipher.updateAAD(aad);
byte[] plaintext = cipher.doFinal(envelope.ciphertextWithTag());
try {
return new String(plaintext, StandardCharsets.UTF_8);
} finally {
java.util.Arrays.fill(plaintext, (byte) 0);
}
} catch (AEADBadTagException ex) {
throw new CryptoIntegrityException(
"ciphertext authentication failed",
ex
);
} catch (GeneralSecurityException ex) {
throw new CryptoException("field decryption failed", ex);
}
}
private static void validateEnvelope(CipherEnvelope envelope) {
if (envelope.formatVersion() != FORMAT_VERSION) {
throw new CryptoFormatException(
"unsupported format version=" + envelope.formatVersion()
);
}
if (!ALGORITHM_ID.equals(envelope.algorithm())) {
throw new CryptoFormatException(
"unsupported algorithm=" + envelope.algorithm()
);
}
if (envelope.nonce().length != NONCE_LENGTH_BYTES) {
throw new CryptoFormatException("invalid GCM nonce length");
}
if (envelope.ciphertextWithTag().length < 16) {
throw new CryptoFormatException("ciphertext is too short");
}
}
private static void validateAes256Key(SecretKey secretKey) {
byte[] encoded = secretKey.getEncoded();
try {
if (encoded == null || encoded.length != AES_256_KEY_BYTES) {
throw new CryptoException("AES-256 key is required");
}
} finally {
if (encoded != null) {
java.util.Arrays.fill(encoded, (byte) 0);
}
}
}
}
10.6 关于内存清零的现实边界
Java 中对临时 byte[] 执行清零是合理的最佳努力,但不能保证 JVM、JIT、GC、字符串常量池或对象复制过程中不存在其他副本。
因此还需要:
十一、盲索引:加密手机号如何支持等值查询
11.1 为什么不能直接查询随机密文
AES-GCM 每次使用不同 nonce,同一手机号会得到不同密文:
13800138000 -> ciphertext A
13800138000 -> ciphertext B
因此下面的查询无法工作:
WHERE phone_cipher = encrypt('13800138000')
这正是随机化加密隐藏重复模式的结果。
11.2 正确方案:独立 HMAC 盲索引
phone_blind_index = HMAC-SHA-256(BIK, domain || normalized_phone)
查询时:
- 规范化输入。
- 使用相同 BIK 计算盲索引。
- 按
tenant_id + phone_blind_index 查询。
- 读取候选记录后再解密并做最终一致性校验。
11.3 不要给等值盲索引加入随机盐
随机盐会导致同一输入得到不同结果,从而无法直接使用数据库等值索引。
盲索引的核心保护来自:
- 服务端秘密 BIK,也可理解为 pepper。
- 输入规范化。
- 业务域隔离。
- 租户隔离。
- 访问控制和查询限流。
若每条记录使用随机盐,则查询时必须预先知道每条记录的盐,无法解决“先通过手机号定位记录”的问题。
11.4 输入规范化非常关键
手机号可能出现:
13800138000
+86 138 0013 8000
0086-138-0013-8000
如果规范化规则不一致,同一手机号会生成多个盲索引。
建议:
- 手机号统一为 E.164 或业务认可的标准格式。
- 邮箱域名部分统一小写,是否处理本地部分大小写需结合业务规则。
- 证件号统一去空格、统一字符集和大小写。
- 规范化规则必须版本化。
11.5 Java 实现
package com.example.crypto;
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.util.Objects;
public final class BlindIndexService {
private static final String HMAC_ALGORITHM = "HmacSHA256";
private static final byte SEPARATOR = 0x00;
private final SecretKey blindIndexKey;
public BlindIndexService(SecretKey blindIndexKey) {
this.blindIndexKey = Objects.requireNonNull(blindIndexKey, "blindIndexKey");
}
public byte[] phoneIndex(long tenantId, String normalizedPhone) {
Objects.requireNonNull(normalizedPhone, "normalizedPhone");
byte[] domain = "phone-bidx-v1".getBytes(StandardCharsets.UTF_8);
byte[] tenant = Long.toUnsignedString(tenantId).getBytes(StandardCharsets.UTF_8);
byte[] value = normalizedPhone.getBytes(StandardCharsets.UTF_8);
ByteBuffer input = ByteBuffer.allocate(
domain.length + 1 + tenant.length + 1 + value.length
);
input.put(domain)
.put(SEPARATOR)
.put(tenant)
.put(SEPARATOR)
.put(value);
try {
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
mac.init(blindIndexKey);
return mac.doFinal(input.array());
} catch (GeneralSecurityException ex) {
throw new CryptoException("blind index calculation failed", ex);
} finally {
java.util.Arrays.fill(value, (byte) 0);
java.util.Arrays.fill(input.array(), (byte) 0);
}
}
}
11.6 低熵字段仍可能被枚举
手机号、邮编、国家代码等字段空间有限。即使数据库只有 HMAC 结果,只要 BIK 泄漏,攻击者仍可能离线枚举。
因此必须:
- 将 BIK 放在 KMS 或受控密钥系统中。
- 限制盲索引查询接口的速率。
- 不允许任意批量探测。
- 对查询行为进行审计和异常检测。
- 按租户或数据域拆分 BIK,降低影响半径。
11.7 范围查询、模糊查询怎么办
普通盲索引只适合等值查询。
| 查询需求 |
建议方案 |
| 手机号精确查询 |
HMAC 盲索引 |
| 身份证精确查询 |
HMAC 盲索引 |
| 手机号后四位 |
单独受控派生列,接受额外泄漏面 |
| 姓名模糊查询 |
业务侧候选集、受控搜索服务或 Tokenization |
| 金额范围查询 |
不对金额主体使用普通字段加密,按数据分类重构 |
| 日期范围查询 |
保留低敏感检索维度或按桶派生 |
| 任意密文范围查询 |
不建议直接采用 OPE 作为默认方案 |
保序加密、可搜索加密和确定性加密会泄漏不同程度的数据关系,应由安全团队针对具体威胁模型评估,不能只因为“方便建索引”就直接上线。
十二、MyBatis 集成:透明加密并不总是最佳答案
12.1 原始做法的问题
很多方案通过 TypeHandler<String> 全局注册加密处理器。这存在严重风险:
- 可能误加密所有字符串字段。
- TypeHandler 不一定能获取表名、列名、租户 ID 等完整上下文。
- 所有查询自动解密,可能绕过业务层解密授权。
- Mapper 返回对象后,明文可能被日志、缓存、序列化器继续传播。
- 历史数据、空值、密文版本和迁移逻辑难以精细控制。
MyBatis 的 TypeHandler 本质是 JDBC 参数与 Java 类型之间的转换机制,不应被误认为完整的数据安全策略层。
12.2 三种集成模式
模式 A:显式仓储加解密,安全性最高
数据库 DO 只保存密文:
public class UserSensitiveDO {
private Long id;
private Long tenantId;
private String phoneCipher;
private byte[] phoneBlindIndex;
private Integer cryptoKeyVersion;
}
业务仓储在权限检查后显式解密:
public final class UserSensitiveRepository {
private static final CryptoContext PHONE_CONTEXT =
new CryptoContext("member-service", "user_sensitive_profile", "phone");
private final UserSensitiveMapper mapper;
private final AesGcmFieldCrypto fieldCrypto;
private final BlindIndexService blindIndexService;
public UserSensitiveRepository(
UserSensitiveMapper mapper,
AesGcmFieldCrypto fieldCrypto,
BlindIndexService blindIndexService
) {
this.mapper = mapper;
this.fieldCrypto = fieldCrypto;
this.blindIndexService = blindIndexService;
}
public void savePhone(long userId, long tenantId, String normalizedPhone) {
String cipher = fieldCrypto.encrypt(normalizedPhone, PHONE_CONTEXT);
byte[] index = blindIndexService.phoneIndex(tenantId, normalizedPhone);
mapper.updatePhone(userId, cipher, index);
}
public String loadPhoneAfterAuthorization(long userId) {
String cipher = mapper.selectPhoneCipher(userId);
return fieldCrypto.decrypt(cipher, PHONE_CONTEXT);
}
}
优点是加解密边界、授权和审计都清晰。高敏感字段推荐此模式。
模式 B:字段专用 TypeHandler
为每个稳定上下文字段定义专用 TypeHandler,而不是注册全局 String 加密处理器。
@MappedJdbcTypes(JdbcType.VARCHAR)
public final class PhoneCipherTypeHandler extends BaseTypeHandler<String> {
private static final CryptoContext CONTEXT =
new CryptoContext("member-service", "user_sensitive_profile", "phone");
private final AesGcmFieldCrypto crypto;
public PhoneCipherTypeHandler(AesGcmFieldCrypto crypto) {
this.crypto = crypto;
}
@Override
public void setNonNullParameter(
PreparedStatement ps,
int i,
String parameter,
JdbcType jdbcType
) throws SQLException {
try {
ps.setString(i, crypto.encrypt(parameter, CONTEXT));
} catch (RuntimeException ex) {
throw new SQLException("phone encryption failed", ex);
}
}
@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
return decrypt(rs.getString(columnName));
}
@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return decrypt(rs.getString(columnIndex));
}
@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return decrypt(cs.getString(columnIndex));
}
private String decrypt(String value) throws SQLException {
if (value == null) {
return null;
}
try {
return crypto.decrypt(value, CONTEXT);
} catch (RuntimeException ex) {
throw new SQLException("phone decryption failed", ex);
}
}
}
XML 中显式指定:
<resultMap id="userSensitiveResultMap"
type="com.example.user.UserSensitiveEntity">
<id column="id" property="id"/>
<result column="phone_cipher"
property="phone"
typeHandler="com.example.crypto.PhoneCipherTypeHandler"/>
</resultMap>
但要注意构造器依赖注入与 MyBatis TypeHandler 实例注册方式。应由 Spring 显式创建实例并注册,避免在 Handler 内使用静态 ApplicationContext。
模式 C:自定义注解加拦截器
注解与拦截器可以减少样板代码,但实现复杂度最高:
- 必须准确解析参数对象、批量参数、Map、嵌套对象和返回类型。
- 必须防止重复加密。
- 必须处理分页、缓存、异步和事务重试。
- 必须确保注解不会绕过授权与审计。
没有完善测试和框架治理能力时,不建议自研“全自动透明加密框架”。
12.3 推荐结论
-
高敏感字段:显式仓储加解密。
-
稳定、低复杂度字段:字段专用 TypeHandler。
-
大量多语言服务:统一 SDK,必要时引入独立数据安全服务。
-
不要全局注册 String 加密 Handler。
-
十三、密钥轮转:不是替换配置然后重启服务
13.1 正确轮转状态机
完整步骤:
- 在 KMS 创建 DEK v2 或轮转 KEK 版本。
- 所有实例获得读取 v1、v2 的能力。
- 健康检查确认 v2 可加载。
- 将当前写版本从 v1 切换为 v2。
- 新写入只使用 v2,旧数据继续由 v1 解密。
- 观察加密失败率、未知版本数、KMS 延迟和缓存命中率。
- 后台批量将 v1 密文重加密为 v2。
- 验证数据库、缓存、消息、搜索索引和离线副本中的旧版本残留。
- 先禁用旧版本写入,再根据保留策略退役旧版本。
- 只有在确认没有恢复、审计和历史回放需求后,才考虑销毁旧密钥。
13.2 KEK 轮转与 DEK 轮转不是一回事
仅轮转 KEK
如果每条数据保存 wrapped DEK,可以使用新 KEK 对 wrapped DEK 重新封装。业务数据密文不需要解密再加密。
轮转 DEK
业务密文必须解密后使用新 DEK 重新加密。成本更高,但可缩短单个 DEK 的生命周期和影响范围。
13.3 不要在轮转时直接清空所有历史密钥缓存
如果新版本发布后立即删除旧版本缓存,而 KMS 又短暂不可用,历史数据将无法解密。
更合理的策略:
- 当前写密钥主动刷新。
- 历史解密密钥按版本懒加载。
- 常用历史版本保留较长缓存。
- 轮转事件只刷新当前版本元数据,不盲目清空全部缓存。
- 对未知版本和 KMS 失败分别监控。
13.4 轮转事件必须幂等
public record KeyRotationEvent(
String keyDomain,
int previousVersion,
int currentVersion,
long eventVersion
) {}
消费者应满足:
十四、历史明文数据如何平滑迁移
14.1 不要停机执行一次性全表更新
千万级或亿级数据直接执行:
UPDATE user_profile
SET phone_cipher = ...;
会带来:
- 大事务。
- Undo/Redo 暴涨。
- 主从延迟。
- 锁等待。
- Binlog 膨胀。
- 回滚困难。
14.2 推荐六阶段迁移
阶段 1:新增密文字段
ALTER TABLE user_profile
ADD COLUMN phone_cipher VARBINARY(512) NULL,
ADD COLUMN phone_blind_index BINARY(32) NULL,
ADD COLUMN crypto_key_version INT UNSIGNED NULL;
保留原明文字段,暂不切换读取。
阶段 2:双写
新请求同时写入:
双写期应尽量短,因为明文仍会进入 Binlog、CDC 和下游。
阶段 3:双读兼容
读取优先级:
存在合法密文 -> 解密读取
不存在密文 -> 读取旧明文并触发补偿迁移
注意:“解密失败”绝不能回退读取旧明文,否则密文篡改、错误密钥和格式损坏会被静默掩盖。
只有明确的“密文字段为空”才允许读取旧字段。
阶段 4:后台回填
使用主键游标分页,不使用大 OFFSET:
SELECT id, tenant_id, phone_plain
FROM user_profile
WHERE id > :lastId
AND phone_cipher IS NULL
ORDER BY id
LIMIT 500;
更新时增加条件,保证幂等:
UPDATE user_profile
SET phone_cipher = :cipher,
phone_blind_index = :blindIndex,
crypto_key_version = :keyVersion
WHERE id = :id
AND phone_cipher IS NULL;
建议控制:
- 每批 100 到 1000 行,根据行大小和数据库压力压测。
- 批次之间动态限速。
- 监控主从延迟、锁等待、Redo、CPU、连接池和失败率。
- 失败记录进入可重试任务表,不打印原文。
阶段 5:切换读写
- 停止写旧明文字段。
- 所有读取只允许使用密文。
- 验证不存在空密文和异常版本。
阶段 6:清理明文
UPDATE user_profile
SET phone_plain = NULL
WHERE phone_plain IS NOT NULL
AND phone_cipher IS NOT NULL;
完成验证后再删除列。
同时处理:
- 旧备份。
- Binlog。
- CDC Topic。
- Elasticsearch 索引。
- 数据湖和离线表。
- 日志与导出文件。
- 测试环境复制数据。
只删除在线表的明文字段,并不意味着明文已经从企业数据链路中消失。
十五、Kubernetes 与 Vault/KMS 集成
15.1 正确认识 Kubernetes Secret
Kubernetes Secret 的值是 Base64 编码,不等于加密。Secret 默认可能以未加密形式存储在 etcd 中,但集群可以配置 API 数据静态加密,也可以使用 KMS Provider。
因此不应简单地说“Kubernetes Secret 永远明文存储”,更准确的判断是:
- Base64 本身不提供保密性。
- 是否静态加密取决于集群配置。
- 即使 etcd 已加密,能够读取 Secret 的 Pod、ServiceAccount 或管理员仍可能获取明文。
15.2 推荐身份链路
15.3 不要注入长期根 Token
推荐:
- Kubernetes ServiceAccount 加 Workload Identity。
- Vault Kubernetes Auth。
- Vault Agent 自动续租短期 Token。
- Secrets Store CSI Driver 挂载短期凭证或材料。
- 云厂商 IAM Role for Service Account。
避免:
- 在 Deployment YAML 中写永久 Token。
- 把 Vault Root Token 放入 Secret。
- 多个服务共用同一个高权限身份。
- 应用拥有创建、轮转、删除所有密钥的权限。
15.4 最小权限示例
订单查询服务通常只需要:
- 读取指定数据域的 wrapped DEK。
- 调用指定 KEK 的 unwrap/decrypt。
- 不允许创建、轮转和删除 KEK。
轮转控制器可以拥有:
- 创建新版本。
- 切换当前版本。
- 触发 rewrap。
但不一定需要读取业务数据库中的明文。
15.5 Vault Transit 两种使用方式
方式一:每条数据远程调用 Transit
优点:密钥不进入业务进程,权限和审计集中。
缺点:每次加解密增加网络调用、Vault 吞吐和可用性依赖。
适合低 QPS、高敏感场景。
方式二:Transit 只封装 DEK
业务数据由应用本地 DEK 加密,Vault 只负责:
- 生成或封装 DEK。
- 解封 DEK。
- 轮转 KEK。
- 重新封装 wrapped DEK。
这是高 QPS 系统更常见的平衡方案。
十六、高并发性能优化与容量评估
16.1 最大的性能问题通常不是 AES
字段很短时,性能瓶颈更可能来自:
- 每条数据都远程请求 KMS。
- Base64 编解码与大量对象分配。
- 重复字符串规范化。
- ORM 反射和拦截器扫描。
- 连接池、数据库写放大。
- 解密后对象在多层 DTO 中复制。
16.2 不要未经压测就池化 Cipher
Cipher 是有状态且非线程安全对象,不能跨线程并发复用。
但这不代表必须立即引入复杂对象池。对于短字段:
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
是否成为瓶颈取决于 JDK、Provider、CPU、字段大小和调用模式。建议先使用 JMH 测量:
Cipher.getInstance 成本。
init 成本。
- 32B、128B、1KB 明文吞吐。
- 单线程和多线程吞吐。
- GC 分配率。
- 开启与关闭 Base64 的差异。
只有数据证明对象创建是热点后,再评估 ThreadLocal、对象池或 Tink。
16.3 Key Cache 设计
缓存键至少包括:
key_domain + tenant_id + key_version
缓存策略建议:
- 当前写密钥短周期主动刷新。
- 历史解密密钥长周期缓存。
- 设置最大数量,避免租户数过多撑爆堆。
- 防止缓存击穿,使用 single-flight 加载。
- KMS 失败实施短时间负缓存,但不要把短暂故障缓存过久。
- 记录命中率、加载耗时、失败原因和版本分布。
16.4 不建议缓存业务明文
可以缓存 DEK,但尽量不要把完整手机号、证件号等明文放入通用 Redis 或本地业务缓存。
确有需要时:
- 缓存内容继续使用字段密文。
- 设置短 TTL。
- 独立缓存命名空间。
- 禁止明文日志和管理后台查看。
- 明确缓存清除与密钥轮转策略。
16.5 批量接口
对于批量查询:
- 先按 key version 分组。
- 每个版本只加载一次 DEK。
- 批量执行本地解密。
- 不要每行触发一次 KMS 请求。
如果使用 Vault Transit,可评估其批量输入能力,但仍需进行真实网络与服务端容量测试。
16.6 容量指标
上线前至少测量:
- 单字段加密 P50、P95、P99。
- 单字段解密 P50、P95、P99。
- KMS unwrap P95、P99。
- Key Cache 命中率。
- 每次请求加解密字段数量。
- 单实例最大加密 QPS。
- CPU 与 GC 增量。
- 密文字段行大小增量。
- Binlog 与网络带宽增量。
- 历史回填速度与主从延迟。
不要引用与自身硬件、JDK、Provider 和字段大小无关的“1GB/s”作为生产容量结论。
十七、异常处理、可用性与故障降级
17.1 必须区分四类失败
| 异常 |
含义 |
推荐处理 |
CryptoFormatException |
密文格式未知或损坏 |
拒绝处理,告警,进入数据修复流程 |
CryptoIntegrityException |
Tag 校验失败、AAD 不匹配或密文被篡改 |
高优先级安全告警,不回退明文 |
CryptoKeyUnavailableException |
KMS、缓存或版本元数据不可用 |
依据读写策略返回可重试错误 |
CryptoException |
Provider 或代码执行异常 |
失败关闭,保留错误分类 |
17.2 加密失败时禁止写明文
错误做法:
try {
entity.setPhoneCipher(crypto.encrypt(phone));
} catch (Exception e) {
entity.setPhonePlain(phone); // 严重错误
}
正确原则:
需要加密的字段无法安全加密时,本次写入应失败,而不是自动降级为明文。
17.3 解密失败禁止静默返回空字符串
catch (Exception e) {
return ""; // 错误:掩盖数据损坏
}
空值、格式错误、密钥不可用和认证失败必须保持语义区别。
17.4 KMS 故障时的读写策略
写请求
- 当前 DEK 已在安全缓存中:可在限定窗口内继续写。
- 当前 DEK 未缓存且 KMS 不可用:拒绝敏感字段写入。
- 不得使用旧版本密钥偷偷继续写,除非轮转控制器明确允许回退。
读请求
- 所需版本 DEK 已缓存:继续解密。
- 未缓存且 KMS 不可用:返回可重试错误或受控脱敏结果。
- 对高敏感页面,不得因 KMS 故障直接返回数据库密文或历史明文。
17.5 断路器要谨慎
KMS Gateway 可以使用超时、重试和断路器,但需注意:
-
只对明确可重试错误重试。
-
使用指数退避和抖动。
-
不在请求链路无限重试。
-
断路后仍允许命中本地 Key Cache。
-
轮转、查询和解密操作可使用不同隔离舱。
-
十八、日志、脱敏、审计与权限隔离
18.1 加密不能代替日志治理
下面代码应被禁止:
log.info("request={}", request);
log.debug("user={}", userEntity);
log.error("decrypt failed, data={}", ciphertext);
即使打印的是密文,也可能暴露:
- 密钥版本。
- 数据关系。
- 可重放材料。
- 批量导出入口。
18.2 敏感值对象避免自动 toString()
public final class SensitiveValue {
private final String value;
public SensitiveValue(String value) {
this.value = value;
}
public String reveal() {
return value;
}
@Override
public String toString() {
return "[REDACTED]";
}
}
对包含敏感字段的 DTO,不要无脑使用 Lombok @Data。
18.3 解密审计记录什么
建议记录:
- 调用主体。
- 服务与接口。
- 租户。
- 数据分类。
- 业务记录 ID 的不可逆摘要。
- 解密目的码。
- 是否返回完整值或掩码。
- 成功、拒绝或失败原因。
- 时间、来源 IP、Trace ID。
不要记录:
- 明文。
- 完整密文。
- 明文 DEK。
- KMS Token。
18.4 解密权限应独立于数据库查询权限
一个账号可以查询 phone_cipher,不代表它有权调用 KMS 解密。
推荐拆分:
DB_READ_CIPHER
CRYPTO_ENCRYPT_PHONE
CRYPTO_DECRYPT_PHONE_MASKED
CRYPTO_DECRYPT_PHONE_FULL
CRYPTO_ROTATE_PHONE_KEY
不同权限分配给不同服务身份和运维角色。
18.5 动态脱敏应发生在授权之后
正确顺序:
身份认证 -> 权限判断 -> 解密 -> 按场景脱敏 -> 输出 -> 审计
不要在 Controller 中随意解密,再依赖前端隐藏。
十九、测试体系:不仅要验证能解密
19.1 单元测试清单
必须验证:
- 正常加密解密。
- 同一明文多次加密得到不同密文。
- 修改任意密文字节后解密失败。
- 修改 nonce 后解密失败。
- 修改 AAD 后解密失败。
- 错误密钥版本无法解密。
- 未知格式版本被拒绝。
- 空值与空字符串语义正确。
- UTF-8 中文、Emoji、超长值可正确处理。
- 盲索引输入规范化一致。
19.2 JUnit 示例
import org.junit.jupiter.api.Test;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class AesGcmFieldCryptoTest {
private static final CryptoContext PHONE_CONTEXT =
new CryptoContext("member-service", "user_profile", "phone");
@Test
void shouldEncryptAndDecrypt() throws Exception {
SecretKey key = generateAes256();
DataKeyProvider provider = provider(Map.of(1, key), 1);
AesGcmFieldCrypto crypto = new AesGcmFieldCrypto(provider);
String cipher = crypto.encrypt("13800138000", PHONE_CONTEXT);
String plain = crypto.decrypt(cipher, PHONE_CONTEXT);
assertEquals("13800138000", plain);
}
@Test
void samePlaintextShouldProduceDifferentCiphertext() throws Exception {
SecretKey key = generateAes256();
AesGcmFieldCrypto crypto = new AesGcmFieldCrypto(
provider(Map.of(1, key), 1)
);
String first = crypto.encrypt("13800138000", PHONE_CONTEXT);
String second = crypto.encrypt("13800138000", PHONE_CONTEXT);
assertNotEquals(first, second);
}
@Test
void wrongContextShouldFailAuthentication() throws Exception {
SecretKey key = generateAes256();
AesGcmFieldCrypto crypto = new AesGcmFieldCrypto(
provider(Map.of(1, key), 1)
);
String cipher = crypto.encrypt("13800138000", PHONE_CONTEXT);
CryptoContext wrongContext =
new CryptoContext("member-service", "user_profile", "email");
assertThrows(
CryptoIntegrityException.class,
() -> crypto.decrypt(cipher, wrongContext)
);
}
private static SecretKey generateAes256() throws Exception {
KeyGenerator generator = KeyGenerator.getInstance("AES");
generator.init(256);
return generator.generateKey();
}
private static DataKeyProvider provider(
Map<Integer, SecretKey> keys,
int currentVersion
) {
return new DataKeyProvider() {
@Override
public DataKey currentEncryptionKey() {
return new DataKey(currentVersion, keys.get(currentVersion));
}
@Override
public DataKey keyByVersion(int version) {
SecretKey key = keys.get(version);
if (key == null) {
throw new IllegalArgumentException("unknown key version=" + version);
}
return new DataKey(version, key);
}
};
}
}
19.3 集成测试
至少覆盖:
- MyBatis 插入后数据库列确实为密文。
- 查询盲索引可以命中正确记录。
- 不同租户相同手机号的盲索引不同。
- 轮转后新数据使用新版本,旧数据仍可读取。
- KMS 不可用时缓存命中与缓存未命中的行为符合预期。
- 双写迁移任务可幂等重跑。
- 主从切换后密钥版本元数据一致。
19.4 安全测试
二十、常见错误清单
错误 1:把 Base64 当加密
Base64 只是编码,任何人都可以解码。
错误 2:使用固定 IV
同一 AES-GCM 密钥下重复 nonce 会严重破坏安全性。
错误 3:DEK 与盲索引共用同一密钥
违反密钥用途隔离原则,轮转和权限边界混乱。
错误 4:盲索引使用普通 SHA-256
手机号空间有限,攻击者可预计算所有候选值。
错误实现:
SHA-256(phone)
正确方向:
HMAC-SHA-256(BIK, domain || tenant || normalized_phone)
错误 5:给盲索引加随机盐后还想直接等值查询
随机盐会使同一值的索引不同,数据库无法直接命中。
错误 6:密钥版本只用一个字节
一个字节最多表达 255 个正版本,长期系统、多个密钥域和迁移场景很容易受限。使用无符号 32 位版本或稳定字符串 Key ID 更合理。
错误 7:所有 String 字段注册同一个 TypeHandler
容易误加密、误解密,并扩大明文对象传播范围。
错误 8:轮转后立刻删除旧密钥
旧密文、备份、消息和延迟任务可能仍依赖旧版本。
错误 9:解密失败时回退明文
这会让篡改、密钥错误和迁移缺陷被静默掩盖。
错误 10:加密后继续在日志中打印明文对象
字段加密无法抵消日志泄漏。
错误 11:每行数据都远程调用 KMS
高并发系统会把网络延迟、KMS 配额和外部依赖放大到核心交易链路。
错误 12:多个环境共用同一密钥
开发、测试、预发布和生产必须使用不同密钥域,测试环境不得复制生产明文或生产 DEK。
错误 13:只迁移主库,不处理数据副本
Binlog、CDC、ES、数据仓库、对象存储和备份仍可能保留明文。
错误 14:把“可以解密”当作“有权解密”
密码学能力与业务授权必须同时成立。
二十一、分阶段演进路线
阶段 0:止血
- 清理日志中的手机号、证件号和密钥。
- 禁止请求对象直接
toString()。
- 收敛数据库和日志查询权限。
- 建立敏感字段清单。
阶段 1:基础字段加密
- AES-256-GCM。
- 固定密文格式 v1。
- KMS 托管 KEK。
- 每业务域一个 DEK。
- 显式仓储加解密。
阶段 2:可查询与迁移
- 独立 BIK。
- HMAC 盲索引。
- 双写、回填、切读、清理明文。
- 解密审计。
阶段 3:密钥治理
- 当前版本与历史版本管理。
- 自动轮转状态机。
- 灰度切换与回滚。
- 密钥缓存、限流和 KMS 容灾。
阶段 4:租户与数据域隔离
- 每租户加每数据域 DEK/BIK。
- 细粒度 KMS Policy。
- 高敏感字段独立服务身份。
阶段 5:平台化
- Java、Go、Python 统一 SDK。
- 密文格式兼容规范。
- 自动代码扫描和日志检测。
- 密钥资产盘点。
- 解密审批、审计和异常检测平台。
阶段 6:高敏感数据 Tokenization
对支付卡、医疗核心数据等场景,进一步将原值集中到专用安全域,业务系统只持有 Token,降低明文进入普通微服务和数据库的机会。
二十二、上线检查清单
算法与密文
- 使用 AEAD,而不是 ECB。
- AES-GCM nonce 为 12 字节且同一密钥下不重复。
- 认证标签使用 128 位。
- 使用稳定 AAD 绑定应用、表和字段上下文。
- 密文格式包含格式版本、算法和密钥版本。
- 未知格式严格拒绝,不猜测解密。
密钥管理
- KEK 托管在 Vault、云 KMS 或 HSM。
- 明文 DEK 不进入配置中心、代码仓库和数据库。
- DEK 与 BIK 分离。
- 不同环境使用不同密钥域。
- 应用只拥有所需的最小 KMS 权限。
- 已定义密钥轮转、退役和应急吊销流程。
查询与数据库
- 等值查询使用独立 HMAC 盲索引。
- 输入规范化规则已版本化。
- 索引包含租户边界。
- 密文字段容量按字节计算。
- 必要时使用 VARBINARY,避免无意义 Base64 膨胀。
- 已评估唯一索引和碰撞处理。
应用与框架
- 没有全局 String 加密 TypeHandler。
- 高敏感字段在授权后显式解密。
- 敏感 DTO 不会自动打印完整值。
- 加密或解密失败不会回退明文。
- 明文不会进入通用缓存。
- 批量请求不会逐行调用 KMS。
迁移与轮转
- 使用主键游标分批回填。
- 双写期可观测且时间受控。
- 解密失败与密文为空严格区分。
- 新写版本切换前所有实例可读取新版本。
- 旧密钥退役前已扫描数据库、备份、消息和离线副本。
- 轮转事件支持幂等和乱序保护。
容器与运维
-
Kubernetes Secret 已配置静态加密或外部 Secret Store。
-
使用短期工作负载身份,不使用永久根 Token。
-
KMS、Vault 和密钥缓存具备监控。
-
heap dump、core dump 和诊断文件受到保护。
-
日志、APM、Trace 和异常平台不含敏感明文。
-
解密操作具备审计记录和异常告警。
-
二十三、总结
字段级加密不是给数据库列套一个 AES 工具类,而是一套跨越密码学、密钥管理、数据库设计、权限模型、容器安全、数据迁移和生产运维的系统工程。
真正可靠的方案应具备以下特征:
- 明确威胁模型:知道字段加密保护什么,也知道它不能保护什么。
- 使用 AEAD:优先采用 AES-GCM 或成熟高层密码库,并正确处理 nonce、Tag 与 AAD。
- 分离密钥职责:KEK、DEK、BIK 不混用,权限和生命周期独立治理。
- 密文可演进:格式、算法和密钥版本都能够兼容升级。
- 查询不牺牲底线:等值查询使用独立 HMAC 盲索引,不把随机加密改成脆弱的固定密文。
- 解密受授权控制:不是查到数据就自动获得完整明文。
- 轮转真正可执行:支持读旧写新、历史回填、验证、回滚和旧密钥退役。
- 故障时失败关闭:不因 KMS 异常、格式错误或认证失败而回退明文。
- 全链路治理:数据库密文之外,还要处理日志、缓存、消息、搜索、备份和离线副本。
- 用数据评估性能:通过自身压测确定 Cipher、KMS、缓存和数据库写放大的真实瓶颈。
当这些能力都具备时,字段加密才不再是一段“看起来安全”的工具代码,而会成为企业数据安全体系中可审计、可轮转、可扩展、可恢复的基础设施。
参考资料
- NIST, SP 800-38D: Recommendation for Block Cipher Modes of Operation: Galois/Counter Mode (GCM) and GMAC
https://csrc.nist.gov/pubs/sp/800/38/d/final
- NIST, Second Pre-Draft Call for Comments: GCM and GMAC Block Cipher Modes, SP 800-38D Revision 1
https://csrc.nist.gov/pubs/sp/800/38/d/r1/2prd
- OWASP, Cryptographic Storage Cheat Sheet
https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html
- OWASP, Secrets Management Cheat Sheet
https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- Google Cloud, Envelope encryption
https://cloud.google.com/kms/docs/envelope-encryption
- Google Tink, Authenticated Encryption with Associated Data
https://developers.google.com/tink/aead
- Google Tink, Bind ciphertext to its context
https://developers.google.com/tink/bind-ciphertext
- HashiCorp Vault, Transit secrets engine
https://developer.hashicorp.com/vault/docs/secrets/transit
- HashiCorp Vault, Re-wrapping data after encryption key rotation
https://developer.hashicorp.com/vault/tutorials/encryption-as-a-service/eaas-transit-rewrap
- Kubernetes, Good practices for Kubernetes Secrets
https://kubernetes.io/docs/concepts/security/secrets-good-practices/
- Kubernetes, Encrypting Confidential Data at Rest
https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/
- MyBatis, Configuration: typeHandlers
https://mybatis.org/mybatis-3/configuration
本文中的示例用于展示工程设计方法。实际生产环境应结合数据分类、业务风险、合规要求、KMS 产品能力、JDK Provider、数据库规模和团队运维能力进行安全评审、容量测试与灰度发布。