为什么需要 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. 最常见的误区:我已经加密了,所以安全了

很多团队第一次做敏感数据保护,会写出这样的方案:

  1. 生成一个 AES key。
  2. 把它放到配置文件或环境变量里。
  3. 写入数据库前用它加密。
  4. 读取数据库后用它解密。

比明文强吗?强。

够好吗?通常不够。

因为这套方案把问题从“数据明文暴露”改成了“密钥在哪里暴露”。如果数据库备份、应用配置、容器环境变量、CI 日志、运维脚本里任何一个地方泄露,攻击者拿到密文和密钥,就像拿到了保险柜和钥匙串。剩下的只是体力活。

更麻烦的是轮换。

如果全库都用同一个 key,一旦要换 key,就得把所有历史数据读出来、解密、再加密、再写回去。数据量小的时候像搬家,数据量大了就像半夜换城市供水管道:理论上可以,实际会把值班同学熬成熊猫。

所以,加密本身不是终点。密钥生命周期管理才是正菜。


2. KMS 到底解决什么问题

KMS,全称 Key Management Service,直译是密钥管理服务。它不是一个“万能加密按钮”,而是一个专门管理高价值密钥的系统。

你可以把它想成银行金库:

  1. 金库里保管主钥匙,不让主钥匙到处乱跑。
  2. 谁来用钥匙,要认证、授权、审计。
  3. 钥匙什么时候轮换,有策略和记录。
  4. 操作失败时,宁可不开门,也不临时发一把塑料钥匙。

在工程上,KMS 主要承担几类职责:

问题 没有 KMS 时的常见做法 有 KMS 后的做法
主密钥存放 配置文件、环境变量、数据库、脚本 存在独立的密钥管理系统或 HSM 中
访问控制 依赖应用自己的权限 KMS 自己做认证、授权和审计
密钥轮换 应用自己实现,容易遗漏 通过 KMS 版本和策略管理
审计 很难知道谁什么时候用了 key 每次关键操作都可记录
爆炸半径 一个 key 可能解开一大片数据 可以按用途、租户、环境或数据域拆分

这里有一个关键点:KMS 最好不要把主密钥明文交给业务系统。

业务系统可以请求 KMS 做加密、解密、签名、验签,或者包装、解包装某个数据密钥。但主密钥本身不应该在应用进程里散步,更不应该被写进日志。密钥一旦开始旅游,安全边界就开始漏风。


3. 只用 KMS 直接加密数据,为什么还不够

既然 KMS 这么好,能不能把每一段敏感数据都直接发给 KMS 加密?

可以,但通常不划算,也不总是合适。

原因有三点。

第一,KMS 是高价值服务,不适合承载所有大块数据的加解密流量。业务数据可能很大、访问频繁、延迟敏感,把每次读写都变成远程 KMS 调用,成本和延迟都不好看。

第二,很多 KMS 对单次加密数据大小有限制。它们更适合处理密钥、小块材料和加密操作的控制面,不适合当成通用数据加密管道。

第三,系统可靠性会被放大。每读一条数据都必须远程调用 KMS,KMS 抖一下,业务读路径也跟着哆嗦。

于是,信封加密就登场了。


4. 什么是信封加密

信封加密的英文是 Envelope Encryption。名字很形象:

  1. 真正的数据放进信里。
  2. 这封信,用一把一次性或短生命周期的小钥匙锁上。
  3. 小钥匙再放进信封。
  4. 信封用金库里的主钥匙封起来。

在密码学术语里,通常会有两类 key:

名称 常见缩写 作用
Data Encryption Key DEK 直接加密业务数据
Key Encryption Key KEK 加密或包装 DEK,本身由 KMS 管理

写入数据时,大致流程是:

  1. 业务系统为这份数据生成一个随机 DEK。
  2. 用 DEK 加密敏感数据,得到 ciphertext。
  3. 把 DEK 交给 KMS,用 KEK 包装成 wrapped DEK。
  4. 数据库存 ciphertext、wrapped DEK 和必要的 key metadata。
  5. 明文数据、明文 DEK 不落库,不写日志,用完就丢。

读取数据时,流程反过来:

  1. 从数据库读出 ciphertext 和 wrapped DEK。
  2. 调用 KMS,把 wrapped DEK 解包装成临时明文 DEK。
  3. 用 DEK 解密 ciphertext。
  4. 返回明文给已授权的调用方。
  5. 明文 DEK 只在内存里短暂停留,用完清理。

数据库里看到的不是“秘密”,而是被锁好的信件和被金库封好的小钥匙。


5. 为什么这套设计更稳

5.1 数据库泄露,不等于秘密泄露

