编程 字段加密只存 Base64(nonce‖ct‖tag):A 行密文搬到 B 行照样解开,轮换时认不出 key_version

2026-11-14 00:03:47

字段级加密落地:用 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,字段之间可互相搬运
复制全文 生成海报 加密字段 AES-GCM AAD 密钥轮转 MySQL

推荐文章

程序员茄子在线接单