字段级加密落地:用 AAD 约束密文搬运,用格式头支撑密钥轮转
数据库开启 TDE,不代表 DBA 看不到明文;字段使用 AES 加密,也不代表系统具备密钥治理能力;代码使用 GCM,更不代表实现一定安全。这三件事各自解决不同层面的问题。
一、一次“已经加密”的数据泄漏
某电商平台订单系统保存手机号、收货地址、证件号。团队完成了三项改造:云磁盘加密、MySQL TDE、备份文件服务端加密。线上排障时,只有只读权限的运维执行:
SELECT order_no, receiver_phone, receiver_address
FROM trade_order WHERE order_no = '...';
结果仍是完整明文。同时应用为排查接口超时加了 log.info("create order request={}", request),Lombok 生成的 toString() 把手机号、证件号、详细地址全部写进日志平台。
根源在于:磁盘加密、TDE、备份加密保护的是存储介质与备份副本,不阻止有查询权限的主体读取列明文。要降低数据库账号泄漏、备份泄漏、只读 DBA 越权、数据导出和离线分析的风险,数据必须在进库前就是应用层密文。应用被完全攻陷时,攻击者仍能读到内存中的解密后明文,所以字段加密必须建立在明确的威胁模型上。
二、字段加密到底防什么
2.1 四类手段不是同一件事
- 磁盘/文件系统加密:保护磁盘、快照、物理介质;不能阻止 SQL 读到明文;可恢复原文。
- 数据库 TDE:保护表空间、数据文件、日志文件、备份;不能阻止 SQL 读到明文。
- 应用层字段加密:保护指定业务字段;能阻止 SQL 读到明文;可恢复原文。用于手机号、证件号、银行卡号。
- 脱敏:页面/接口/日志展示,不改变底层原文。
- 哈希:保护不需要恢复的秘密,不可逆。用于密码、不可逆比对。
- Tokenization:原值与令牌映射,是否可还原由令牌服务控制。
密码不应通过可逆 AES 保存,应使用专用密码哈希。字段加密适用于业务必须恢复原文的数据。
2.2 威胁矩阵
| 威胁 | TDE | 字段加密 |
|---|---|---|
| 云盘快照泄漏 | 有效 | 有效 |
| MySQL 物理备份泄漏 | 有效 | 有效 |
| 只读数据库账号泄漏 | 无效 | 有效 |
| DBA 越权查询 | 无效 | 有效 |
| 应用日志打印明文 | 无效 | 无效,需日志脱敏 |
| 应用进程被完全控制 | 保护有限 | 保护有限 |
| KMS 管理员越权 | 保护有限 | 保护有限,需双人审批/HSM/职责分离 |
| 密文被篡改 | — | AEAD 可检测 |
2.3 字段分级
- L0 公开:不加密。
- L1 内部:依赖访问控制与审计。
- L2 敏感(手机号、邮箱、地址):字段加密加动态脱敏。
- L3 高敏(证件号、银行卡号、医疗):字段加密、独立密钥域、严格解密审批。
- L4 凭证(密码、令牌、私钥):密码哈希或 Secrets Manager。
不要为了“统一”而加密所有字段。过度加密会破坏索引、排序、统计、排障和数据分析能力,并显著增加密钥治理成本。
三、算法选型:优先 AEAD
字段加密要回答的不只是机密性:密文是否被修改?密文是否被复制到错误字段?是否来自错误租户或业务域?元数据是否被替换?这些问题指向 AEAD,可用 AES-256-GCM、ChaCha20-Poly1305 或 Google Tink AEAD。
不推荐 ECB:同密钥下相同明文块产生相同密文块,泄漏数据模式——手机号 A 固定对应密文 X。
CBC 只提供机密性,不天然提供完整性,须正确组合独立 MAC 并严格 Encrypt-then-MAC;新系统直接用 AEAD。
AES-128 与 AES-256 在正确实现下都足够强,选 256 是为统一安全基线与合规表达。真正的风险来自:GCM nonce 重复、密钥硬编码、同密钥跨环境/跨租户/跨用途复用、解密失败后回退明文、密钥轮转不可执行、日志与监控暴露原文。
四、GCM 的边界条件
4.1 96 位 nonce 的唯一性
nonce 不要求保密,但同一密钥下不得重复。随机生成可行,但碰撞概率随加密次数累积。应控制单个 DEK 最大加密量、DEK 使用周期、轮转频率、高并发实例的随机数实现,以及虚拟机快照恢复导致随机状态回退的问题。极高调用量可用“实例唯一前缀 + 单调计数器”的结构化 nonce,但持久化、并发和故障恢复要严谨。
4.2 随机数实例
不要每次调用 SecureRandom.getInstanceStrong(),它可能阻塞。复用一个线程安全的 static final SecureRandom 实例,每次加密生成新的 12 字节 nonce。
4.3 认证标签
使用 128 位认证标签,不要为省字节截短。
4.4 AAD 绑定业务上下文
AAD 参与认证但不加密,解密时必须提供完全相同的值。它的作用不是隐藏信息,而是把密文绑定到业务上下文,其中要重点处理密文搬运攻击:同一 DEK 下,把 user_profile.phone 的密文复制到 bank_account.card_no,如果没有 AAD,这段密文照样能解密成功;把 A 行的密文搬到 B 行,也一样。AAD 不匹配时,认证直接失败。
推荐稳定的 AAD 组成:
应用标识|数据域|表名|字段名|密文格式版本
member-service|user_profile|phone|format-v1
可以加租户 ID,但解密时必须能得到完全相同的值。不要把会修改的昵称、状态、时间戳放进 AAD,否则更新后旧密文无法解密。主键绑定要谨慎:插入前主键可能尚未生成,迁移和跨库复制会变复杂。
五、密钥体系:KEK、DEK、BIK 分离
- KEK 加密或封装 DEK,由 Vault、云 KMS 或 HSM 托管。
- DEK 直接用于 AES-GCM,应用授权后短期获得并缓存于进程内存。
- BIK 计算盲索引:
blind_index = HMAC-SHA-256(BIK, normalized_value)。
BIK 必须与 DEK 分离:用途隔离;DEK 轮转不必同步改索引;泄漏 BIK 不等于获得解密能力;两者可以有不同的轮转频率与权限。
严禁出现的写法:
application.yml里的明文aes-key- 代码里的
static final String AES_KEY = "0123456789abcdef" - Nacos 里存
Base64(AES_KEY)——Base64 不是加密
可以存元数据:KMS 地址与命名空间、KEK 标识、当前 DEK 版本号、密文格式版本、轮转策略、允许算法列表、缓存 TTL。不可存:明文 DEK、明文 BIK、可换长期密钥的永久 Token、未经二次保护的 KMS 根凭证。
六、信封加密粒度
每条记录一个 DEK:影响半径小,便于按记录加密删除,不必定期重加密全部数据;但每条要存 wrapped_dek,缓存命中率低,写入与存储开销大。适合极高敏感、小对象、强隔离。
每租户或每数据域一个 DEK:隔离与性能平衡,本地缓存命中率高,可单租户单域独立轮转;密钥数量随租户增长。适合 SaaS、多租户中台。
每应用/每业务域一个 DEK:实现简单、性能高;影响半径最大,跨租户隔离弱,历史重加密成本高。适合中低敏感字段、早期系统。
不要机械认为“每条记录一个 DEK”一定最好,要一起评估隔离、吞吐、KMS 配额、延迟、存储和运维复杂度。
七、架构:把加密变成受控边界
- Sensitive Data Policy:判断调用者能否解密、返回完整明文还是掩码、是否记录解密原因、哪些字段使用独立密钥域。
- Field Crypto SDK:AEAD 加解密、密文封装解析、AAD 生成、盲索引计算、多格式兼容、统一异常分类。
- Key Provider:取当前写密钥版本、按历史版本加载解密密钥、缓存 DEK/BIK、KMS 失败时的明确策略、轮转通知。
- KMS Gateway:屏蔽 Vault、AWS KMS、GCP KMS、Azure Key Vault、HSM 的差异。
- Rotation Controller:创建新版本、切换写版本、触发重加密、验证旧版本残留、退役旧密钥。
不推荐把解密能力下放给所有服务:订单列表服务只拿脱敏手机号;短信服务只获得一次性发送能力;客服系统必须在授权、工单和审计上下文中才能看完整号码;数据分析平台只用脱敏值或不可逆标识。字段加密的价值不只是数据库里变成密文,而是缩小“谁能够解密”的范围。
八、密文格式:不要只存一段 Base64
只存 Base64(nonce + ciphertext + tag),几年后无法判断:用了哪个密钥版本、AES-GCM 还是别的算法、AAD 规则是什么、是否历史兼容格式。轮转时尤其明显——密文里没有 key_version,认不出该用哪把密钥解。
推荐逻辑格式:
magic | format_version | algorithm | key_version | nonce | ciphertext_with_tag
序列化为:
enc:v1:aes256gcm:k42:
逐段含义:enc 标识受支持密文,v1 是密文格式版本,aes256gcm 是算法标识,k42 是 DEK 版本,最后一段是 nonce、密文与标签。
format_version、algorithm、key_version 这些元数据也应作为 AAD 的一部分参与认证,避免攻击者修改密文头部后仍能解密。
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;
密文头已含版本号时,额外的 crypto_key_version 列不是解密必需,但便于统计各版本数据量、让迁移任务无需解析整段密文即可筛出旧版本、监控轮转进度、发现异常写入版本。代价是要保证列值与密文头一致,由 SDK 统一生成,禁止业务代码分别赋值。
容量估算:AES-GCM 密文长度约为明文字节数 + 12 字节 nonce + 16 字节 tag + 格式头;Base64 再乘约 4/3。中文地址按 UTF-8 编码后的字节数远大于 Java 字符数,容量应按最大字节数设计。
脱敏列不是绝对无风险:phone_masked、last4、证件后四位仍可能与其他信息组合识别个人,应纳入数据分类、访问控制与审计。
十、密钥轮转不是改配置重启
轮转要区分三件事,爆炸半径不同:切换写版本、重加密历史数据、退役旧密钥。历史明文迁移要分阶段:新增双写、回填、校验覆盖、切读、退役旧路径;解密失败禁止回退明文。
常见错误清单:
- nonce 复用
- key 与密文存放在同一个库
- 解析失败即当明文处理
- 把密文当
ORDER BY/LIKE的查询条件 - 盲索引与加密密钥共用
- 轮转只改配置不回填
- 密文未绑定 AAD,字段之间可互相搬运