如果攻击者只拿到数据库,他能看到 ciphertext 和 wrapped DEK,但拿不到 KMS 里的 KEK。没有 KEK,wrapped DEK 解不开;没有 DEK,ciphertext 也解不开。

这就是分层防御的意义:不要假设某一层永远不失守。数据库会有备份,会有只读账号,会被导出,会被误传。好的设计要承认现实,然后让单点泄露无法直接变成事故。

5.2 主密钥轮换更便宜

如果数据直接用 KEK 加密,换 KEK 就要重加密所有数据。

信封加密下,数据由 DEK 加密,KEK 只包装 DEK。换 KEK 时,通常只需要:

  1. 用旧 KEK 解开 wrapped DEK。
  2. 用新 KEK 重新包装同一个 DEK。
  3. 更新 wrapped DEK 和 key version metadata。

业务密文不用动。这就像换保险柜,不用把仓库里每个箱子拆开重装一遍。

5.3 可以更细地控制爆炸半径

一个全局 key 解全部数据,是最省事的设计,也是最吓人的设计。

信封加密允许你按不同维度生成 DEK:每条记录、每个对象、每个租户、每个文件、每个数据版本。怎么拆,取决于你的性能、成本和隔离要求。

拆得越细,管理成本越高,但单个 key 失控时的影响越小。工程不是背诵“最佳实践”,而是在约束里找合理边界。

5.4 审计和权限更清楚

谁能解包装 DEK?谁能创建 KEK?谁能禁用旧 key?谁在什么时候访问过 KMS?

这些问题如果散在应用配置和脚本里,最后多半靠人肉考古。放到 KMS 边界里,至少可以把权限、审计、告警、轮换策略集中起来。

安全系统最怕“没人说得清”。KMS 的一个现实价值,就是让关键问题有地方问,有日志查,有策略改。


6. 一个最小可用的落地模型

不要一上来就搞成论文。多数系统先把下面这个模型做扎实,就已经比“配置文件里放一把万能钥匙”强很多。

写入路径

  1. 校验调用方权限。
  2. 生成随机 DEK,常见选择是 256-bit key。
  3. 使用 AEAD 算法加密数据,例如 AES-GCM 或 ChaCha20-Poly1305。
  4. 调用 KMS,用指定 KEK 包装 DEK。
  5. 持久化 ciphertext、wrapped DEK、algorithm、key id、key version 等 metadata。
  6. 明文 secret 和明文 DEK 不落库、不进日志、不进异常消息。

读取路径

  1. 校验调用方权限。
  2. 读取 ciphertext、wrapped DEK 和 key metadata。
  3. 调用 KMS 解包装 DEK。
  4. 用 DEK 解密并校验认证标签。
  5. 失败就失败关闭,不回退到明文、不尝试弱算法、不吞异常。

轮换路径

  1. 在 KMS 中准备新 KEK 或新 key version。
  2. 新写入数据使用新的 KEK metadata 包装 DEK。
  3. 历史数据逐步 rewrap:解开旧 wrapped DEK,再用新 KEK 包装。
  4. 确认没有数据引用旧 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,而是几个工程约束:

  1. 每份数据使用独立随机 DEK。
  2. 业务数据由 DEK 加密。
  3. DEK 被 KMS 中的 KEK 包装后再保存。
  4. 数据库只保存密文和 wrapped DEK。
  5. 解密失败就失败,不做不安全回退。

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. 什么时候不需要信封加密

不是所有字段都要上这套重装备。

如果只是低敏感度、短生命周期、可公开或可重新生成的数据,普通数据库加密、磁盘加密、访问控制和日志脱敏可能已经够用。

信封加密适合这些场景:

  1. 数据本身高度敏感,例如密码、Token、私钥、证书、支付或身份相关材料。
  2. 数据需要长期保存,不能简单丢弃。
  3. 数据库、备份、分析链路、运维访问存在泄露风险。
  4. 有合规、审计、轮换、租户隔离要求。
  5. 需要把密钥管理职责从业务代码中拆出去。

如果你的系统只是保存用户头像 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 件事

  1. 列出系统里所有敏感字段,按风险分级。
  2. 检查是否有密钥、Token、secret 出现在配置、日志、脚本或测试数据里。
  3. 确认高敏感数据是否使用 AEAD 加密,而不是自制算法。
  4. 设计 DEK/KEK 分层和 key metadata,不要只存一段裸密文。
  5. 写清楚 KMS 不可用、key 轮换、解密失败时的系统行为。

如果你只能先做一件事,就从日志和配置查起。很多安全事故,不是输给密码学,而是输给了“我以为没人会看到这个文件”。


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。