为什么需要 KMS 和信封加密
Posted on 六 27 6月 2026 in Tech
| Abstract | 为什么需要 KMS 和信封加密 |
|---|---|
| Authors | Walter Fan |
| Category | Tech |
| Status | v0.1 |
| Updated | 2026-06-27 |
| License | CC-BY-NC-ND 4.0 |
为什么需要 KMS 和信封加密
做后端久了,总会遇到一个朴素而危险的问题:数据库里到底能不能放明文密码、Token、私钥、证书、API Key?
答案当然是不能。可工程里最麻烦的地方在于,“不能放明文”只是第一步。你把数据加密了,新的问题马上排队进门:加密密钥放哪里?谁能访问?怎么轮换?数据库泄露时损失多大?日志里会不会不小心打印出来?KMS 挂了系统怎么办?
我认为,KMS 和信封加密的价值不在于“看起来很安全”,而在于把密钥管理从一坨散落在配置文件、环境变量、脚本和数据库里的胶水,变成一个边界清楚、可审计、可轮换、可失败关闭的工程体系。
一句话:KMS 管主钥匙,业务系统管数据;信封加密让每份数据都有自己的小钥匙,而小钥匙再被主钥匙锁起来。
1. 最常见的误区:我已经加密了,所以安全了
很多团队第一次做敏感数据保护,会写出这样的方案:
- 生成一个 AES key。
- 把它放到配置文件或环境变量里。
- 写入数据库前用它加密。
- 读取数据库后用它解密。
比明文强吗?强。
够好吗?通常不够。
因为这套方案把问题从“数据明文暴露”改成了“密钥在哪里暴露”。如果数据库备份、应用配置、容器环境变量、CI 日志、运维脚本里任何一个地方泄露,攻击者拿到密文和密钥,就像拿到了保险柜和钥匙串。剩下的只是体力活。
更麻烦的是轮换。
如果全库都用同一个 key,一旦要换 key,就得把所有历史数据读出来、解密、再加密、再写回去。数据量小的时候像搬家,数据量大了就像半夜换城市供水管道:理论上可以,实际会把值班同学熬成熊猫。
所以,加密本身不是终点。密钥生命周期管理才是正菜。
2. KMS 到底解决什么问题
KMS,全称 Key Management Service,直译是密钥管理服务。它不是一个“万能加密按钮”,而是一个专门管理高价值密钥的系统。
你可以把它想成银行金库:
- 金库里保管主钥匙,不让主钥匙到处乱跑。
- 谁来用钥匙,要认证、授权、审计。
- 钥匙什么时候轮换,有策略和记录。
- 操作失败时,宁可不开门,也不临时发一把塑料钥匙。
在工程上,KMS 主要承担几类职责:
| 问题 | 没有 KMS 时的常见做法 | 有 KMS 后的做法 |
|---|---|---|
| 主密钥存放 | 配置文件、环境变量、数据库、脚本 | 存在独立的密钥管理系统或 HSM 中 |
| 访问控制 | 依赖应用自己的权限 | KMS 自己做认证、授权和审计 |
| 密钥轮换 | 应用自己实现,容易遗漏 | 通过 KMS 版本和策略管理 |
| 审计 | 很难知道谁什么时候用了 key | 每次关键操作都可记录 |
| 爆炸半径 | 一个 key 可能解开一大片数据 | 可以按用途、租户、环境或数据域拆分 |
这里有一个关键点:KMS 最好不要把主密钥明文交给业务系统。
业务系统可以请求 KMS 做加密、解密、签名、验签,或者包装、解包装某个数据密钥。但主密钥本身不应该在应用进程里散步,更不应该被写进日志。密钥一旦开始旅游,安全边界就开始漏风。
3. 只用 KMS 直接加密数据,为什么还不够
既然 KMS 这么好,能不能把每一段敏感数据都直接发给 KMS 加密?
可以,但通常不划算,也不总是合适。
原因有三点。
第一,KMS 是高价值服务,不适合承载所有大块数据的加解密流量。业务数据可能很大、访问频繁、延迟敏感,把每次读写都变成远程 KMS 调用,成本和延迟都不好看。
第二,很多 KMS 对单次加密数据大小有限制。它们更适合处理密钥、小块材料和加密操作的控制面,不适合当成通用数据加密管道。
第三,系统可靠性会被放大。每读一条数据都必须远程调用 KMS,KMS 抖一下,业务读路径也跟着哆嗦。
于是,信封加密就登场了。
4. 什么是信封加密
信封加密的英文是 Envelope Encryption。名字很形象:
- 真正的数据放进信里。
- 这封信,用一把一次性或短生命周期的小钥匙锁上。
- 小钥匙再放进信封。
- 信封用金库里的主钥匙封起来。
在密码学术语里,通常会有两类 key:
| 名称 | 常见缩写 | 作用 |
|---|---|---|
| Data Encryption Key | DEK | 直接加密业务数据 |
| Key Encryption Key | KEK | 加密或包装 DEK,本身由 KMS 管理 |
写入数据时,大致流程是:
- 业务系统为这份数据生成一个随机 DEK。
- 用 DEK 加密敏感数据,得到 ciphertext。
- 把 DEK 交给 KMS,用 KEK 包装成 wrapped DEK。
- 数据库存 ciphertext、wrapped DEK 和必要的 key metadata。
- 明文数据、明文 DEK 不落库,不写日志,用完就丢。
读取数据时,流程反过来:
- 从数据库读出 ciphertext 和 wrapped DEK。
- 调用 KMS,把 wrapped DEK 解包装成临时明文 DEK。
- 用 DEK 解密 ciphertext。
- 返回明文给已授权的调用方。
- 明文 DEK 只在内存里短暂停留,用完清理。
数据库里看到的不是“秘密”,而是被锁好的信件和被金库封好的小钥匙。
5. 为什么这套设计更稳
5.1 数据库泄露,不等于秘密泄露
如果攻击者只拿到数据库,他能看到 ciphertext 和 wrapped DEK,但拿不到 KMS 里的 KEK。没有 KEK,wrapped DEK 解不开;没有 DEK,ciphertext 也解不开。
这就是分层防御的意义:不要假设某一层永远不失守。数据库会有备份,会有只读账号,会被导出,会被误传。好的设计要承认现实,然后让单点泄露无法直接变成事故。
5.2 主密钥轮换更便宜
如果数据直接用 KEK 加密,换 KEK 就要重加密所有数据。
信封加密下,数据由 DEK 加密,KEK 只包装 DEK。换 KEK 时,通常只需要:
- 用旧 KEK 解开 wrapped DEK。
- 用新 KEK 重新包装同一个 DEK。
- 更新 wrapped DEK 和 key version metadata。
业务密文不用动。这就像换保险柜,不用把仓库里每个箱子拆开重装一遍。
5.3 可以更细地控制爆炸半径
一个全局 key 解全部数据,是最省事的设计,也是最吓人的设计。
信封加密允许你按不同维度生成 DEK:每条记录、每个对象、每个租户、每个文件、每个数据版本。怎么拆,取决于你的性能、成本和隔离要求。
拆得越细,管理成本越高,但单个 key 失控时的影响越小。工程不是背诵“最佳实践”,而是在约束里找合理边界。
5.4 审计和权限更清楚
谁能解包装 DEK?谁能创建 KEK?谁能禁用旧 key?谁在什么时候访问过 KMS?
这些问题如果散在应用配置和脚本里,最后多半靠人肉考古。放到 KMS 边界里,至少可以把权限、审计、告警、轮换策略集中起来。
安全系统最怕“没人说得清”。KMS 的一个现实价值,就是让关键问题有地方问,有日志查,有策略改。
6. 一个最小可用的落地模型
不要一上来就搞成论文。多数系统先把下面这个模型做扎实,就已经比“配置文件里放一把万能钥匙”强很多。
写入路径
- 校验调用方权限。
- 生成随机 DEK,常见选择是 256-bit key。
- 使用 AEAD 算法加密数据,例如 AES-GCM 或 ChaCha20-Poly1305。
- 调用 KMS,用指定 KEK 包装 DEK。
- 持久化 ciphertext、wrapped DEK、algorithm、key id、key version 等 metadata。
- 明文 secret 和明文 DEK 不落库、不进日志、不进异常消息。
读取路径
- 校验调用方权限。
- 读取 ciphertext、wrapped DEK 和 key metadata。
- 调用 KMS 解包装 DEK。
- 用 DEK 解密并校验认证标签。
- 失败就失败关闭,不回退到明文、不尝试弱算法、不吞异常。
轮换路径
- 在 KMS 中准备新 KEK 或新 key version。
- 新写入数据使用新的 KEK metadata 包装 DEK。
- 历史数据逐步 rewrap:解开旧 wrapped DEK,再用新 KEK 包装。
- 确认没有数据引用旧 KEK 后,再按策略禁用或销毁旧 key。
这套模型的重点不是“术语齐全”,而是边界清楚:数据加密在业务侧,主密钥托管在 KMS,数据库只保存密文和被包装过的数据密钥。
7. Python 关键代码示例
下面给一个最小示例,演示信封加密的核心动作:生成 DEK、用 AEAD 加密数据、调用 KMS 包装 DEK、读取时再解包装 DEK。
先说清楚边界:这里的 DemoKMS 只用于本地演示和单元测试。生产环境不要把 KEK 放在应用进程里,也不要自己在业务服务里实现“本地 KMS”。真实系统里,KMSClient 应该替换成云 KMS、HSM、Vault Transit 或公司统一密钥管理系统的 SDK。
依赖:
pip install cryptography
7.1 定义 KMS 接口和密文信封
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Protocol
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
class KMSClient(Protocol):
def wrap_key(self, plaintext_dek: bytes, key_id: str, key_version: str) -> bytes:
"""Use KEK in KMS to wrap a plaintext DEK."""
def unwrap_key(self, wrapped_dek: bytes, key_id: str, key_version: str) -> bytes:
"""Use KEK in KMS to recover a plaintext DEK."""
@dataclass(frozen=True)
class EnvelopeRecord:
algorithm: str
key_id: str
key_version: str
nonce: bytes
ciphertext: bytes
wrapped_dek: bytes
EnvelopeRecord 对应数据库里要保存的最小信息:密文、nonce、wrapped DEK、算法、key id 和 key version。注意,这里没有保存明文 DEK,也没有保存 KEK。
7.2 写入:seal secret
def seal_secret(
plaintext: bytes,
kms: KMSClient,
key_id: str,
key_version: str,
aad: bytes,
) -> EnvelopeRecord:
# 256-bit DEK. In production, use a CSPRNG from a trusted library/runtime.
plaintext_dek = os.urandom(32)
nonce = os.urandom(12) # 96-bit nonce is the common AES-GCM choice.
try:
aesgcm = AESGCM(plaintext_dek)
ciphertext = aesgcm.encrypt(nonce, plaintext, aad)
wrapped_dek = kms.wrap_key(plaintext_dek, key_id, key_version)
return EnvelopeRecord(
algorithm="AES-256-GCM",
key_id=key_id,
key_version=key_version,
nonce=nonce,
ciphertext=ciphertext,
wrapped_dek=wrapped_dek,
)
finally:
# Python bytes cannot be reliably zeroized because objects may be copied.
# For high-assurance systems, use platform/KMS features and avoid keeping
# plaintext keys in memory longer than necessary.
del plaintext_dek
这里的 aad 是 Additional Authenticated Data,可以放不敏感但必须绑定的上下文,例如 record id、tenant id、数据类型、版本号等。AAD 不会被加密,但会参与认证校验。读取时必须传入同样的 AAD,否则解密失败。
7.3 读取:open secret
def open_secret(record: EnvelopeRecord, kms: KMSClient, aad: bytes) -> bytes:
if record.algorithm != "AES-256-GCM":
raise ValueError(f"unsupported algorithm: {record.algorithm}")
plaintext_dek = kms.unwrap_key(
record.wrapped_dek,
record.key_id,
record.key_version,
)
try:
aesgcm = AESGCM(plaintext_dek)
return aesgcm.decrypt(record.nonce, record.ciphertext, aad)
finally:
del plaintext_dek
如果 KMS 解包装失败,或者 AES-GCM 认证标签校验失败,这段代码会直接抛异常。调用方应该记录脱敏后的错误、告警、重试或返回通用错误,但不要回退到明文、默认 key 或旧算法。
7.4 测试用 DemoKMS
再次强调:下面这个 DemoKMS 只用于让示例能跑起来,不是生产做法。
class DemoKMS:
def __init__(self) -> None:
self._keks: dict[tuple[str, str], bytes] = {}
def create_kek(self, key_id: str, key_version: str) -> None:
self._keks[(key_id, key_version)] = os.urandom(32)
def wrap_key(self, plaintext_dek: bytes, key_id: str, key_version: str) -> bytes:
kek = self._keks[(key_id, key_version)]
nonce = os.urandom(12)
wrapped = AESGCM(kek).encrypt(nonce, plaintext_dek, b"dek-wrap")
return nonce + wrapped
def unwrap_key(self, wrapped_dek: bytes, key_id: str, key_version: str) -> bytes:
kek = self._keks[(key_id, key_version)]
nonce = wrapped_dek[:12]
ciphertext = wrapped_dek[12:]
return AESGCM(kek).decrypt(nonce, ciphertext, b"dek-wrap")
7.5 跑一个完整例子
def main() -> None:
kms = DemoKMS()
kms.create_kek("customer-secret-kek", "v1")
aad = b"record_type=api_token;record_id=123"
record = seal_secret(
plaintext=b"sk_live_not_a_real_secret",
kms=kms,
key_id="customer-secret-kek",
key_version="v1",
aad=aad,
)
recovered = open_secret(record, kms, aad)
assert recovered == b"sk_live_not_a_real_secret"
# AAD 被篡改时,AES-GCM 会校验失败。
try:
open_secret(record, kms, b"record_type=api_token;record_id=456")
except Exception:
print("decrypt failed as expected")
if __name__ == "__main__":
main()
这段代码的重点不是 DemoKMS,而是几个工程约束:
- 每份数据使用独立随机 DEK。
- 业务数据由 DEK 加密。
- DEK 被 KMS 中的 KEK 包装后再保存。
- 数据库只保存密文和 wrapped DEK。
- 解密失败就失败,不做不安全回退。
7.6 KEK 轮换时的 rewrap 示例
主密钥轮换时,不需要重新加密业务密文,只要重新包装 DEK。
def rewrap_dek(
record: EnvelopeRecord,
kms: KMSClient,
new_key_id: str,
new_key_version: str,
) -> EnvelopeRecord:
plaintext_dek = kms.unwrap_key(
record.wrapped_dek,
record.key_id,
record.key_version,
)
try:
new_wrapped_dek = kms.wrap_key(
plaintext_dek,
new_key_id,
new_key_version,
)
return EnvelopeRecord(
algorithm=record.algorithm,
key_id=new_key_id,
key_version=new_key_version,
nonce=record.nonce,
ciphertext=record.ciphertext,
wrapped_dek=new_wrapped_dek,
)
finally:
del plaintext_dek
可以看到,ciphertext 没有变化。变的只是 wrapped_dek 和 key metadata。这就是信封加密在轮换时省心的地方。
8. 容易踩的坑
坑一:把 KEK 放进应用配置
如果 KEK 放在配置文件、环境变量或数据库里,再喊 KMS 就有点像把保险柜门拆了,外面贴一张“高级安防”的标签。
KMS 的核心价值之一,是让 KEK 不离开它自己的安全边界。
坑二:把 wrapped DEK 当成普通字段随便打印
wrapped DEK 不是明文密钥,但它仍然是敏感材料。日志、错误消息、监控标签里都不应该出现完整值。
别小看日志。很多事故不是黑客拍电影式入侵,而是某个 DEBUG 日志在凌晨三点诚实得过了头。
坑三:GCM nonce 重复
如果使用 AES-GCM,nonce 不能在同一把 key 下重复。重复 nonce 不是“小瑕疵”,而是会严重破坏安全性。
简单做法是:每次加密生成新的随机 nonce,并把 nonce 和认证标签作为密文信封的一部分保存。不要自己发明半吊子的 nonce 规则。
坑四:KMS 失败时偷偷降级
有些系统为了“可用性”,会在 KMS 解密失败时尝试备用明文、旧算法、默认 key。
这基本等于给攻击者留了后门。安全关键路径要 fail closed:失败就失败,告警、重试、熔断、降级读缓存都可以讨论,但不能回退到不安全路径。
坑五:只做加密,不做授权
加密不是授权。能解密,不代表应该解密。
读取敏感数据前,仍然要检查调用方身份、作用域、租户边界、数据归属和业务权限。否则就是给错误的人发了一把正确的钥匙。
9. 什么时候不需要信封加密
不是所有字段都要上这套重装备。
如果只是低敏感度、短生命周期、可公开或可重新生成的数据,普通数据库加密、磁盘加密、访问控制和日志脱敏可能已经够用。
信封加密适合这些场景:
- 数据本身高度敏感,例如密码、Token、私钥、证书、支付或身份相关材料。
- 数据需要长期保存,不能简单丢弃。
- 数据库、备份、分析链路、运维访问存在泄露风险。
- 有合规、审计、轮换、租户隔离要求。
- 需要把密钥管理职责从业务代码中拆出去。
如果你的系统只是保存用户头像 URL,硬套信封加密,多半是给自己添堵。安全设计不是把所有门都焊死,而是知道哪扇门后面真有值钱东西。
10. 设计检查清单
如果你正在设计一个保存敏感数据的服务,可以用下面这张表先自查。
| 检查项 | 建议 |
|---|---|
| 明文数据是否落库 | 不落库 |
| 明文 DEK 是否持久化 | 不持久化 |
| KEK 是否离开 KMS | 不离开 |
| 日志是否可能包含 secret、DEK、wrapped DEK | 默认脱敏,必要时禁止打印 |
| 加密算法是否是 AEAD | 优先使用成熟库里的 AES-GCM 或 ChaCha20-Poly1305 |
| nonce/IV 是否正确生成 | 每次加密唯一,避免复用 |
| key metadata 是否保存 | 保存 key id、version、algorithm,便于解密和轮换 |
| KMS 失败时怎么办 | fail closed,并配套重试、告警和恢复流程 |
| 是否有 rewrap 策略 | 支持主密钥轮换,不重加密业务数据 |
| 是否有访问审计 | 记录关键 KMS 操作和敏感数据访问 |
总结
KMS 和信封加密解决的不是“如何调用一个加密函数”这么小的问题,而是一个更现实的问题:当系统越来越大、数据越来越敏感、人越来越多、事故越来越难预测时,怎样让密钥不失控。
我的经验是,安全设计最怕两个极端:一个是“先明文跑起来,以后再说”;另一个是“照着安全名词堆满架构图”。前者容易欠债,后者容易自嗨。
比较靠谱的做法是:先明确威胁模型,再把边界画清楚。数据库可以被拿走,应用日志可能出错,配置可能泄露,KMS 也可能短暂不可用。设计不是假装这些事不会发生,而是让它们发生时不要一锅端。
一句话收尾:KMS 负责看住主钥匙,信封加密负责把风险拆小;两者合起来,才是能在生产环境里站得住的敏感数据保护方案。
明天可以做的 5 件事
- 列出系统里所有敏感字段,按风险分级。
- 检查是否有密钥、Token、secret 出现在配置、日志、脚本或测试数据里。
- 确认高敏感数据是否使用 AEAD 加密,而不是自制算法。
- 设计 DEK/KEK 分层和 key metadata,不要只存一段裸密文。
- 写清楚 KMS 不可用、key 轮换、解密失败时的系统行为。
如果你只能先做一件事,就从日志和配置查起。很多安全事故,不是输给密码学,而是输给了“我以为没人会看到这个文件”。
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。