-
Notifications
You must be signed in to change notification settings - Fork 1
Home
用一把"主密钥"保护"数据密钥",再用"数据密钥"加密业务数据——主密钥始终不离开安全边界,海量数据在本地完成高性能加解密。这是 AWS、Azure、阿里云、腾讯云等主流云厂商应对大规模数据加密的行业标准实践。 LightCrypto-Link(LCL)以信封加密为核心模型,并将其落地为 Multi-DEK 架构 + 可插拔 CMK Provider 的工程实现。本文是理解整个项目的入口。
如果直接用 CMK 加密每一条业务数据,会面临三个问题:
| 问题 | 直接用 CMK 加密 | 信封加密 |
|---|---|---|
| 性能 | 每次加解密都要走网络调用 KMS,延迟高 | 仅密钥操作走网络,数据加解密在本地 CPU 完成 |
| 密钥暴露面 | CMK 频繁参与运算,泄露风险大 | CMK 只用来 wrap/unwrap DEK,调用次数极少 |
| CMK 轮转成本 | 轮转 CMK 需要重新加密全部业务数据 | 轮转 CMK 只需 re-wrap DEK,业务密文不动 |
| 信封加密的本质是 "数据加密"与"密钥管理"的彻底分离:把昂贵的非对称/远程操作留给少量密钥,把高频的对称操作留给本地 CPU。 |
flowchart LR
subgraph KMS["安全边界 (KMS / HSM)"]
CMK["CMK 主密钥<br/>永不出界"]
end
subgraph App["应用进程 (本地内存)"]
DEK_P["明文 DEK<br/>用完即焚"]
DATA["业务数据"]
CIPHER["密文数据"]
end
subgraph Store["持久化存储 (DB)"]
WDEK["密文 DEK (信封)"]
CIPHER2["密文数据"]
end
CMK -- "wrap / unwrap" --> WDEK
DEK_P -- "加密" --> DATA
DATA --> CIPHER
DEK_P --> WDEK
CIPHER --> CIPHER2
上图概括了信封加密的全貌:CMK 在安全边界内只负责把 DEK "装进信封"(wrap)和 "拆开信封"(unwrap),DEK 明文只在内存中短暂存在并加密数据,落盘的永远是密文 DEK 和密文数据。
信封加密的核心是两级密钥,二者职责正交、生命周期独立。
| 维度 | CMK(Customer Master Key) | DEK(Data Encryption Key) |
|---|---|---|
| 角色 | 根密钥,保护 DEK | 数据密钥,保护业务数据 |
| 存放位置 | KMS / HSM / 本地安全配置 | 随密文一起落盘(以 wrapped 形式) |
| 明文是否出界 | 永不 | 仅在内存中短暂存在,用完即焚 |
| 算法 | 非对称(RSA-OAEP)或对称(AES) | 对称(AES-256-GCM) |
| 调用频率 | 极低(仅密钥操作) | 高频(每条数据) |
| 轮转影响 | 只需 re-wrap DEK | 需 re-encrypt 该 DEK 保护的数据 |
| 泄露后果 | 灾难性(全量数据可解) | 局部(仅该 DEK 保护的数据) |
| 在 LCL 中,DEK 并非孤立存在——每个 DEK 都配对一个 HMAC Key,二者通过 binding 哈希绑定,用于完整性校验和防止密钥替换攻击。 |
-
CMK(主密钥):根信任锚,由
CmkProvider抽象,可对接 Azure Key Vault、阿里云 KMS、本地对称密钥等。 -
DEK(数据密钥):每个实体类一把,AES-256-GCM,用于加密
@Encrypted字段。 - HMAC Key:与 DEK 成对生成,用于字段级完整性校验。
- Wrapped DEK:被 CMK 加密后的 DEK 密文,落盘在 Key Vault 中。
- KCV(Key Check Value):密钥校验值,用于在不暴露密钥的前提下验证解包是否正确。
- Binding:DEK 与 HMAC Key 之间的绑定哈希,防止密钥被替换。
-
Key Vault:存储 wrapped DEK/HMAC 的 MongoDB 集合(
__lcl_keyvault)。 -
kid(Key ID):DEK 的版本标识,如
v1-a3b2c1d4,支持 DEK 轮转。
写入一条含 @Encrypted 字段的实体时,LCL 的完整调用链如下:
sequenceDiagram
autonumber
participant App as 业务代码
participant Core as LCL Core
participant Vault as Key Vault (MongoDB)
participant Cache as DEK 缓存(内存)
participant Prov as CmkProvider
participant KMS as KMS / HSM
App->>Core: save(entity)
Core->>Cache: 查找该实体类的明文 DEK
alt 缓存未命中
Core->>Vault: 读取 wrapped DEK
alt Vault 无记录(首次)
Core->>Core: 生成随机 DEK + HMAC Key
Core->>Prov: wrap(DEK) / wrap(HMAC)
Prov->>KMS: 用 CMK 加密 DEK
KMS-->>Prov: wrapped DEK
Prov-->>Core: WrappedKey(ciphertext, algo, metadata)
Core->>Vault: 持久化 wrapped DEK/HMAC + KCV + binding
end
Core->>Prov: unwrap(wrapped DEK)
Prov->>KMS: 用 CMK 解密
KMS-->>Prov: 明文 DEK
Prov-->>Core: 明文 DEK
Core->>Cache: 缓存明文 DEK
end
Core->>Core: 用 DEK 加密字段 (AES-256-GCM)
Core-->>App: 持久化密文实体
关键点:
- 明文 DEK 只在内存中存在,用完即焚,绝不落盘、绝不进日志。
- CMK 调用次数极少——只在缓存未命中时触发一次 wrap/unwrap,后续同实体的加解密直接复用缓存。
- 首次写入时自动初始化 Key Vault 文档,无需人工预置密钥。
读取含 @Encrypted 字段的实体时:
sequenceDiagram
autonumber
participant App as 业务代码
participant Core as LCL Core
participant DB as MongoDB (业务集合)
participant Cache as DEK 缓存(内存)
participant Vault as Key Vault
participant Prov as CmkProvider
participant KMS as KMS / HSM
App->>Core: findById(id)
Core->>DB: 读取密文实体
Core->>Cache: 查找明文 DEK
alt 缓存未命中
Core->>Vault: 读取 wrapped DEK + kid
Core->>Prov: unwrap(wrapped DEK)
Note over Prov,KMS: 从 WrappedKey.metadata<br/>取出 CMK 版本号<br/>带版本回放解密
Prov->>KMS: 用对应版本 CMK 解密
KMS-->>Prov: 明文 DEK
Prov-->>Core: 明文 DEK
Core->>Cache: 缓存明文 DEK
end
Core->>Core: 用 DEK 解密字段 (AES-256-GCM)
Core->>Core: 用 HMAC Key 校验完整性
Core-->>App: 返回明文实体
LCL 不是"一把 DEK 加密所有数据",而是 每个带 @Encrypted 字段的实体类拥有独立的 Key Vault 文档和独立 DEK。文档 _id 为 lcl-dek-{EntitySimpleName}。
flowchart TB
subgraph App2["应用实体"]
U["UserEntity<br/>@Encrypted phone, idCard"]
O["OrderEntity<br/>@Encrypted amount, address"]
end
subgraph KV["Key Vault 集合 __lcl_keyvault"]
V1["_id: lcl-dek-UserEntity<br/>keys: [v1-xxx (ACTIVE), v0-yyy (ROTATED)]<br/>cmk: {provider, id}"]
V2["_id: lcl-dek-OrderEntity<br/>keys: [v1-zzz (ACTIVE)]<br/>cmk: {provider, id}"]
end
U --> V1
O --> V2
为什么不用一把全局 DEK?
| 方案 | 影响范围 | 轮转成本 | 故障隔离 |
|---|---|---|---|
| 全局单 DEK | 一把泄露=全量泄露 | 全量 re-encrypt | 无 |
| Multi-DEK(LCL) | 单实体泄露不影响其他 | 单实体 re-encrypt | 实体级隔离 |
| 每个 Vault 文档内部维护一个 DEK 版本列表,支持 DEK 轮转而不破坏历史数据: |
KeyVaultDocument
├── id: "lcl-dek-UserEntity"
├── v: 2 # schema 版本
├── status: ACTIVE
├── activeKid: "v2-a3b2c1d4" # 当前生效的 DEK 版本
├── keys: # 所有版本(含历史)
│ ├── KeyVersionEntry
│ │ ├── kid: "v2-a3b2c1d4"
│ │ ├── status: ACTIVE
│ │ ├── dek: WrappedKeyInfo { wrapped, algorithm, kcv }
│ │ ├── hmk: WrappedKeyInfo { wrapped, algorithm, kcv }
│ │ └── binding: "<DEK-HMAC 绑定哈希>"
│ └── KeyVersionEntry
│ ├── kid: "v1-9f8e7d6c"
│ ├── status: ROTATED # 旧版本,仍可用于解密历史数据
│ ├── dek / hmk / binding
│ └── ...
├── cmk: { provider: "azure", id: "https://.../keys/lcl-cmk" }
├── createdAt / updatedAt
LCL 支持两个正交维度的轮转:DEK 轮转(框架一等公民)和 CMK 轮转(Provider 实现细节)。
DEK 轮转由 LCL Core 原生支持:旧 DEK 标记为 ROTATED,生成新 DEK 用当前 CMK 重新 wrap,新 kid 变为 ACTIVE。历史密文仍用旧 DEK 解密,新数据用新 DEK 加密。
flowchart LR
A["触发 rotateDek(UserEntity)"] --> B["旧 v1 标记 ROTATED"]
B --> C["生成新 DEK v2 + HMAC Key"]
C --> D["用当前 CMK wrap 新 DEK/HMAC"]
D --> E["写入 keys[] 列表, activeKid=v2"]
E --> F["新数据用 v2 加密<br/>历史数据仍用 v1 解密"]
CMK 轮转的核心难点是:解密旧密文时,必须知道当初是用 CMK 的哪个版本 wrap 的。云端 KMS SDK 默认指向 latest version,不显式带版本就解不开旧密文。
LCL 的解法是:CmkProvider 在 wrap() 时把 CMK 版本号写入 WrappedKey.metadata,在 unwrap() 时读回版本号,带版本回放解密。
flowchart LR
subgraph Wrap["wrap 阶段"]
W1["Provider 获取当前 CMK 版本"] --> W2["加密 DEK"]
W2 --> W3["版本号写入 WrappedKey.metadata"]
end
subgraph Unwrap["unwrap 阶段"]
U1["从 metadata 取出 CMK 版本"] --> U2["用该版本初始化 KMS Client"]
U2 --> U3["解密 DEK"]
end
Wrap -- "持久化到 Vault" --> Unwrap
不同 Provider 的版本策略:
| Provider | CMK 版本来源 | publicReference 取值 | 多版本必要性 |
|---|---|---|---|
AzureKeyVaultCmkProvider |
Azure Key Vault key version | 完整 key id(含 version) | 必需,云端 SDK 默认指向 latest |
AlibabaKmsCmkProvider |
阿里云 KMS KeyVersionId | CMK ARN / key id | 必需 |
LocalSymmetricCmkProvider |
单把本地对称密钥,无版本 |
local-cmk-sha256:<前8字节> 指纹 |
不做多版本,轮转走离线 re-wrap |
Local Provider 用 SHA-256 指纹而非固定字符串,是为了在启动期就能发现"配置文件里的 CMK 和库里的不是同一把",而不是等到解密时才报
AEADBadTag。这是配置防呆,不是版本管理。
LCL 通过 CmkProvider SPI 把"密钥存放在哪儿"这一决策与加解密逻辑解耦:
public interface CmkProvider {
String getProviderId(); // "azure" / "alibaba" / "local"
String getPublicReference(); // 非敏感的密钥引用,持久化到 Vault
WrappedKey wrap(byte[] plaintextKey);
byte[] unwrap(WrappedKey wrappedKey);
}WrappedKey 是个带 metadata 的 record,metadata 这个 Map 就是各 Provider 用来塞厂商特有版本信息的扩展位:
public record WrappedKey(
byte[] ciphertext,
String algorithm,
Map<String, String> metadata // CMK 版本号等厂商特有信息
) {}这种设计的好处是:框架核心不感知 CMK 版本号的具体字段名,Azure / 阿里 / 本地 / 未来 HSM 各自用自己习惯的 key 写入 metadata,框架统一透传。DEK 多版本是框架一等公民,CMK 多版本是 Provider 实现细节,二者正交。
| 威胁 | LCL 的防护措施 |
|---|---|
| CMK 明文泄露 | CMK 永不离开 KMS/HSM 边界(Local 模式下靠配置文件安全) |
| DEK 明文泄露 | 明文 DEK 只在内存短暂存在,绝不落盘、绝不进日志 |
| 密文被篡改 | AES-GCM 自带认证标签 + HMAC Key 完整性校验 |
| DEK 被替换 | DEK 与 HMAC Key 通过 binding 哈希绑定 |
| CMK 配置错误 | publicReference 指纹校验,启动期即拦截 |
| 密钥长期不轮转 | DEK 轮转(框架原生)+ CMK 轮转(Provider 层) |
| 单点泄露扩散 | Multi-DEK 架构,实体级密钥隔离 |
| 旧密文无法解密 | keys[] 保留历史版本,ROTATED 状态仍可解密 |
-
快速开始:如何在 Spring Boot 项目中引入 LCL、配置 CMK、标注
@Encrypted - CMK Provider 接入:Azure Key Vault / 阿里云 KMS / 本地对称密钥的配置示例
- 密钥轮转指南:DEK 轮转与 CMK 轮转的实操步骤
-
API 参考:
CmkProvider、KeyVaultService、@Encrypted注解的完整文档
信封加密不是 LCL 的发明,而是行业最佳实践的工程化落地。LCL 的价值在于:把"两级密钥 + Multi-DEK + 可插拔 CMK + 双向轮转"这套本应复杂的体系,封装成开发者只需一个
@Encrypted注解就能使用的透明能力。