找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4279

积分

0

好友

563

主题
发表于 3 小时前 | 查看: 6| 回复: 0

从 AES-GCM、信封加密、盲索引到密钥轮转的企业级落地实践

数据库开启了 TDE,不代表 DBA 看不到明文;字段加了 AES,也不代表系统就具备密钥治理能力;代码里用了 GCM,更不代表实现一定安全。

真正可落地的字段级加密,必须同时解决威胁模型、算法误用、密钥分层、密文格式、查询索引、权限隔离、历史迁移、轮转回滚、容器注入、审计监控与故障降级等问题。


目录

  • 一、从一次“已经加密”的数据泄漏说起

  • 二、先建立正确认知:字段加密到底防什么

  • 三、算法选型:生产环境为什么优先使用 AEAD

  • 四、GCM 最容易被忽略的安全边界

  • 五、企业级密钥体系:KEK、DEK、BIK 必须分离

  • 六、三种信封加密粒度,应该如何选择

  • 七、推荐架构:把加密能力变成受控安全边界

  • 八、密文格式设计:不要只存一段 Base64

  • 九、数据库表结构与字段容量如何设计

  • 十、生产级 Java AES-GCM 实现

  • 十一、盲索引:加密手机号如何支持等值查询

  • 十二、MyBatis 集成:透明加密并不总是最佳答案

  • 十三、密钥轮转:不是替换配置然后重启服务

  • 十四、历史明文数据如何平滑迁移

  • 十五、Kubernetes 与 Vault/KMS 集成

  • 十六、高并发性能优化与容量评估

  • 十七、异常处理、可用性与故障降级

  • 十八、日志、脱敏、审计与权限隔离

  • 十九、测试体系:不仅要验证能解密

  • 二十、常见错误清单

  • 二十一、分阶段演进路线

  • 二十二、上线检查清单

  • 二十三、总结

  • 参考资料

    • *

一、从一次“已经加密”的数据泄漏说起

某电商平台的订单系统保存了手机号、收货地址、证件号码等个人信息。为了满足安全审计要求,团队完成了三项改造:

  1. 云磁盘开启加密。
  2. MySQL 开启透明数据加密 TDE。
  3. 数据库备份文件开启服务端加密。

从基础设施视角看,数据似乎已经被保护得很完整。

但一次线上排障中,具有只读权限的运维人员直接执行:

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_versionalgorithmkey_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_maskedlast4、证件后四位等字段仍可能与其他信息组合识别个人,应纳入数据分类、访问控制和审计范围。


十、生产级 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、字符串常量池或对象复制过程中不存在其他副本。

因此还需要:

  • 禁止生产环境随意生成 heap dump。

  • 对 heap dump、core dump 和诊断文件加密并限制访问。

  • 避免将明文长时间保存在缓存和全局对象中。

  • 缩短明文生命周期。

  • 对极高敏感数据考虑 Tokenization、独立安全服务或 HSM 支持方案。

    • *

十一、盲索引:加密手机号如何支持等值查询

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)

查询时:

  1. 规范化输入。
  2. 使用相同 BIK 计算盲索引。
  3. tenant_id + phone_blind_index 查询。
  4. 读取候选记录后再解密并做最终一致性校验。

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 正确轮转状态机

完整步骤:

  1. 在 KMS 创建 DEK v2 或轮转 KEK 版本。
  2. 所有实例获得读取 v1、v2 的能力。
  3. 健康检查确认 v2 可加载。
  4. 将当前写版本从 v1 切换为 v2。
  5. 新写入只使用 v2,旧数据继续由 v1 解密。
  6. 观察加密失败率、未知版本数、KMS 延迟和缓存命中率。
  7. 后台批量将 v1 密文重加密为 v2。
  8. 验证数据库、缓存、消息、搜索索引和离线副本中的旧版本残留。
  9. 先禁用旧版本写入,再根据保留策略退役旧版本。
  10. 只有在确认没有恢复、审计和历史回放需求后,才考虑销毁旧密钥。

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
) {}

