KMS 密钥管理服务(Key Management Service)原理与实践
一、为什么需要 KMS
1.1 传统方案的问题
把加密密钥直接写在配置文件或代码中:
# 危险做法:密钥硬编码在配置文件
encrypt:
secret-key: "abc123456789def0"
secret-iv: "0fedcba987654321"
| 风险 |
后果 |
| 配置文件泄露 |
任何能访问代码仓库/配置中心的人都能拿到密钥 |
| 密钥散落各处 |
每个服务独立管理密钥,无法统一轮转 |
| 无法审计 |
不知道谁在什么时间用了密钥 |
| 轮转困难 |
换密钥需要改所有服务配置并重启 |
| 权限失控 |
开发/运维/DBA 都能看到明文密钥 |
1.2 KMS 的核心价值
密钥不落地 = 密钥永远不以明文形式存储在业务系统的磁盘、配置文件、代码仓库中。
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、核心概念
2.1 关键术语
| 概念 |
解释 |
类比 |
| KMS |
密钥管理服务,集中存储和管理密钥的独立系统 |
银行保险柜 |
| Master Key(主密钥) |
KMS 内部用来加密其他密钥的根密钥,由硬件保护 |
保险柜的钥匙 |
| Data Key(数据密钥) |
实际用于加解密业务数据的密钥 |
文件柜的钥匙 |
| 密钥版本(Version) |
同一个密钥的不同版本,支持轮转 |
换锁不换门 |
| 密钥不落地 |
数据密钥只在内存中存在,不写入业务系统磁盘 |
钥匙用完归还 |
| 信封加密(Envelope Encryption) |
用 Master Key 加密 Data Key,存储加密后的 Data Key |
把钥匙锁在另一个保险柜里 |
2.2 角色分离
┌─────────────────────────────────────────────────┐
│ KMS 服务 │
│ ┌───────────┐ │
│ │ Master Key │ ← 硬件安全模块(HSM)保护 │
│ └─────┬─────┘ │
│ │ 加密/解密 │
│ ┌─────▼──────────────────┐ │
│ │ Encrypted Data Keys │ ← 密文存储 │
│ │ (加密后的数据密钥) │ │
│ └────────────────────────┘ │
│ │
│ API: getSecretKey(version, type) │
│ ↓ 返回明文 Data Key(仅通过 HTTPS 传输) │
└───────────────────────┬─────────────────────────┘
│
HTTPS(加密传输)
│
┌───────────────────────▼─────────────────────────┐
│ 业务服务(内存中) │
│ │
│ Data Key 仅存在于内存(ConcurrentHashMap缓存) │
│ 用于加解密手机号、地址等敏感数据 │
│ 永不写入磁盘/配置文件/日志 │
│ │
└─────────────────────────────────────────────────┘
三、完整工作流程
3.1 初始化阶段(服务启动时)
业务服务启动
│
├─→ 从配置文件读取 KMS 地址和认证信息
│ (注意:这里只有 KMS 的地址和 OAuth2 凭证,没有数据密钥)
│
└─→ 此时本地无密钥缓存,等待第一次加解密请求
3.2 加密流程(首次调用)
业务代码:encrypt("138...", version="1", type="phone")
│
├─ 1. 查本地缓存 → 未命中(首次调用)
│
├─ 2. 向认证中心获取 access_token
│ POST https://auth.example.com/oauth/token
│ grant_type=password&username=kms-account&password=xxx
│ → 返回 access_token(有效期 14 小时)
│
├─ 3. 携带 Token 向 KMS 请求数据密钥
│ POST https://kms.example.com/api/secret/get-secret-key
│ Headers: Authorization: Bearer {access_token}
│ Body: { "version": "1", "type": "phone" }
│ → 返回: { "secretKey": "Base64编码的AES密钥", "secretKeyIv": "Base64编码的IV" }
│
├─ 4. 将密钥缓存到内存
│ ConcurrentHashMap.put("1:phone", SecretKeyCache(key, iv))
│
├─ 5. 使用密钥加密
│ AES-GCM(plainText, secretKey, iv) → 密文
│ 添加前缀: "ENC:" + Base64(密文)
│
└─ 6. 返回密文(存入数据库)
"ENC:a1b2c3d4e5f6g7h8i9j0..."
3.3 加密流程(后续调用,缓存命中)
业务代码:encrypt("山东省青岛市xxx路", version="1", type="address")
│
├─ 1. 查本地缓存 → 命中!
│ ConcurrentHashMap.get("1:address") → SecretKeyCache
│
├─ 2. 直接用缓存的密钥加密(无网络请求)
│ AES-GCM(plainText, cachedKey, cachedIv) → 密文
│
└─ 3. 返回密文
3.4 解密流程
从数据库读取: "ENC:a1b2c3d4e5f6g7h8i9j0..."
│
├─ 1. encrypted() 检查前缀 → 是密文
│
├─ 2. 去除前缀,Base64 解码 → 加密字节数组
│
├─ 3. 获取密钥(缓存或 KMS)
│
├─ 4. AES-GCM 解密
│ decrypt(cipherBytes, secretKey, iv) → 明文
│
└─ 5. 返回明文 "13800138000"
3.5 密钥轮转流程
安全团队决定轮转密钥(如每季度一次)
│
├─ 1. KMS 中创建新版本密钥(version=2)
│
├─ 2. 业务服务配置更新
│ xxx.kms.version: 2 ← 新数据用 v2 加密
│ xxx.kms.version-history: 1 ← 旧数据仍可用 v1 解密
│
├─ 3. 新写入数据用 v2 密钥加密(密文中包含版本标识)
│
├─ 4. 读取旧数据时:
│ ├── 从密文中识别版本号
│ └── 用对应版本密钥解密(v1 密钥仍可从 KMS 获取)
│
└─ 5. 可选:后台任务批量重加密旧数据到 v2
四、涉及的技术知识点
4.1 加密算法
| 知识点 |
说明 |
| AES-256 |
对称加密,256位密钥长度,安全性极高 |
| GCM 模式 |
Galois/Counter Mode,认证加密模式 |
| 认证加密(AEAD) |
Authenticated Encryption with Associated Data,同时保证机密性和完整性 |
| GCM Tag(认证标签) |
128位标签验证密文未被篡改 |
| IV(初始化向量) |
保证相同明文每次加密产生不同密文 |
| Base64 编码 |
二进制密文转为可存储的字符串 |
4.2 密钥管理
| 知识点 |
说明 |
| 密钥分层 |
Master Key → Data Key,分层保护 |
| 密钥版本化 |
支持密钥轮转而不影响旧数据解密 |
| 最小权限原则 |
业务服务只能通过 API 获取密钥,不能查看/修改主密钥 |
| 密钥缓存 |
避免每次加解密都调 KMS(性能考虑),但增加了内存泄露风险 |
| 密钥不落地 |
密钥只存在于内存 + HTTPS 传输通道中 |
4.3 安全通信
| 知识点 |
说明 |
| HTTPS |
密钥通过加密通道传输,防中间人攻击 |
| OAuth2 认证 |
调用 KMS 前必须先获取 Token,验证调用方身份 |
| Token 预过期刷新 |
提前刷新避免 Token 过期导致获取密钥失败 |
| volatile |
Token 多线程可见性保证 |
4.4 数据脱敏
| 知识点 |
说明 |
| 掩码规则 |
手机号(前3后4)、身份证(前3后4)、地址(前6位) |
| 场景区分 |
列表页脱敏显示,详情页解密完整展示 |
| 密文可识别 |
前缀标识防止重复加密 |
五、设计模式与架构原则
| 原则/模式 |
在 KMS 中的体现 |
| 职责分离 |
KMS 负责密钥管理,业务服务负责数据加解密 |
| 最小知识原则 |
业务服务不知道密钥从哪里来,只知道调 API 能拿到 |
| 缓存策略 |
本地内存缓存密钥,减少网络开销 |
| 优雅降级 |
加密失败返回原文(不阻断业务),但记录错误日志 |
| 向后兼容 |
密钥版本化,新版本不影响旧数据 |
| 防篡改 |
GCM 模式自带认证标签,密文被篡改会解密失败 |
六、与直接存储密钥的对比
| 维度 |
直接存储密钥 |
KMS 密钥管理 |
| 密钥存储 |
配置文件/环境变量/代码中 |
KMS 服务端(HSM 保护) |
| 访问控制 |
能访问配置的人都能看到密钥 |
必须通过 OAuth2 认证才能获取 |
| 密钥轮转 |
改配置重启所有服务 |
KMS 加版本,业务无感 |
| 审计追踪 |
无 |
KMS 记录每次密钥访问日志 |
| 安全边界 |
密钥在多个地方明文存在 |
密钥只在内存中短暂存在 |
| 泄露影响 |
代码仓库泄露 = 密钥泄露 |
代码泄露不影响,密钥在 KMS |
| 运维复杂度 |
低 |
中(需维护 KMS 服务) |
七、通用示例代码(完整演示)
7.1 最简化的 KMS 交互示意
/**
* 模拟完整的 KMS 交互流程.
*/
public class KmsDemo {
public static void main(String[] args) throws Exception {
// ===== 模拟 KMS 服务端 =====
// 实际中 KMS 是独立部署的服务,密钥存储在 HSM 中
// 这里仅为演示密钥获取流程
// ===== 业务服务侧 =====
// 1. 启动时只知道 KMS 地址和认证信息(从配置文件读取)
String kmsUrl = "https://kms.example.com/api/secret/get-secret-key";
String authUrl = "https://auth.example.com/oauth/token";
// 注意:这里没有任何数据加密密钥!
// 2. 需要加密时,先获取认证 Token
String accessToken = getAccessToken(authUrl, "client-id", "client-pwd");
// 3. 用 Token 从 KMS 获取数据密钥(HTTPS 加密传输)
SecretKeyCache keyCache = getSecretKeyFromKms(kmsUrl, accessToken, "1", "phone");
// keyCache 只存在于内存中!
// 4. 用内存中的密钥加密数据
String plainText = "1380...";
String cipherText = aesGcmEncrypt(plainText, keyCache);
System.out.println("密文: " + cipherText);
// 输出: ENC:a1b2c3d4...(存入数据库)
// 5. 解密时同样从内存获取密钥
String decrypted = aesGcmDecrypt(cipherText, keyCache);
System.out.println("明文: " + decrypted);
// 输出: 1380...
// 关键点:整个过程中密钥只在内存中,从未写入磁盘
}
// 模拟获取 OAuth2 Token
static String getAccessToken(String authUrl, String clientId, String clientPwd) {
// HTTP POST → authUrl/oauth/token
// 返回 access_token
return "mock-access-token";
}
// 模拟从 KMS 获取密钥
static SecretKeyCache getSecretKeyFromKms(String kmsUrl, String token,
String version, String type) {
// HTTP POST → kmsUrl
// Headers: Authorization: Bearer {token}
// Body: {"version": "1", "type": "phone"}
// 响应: {"secretKey": "Base64AESKey", "secretKeyIv": "Base64IV"}
return new SecretKeyCache("mockKeyBase64", "mockIvBase64");
}
// AES-GCM 加密
static String aesGcmEncrypt(String plainText, SecretKeyCache keyCache) throws Exception {
byte[] key = java.util.Base64.getDecoder().decode(keyCache.getSecretKey());
byte[] iv = java.util.Base64.getDecoder().decode(keyCache.getSecretKeyIv());
javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding");
javax.crypto.spec.GCMParameterSpec gcmSpec = new javax.crypto.spec.GCMParameterSpec(128, iv);
javax.crypto.spec.SecretKeySpec keySpec = new javax.crypto.spec.SecretKeySpec(key, "AES");
cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, keySpec, gcmSpec);
byte[] encrypted = cipher.doFinal(plainText.getBytes("UTF-8"));
return "ENC:" + java.util.Base64.getEncoder().encodeToString(encrypted);
}
// AES-GCM 解密
static String aesGcmDecrypt(String cipherText, SecretKeyCache keyCache) throws Exception {
String raw = cipherText.substring(4); // 去掉 "ENC:" 前缀
byte[] key = java.util.Base64.getDecoder().decode(keyCache.getSecretKey());
byte[] iv = java.util.Base64.getDecoder().decode(keyCache.getSecretKeyIv());
javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding");
javax.crypto.spec.GCMParameterSpec gcmSpec = new javax.crypto.spec.GCMParameterSpec(128, iv);
javax.crypto.spec.SecretKeySpec keySpec = new javax.crypto.spec.SecretKeySpec(key, "AES");
cipher.init(javax.crypto.Cipher.DECRYPT_MODE, keySpec, gcmSpec);
byte[] decoded = java.util.Base64.getDecoder().decode(raw);
byte[] decrypted = cipher.doFinal(decoded);
return new String(decrypted, "UTF-8");
}
static class SecretKeyCache {
private final String secretKey;
private final String secretKeyIv;
SecretKeyCache(String secretKey, String secretKeyIv) {
this.secretKey = secretKey;
this.secretKeyIv = secretKeyIv;
}
String getSecretKey() { return secretKey; }
String getSecretKeyIv() { return secretKeyIv; }
}
}
八、安全性总结
"密钥不落地"的含义:
❌ 密钥不在这些地方:
- 代码仓库(Git)
- 配置文件(application.yml)
- 配置中心(Nacos)
- 数据库
- 日志文件
- 环境变量(可选)
✅ 密钥只在这些地方:
- KMS 服务内部(HSM 硬件保护)
- HTTPS 传输通道中(加密传输)
- 业务服务的 JVM 内存中(ConcurrentHashMap,进程退出即消失)
攻击面分析
| 攻击场景 |
传统方案风险 |
KMS 方案防护 |
| 代码仓库泄露 |
密钥明文暴露 |
仓库中无密钥 |
| 配置中心被入侵 |
密钥明文暴露 |
配置中只有 KMS 地址 |
| 服务器被入侵 |
内存中有密钥 |
内存中有密钥(风险相同,但缩小了攻击面) |
| 数据库泄露 |
有密钥则可解密全部数据 |
无密钥,密文无法解密 |
| 内部人员 |
查配置即知密钥 |
需同时拥有 KMS 访问权限 |
| 密钥泄露后 |
历史数据全部可解密 |
轮转密钥,旧版本可废弃 |