消费者应满足:

  • 重复事件不会反复破坏缓存。

  • 乱序事件不会把当前版本回退。

  • 只有更大的 eventVersion 才能更新本地状态。

  • 配置中心或 KMS 仍是最终事实来源,消息只用于加速收敛。

    • *

十四、历史明文数据如何平滑迁移

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 安全测试

  • 尝试替换密文头中的 key version。

  • 将 A 字段密文复制到 B 字段。

  • 重放其他租户密文。

  • 枚举盲索引查询接口。

  • 检查日志、Trace、APM、异常平台是否出现明文。

  • 检查 heap dump、线程 dump、SQL 审计和慢日志。

  • 检查低权限 Pod 是否可读取 KMS 凭证。

    • *

二十、常见错误清单

错误 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 工具类,而是一套跨越密码学、密钥管理、数据库设计、权限模型、容器安全、数据迁移和生产运维的系统工程。

真正可靠的方案应具备以下特征:

  1. 明确威胁模型:知道字段加密保护什么,也知道它不能保护什么。
  2. 使用 AEAD:优先采用 AES-GCM 或成熟高层密码库,并正确处理 nonce、Tag 与 AAD。
  3. 分离密钥职责:KEK、DEK、BIK 不混用,权限和生命周期独立治理。
  4. 密文可演进:格式、算法和密钥版本都能够兼容升级。
  5. 查询不牺牲底线:等值查询使用独立 HMAC 盲索引,不把随机加密改成脆弱的固定密文。
  6. 解密受授权控制:不是查到数据就自动获得完整明文。
  7. 轮转真正可执行:支持读旧写新、历史回填、验证、回滚和旧密钥退役。
  8. 故障时失败关闭:不因 KMS 异常、格式错误或认证失败而回退明文。
  9. 全链路治理:数据库密文之外,还要处理日志、缓存、消息、搜索、备份和离线副本。
  10. 用数据评估性能:通过自身压测确定 Cipher、KMS、缓存和数据库写放大的真实瓶颈。

当这些能力都具备时,字段加密才不再是一段“看起来安全”的工具代码,而会成为企业数据安全体系中可审计、可轮转、可扩展、可恢复的基础设施。


参考资料

  1. 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
  2. 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
  3. OWASP, Cryptographic Storage Cheat Sheet
    https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html
  4. OWASP, Secrets Management Cheat Sheet
    https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
  5. Google Cloud, Envelope encryption
    https://cloud.google.com/kms/docs/envelope-encryption
  6. Google Tink, Authenticated Encryption with Associated Data
    https://developers.google.com/tink/aead
  7. Google Tink, Bind ciphertext to its context
    https://developers.google.com/tink/bind-ciphertext
  8. HashiCorp Vault, Transit secrets engine
    https://developer.hashicorp.com/vault/docs/secrets/transit
  9. HashiCorp Vault, Re-wrapping data after encryption key rotation
    https://developer.hashicorp.com/vault/tutorials/encryption-as-a-service/eaas-transit-rewrap
  10. Kubernetes, Good practices for Kubernetes Secrets
    https://kubernetes.io/docs/concepts/security/secrets-good-practices/
  11. Kubernetes, Encrypting Confidential Data at Rest
    https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/
  12. MyBatis, Configuration: typeHandlers
    https://mybatis.org/mybatis-3/configuration

本文中的示例用于展示工程设计方法。实际生产环境应结合数据分类、业务风险、合规要求、KMS 产品能力、JDK Provider、数据库规模和团队运维能力进行安全评审、容量测试与灰度发布。




上一篇:DeepSeek V4 Flash 成本对比:每任务仅 0.03 美元,比 Fable 5 便宜 105 倍
下一篇:AI保险创企Corgi半年估值翻6倍,如何为算法偏见与模型幻觉定价?
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-8-4 04:45 , Processed in 0.793112 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表