密钥轮换实战:KMS 信封加密配置指南

适用场景

密钥轮换指加密密钥在其生命周期内定期更换,并在更换后仍能解密历史数据。只要系统中存在需要长期保存的敏感字段(身份证号、银行卡号、会话令牌、API 密钥),密钥就应当具备轮换能力:员工离职、代码仓库泄露、合规审计都会要求「密钥可在不中断业务的前提下更换」。

直接对业务数据做加解密容易踩两个坑:一是把密钥硬编码在代码或配置文件中,一旦泄露等于全部数据裸奔;二是轮换时无法解密旧数据,导致只能停服迁移。信封加密与托管密钥服务(KMS)正是为解决这两个问题设计的。

前置条件

  • 具备云厂商 KMS 权限(本文示例以兼容 AWS KMS API 的接口为准,阿里云 KMS 的密钥类型与权限模型可对应替换),或已部署 HashiCorp Vault 的 transit 引擎;
  • Python 3.8 以上环境,安装 boto3 与 cryptography;
  • 业务数据库具备可加列的变更权限,用于记录密钥版本;
  • 已明确合规要求中的密钥轮换周期(常见为 90 天或 1 年)。
pip3 install boto3 cryptography

原理说明

信封加密(Envelope Encryption)把密钥分成两层:

  • 主密钥(KEK / CMK):由 KMS 托管,永不离开 KMS 的硬件安全模块,应用只能请求它执行加密、解密与生成数据密钥;
  • 数据密钥(DEK):由 KMS 现用现生成,用于实际加密业务数据。KMS 同时返回明文 DEK 与用主密钥加密后的密文 DEK。

标准流程是:应用拿到明文 DEK 后,用 AES-256-GCM 加密业务数据,输入随机 IV,得到密文与认证标签;随后立即在内存中丢弃明文 DEK,只把密文 DEK 与密文数据一起持久化。解密时先调用 KMS 把密文 DEK 还原为明文 DEK,再解密数据。

这套结构带来三个关键收益:主密钥不落地,泄露风险收敛到 KMS 的访问控制;轮换只发生在主密钥层,历史数据的密文 DEK 中已经内嵌了密钥版本,无需重新加密即可解密;每一次解密都会在 KMS 侧留痕,可被审计与告警。

操作步骤

1. 创建主密钥并开启自动轮换

KEY_ID=$(aws kms create-key --description "app-dek-kek" \
  --query KeyMetadata.KeyId --output text)
aws kms create-alias --alias-name alias/app-kek --target-key-id "$KEY_ID"

# 开启年度自动轮换,KMS 会保留旧版本密钥供解密
aws kms enable-key-rotation --key-id "$KEY_ID" --rotation-period-in-days 365

# 验证轮换状态
aws kms get-key-rotation-status --key-id alias/app-kek

关键点:–rotation-period-in-days 365 表示自动轮换周期,轮换只影响后续新生成的密钥版本,历史版本仍可用于解密。alias/app-kek 让代码引用别名而非密钥 ID,未来换密钥无需改代码。

2. 实现信封加解密

import os, json, base64, boto3
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

kms = boto3.client("kms", region_name="cn-hangzhou")
KEY_ID = "alias/app-kek"

def encrypt(plaintext: bytes) -> str:
    """生成数据密钥 -> AES-256-GCM 加密 -> 连同密文密钥一起返回"""
    dk = kms.generate_data_key(KeyId=KEY_ID, KeySpec="AES_256")
    dek, blob = dk["Plaintext"], dk["CiphertextBlob"]
    iv = os.urandom(12)
    ct = AESGCM(dek).encrypt(iv, plaintext, None)
    del dek                      # 尽快清除明文密钥引用
    return json.dumps({
        "v": 1,
        "key": base64.b64encode(blob).decode(),
        "iv":  base64.b64encode(iv).decode(),
        "ct":  base64.b64encode(ct).decode(),
    })

def decrypt(token: str) -> bytes:
    o = json.loads(token)
    dek = kms.decrypt(CiphertextBlob=base64.b64decode(o["key"]))["Plaintext"]
    return AESGCM(dek).decrypt(base64.b64decode(o["iv"]),
                               base64.b64decode(o["ct"]), None)

字段 v 用于标记密文格式版本,方便未来升级算法时做灰度解析;iv 必须是每次加密重新生成的随机值,绝不可复用,否则 GCM 模式下会直接丧失机密性。

3. 数据密钥的缓存与内存保护

KMS 调用有延迟与配额限制,高频加解密需要在内存中缓存明文 DEK。缓存必须落在 tmpfs 上并限制权限,重启即清空,避免明文密钥写到磁盘:

mkdir -p /dev/shm/dek_cache
chmod 700 /dev/shm/dek_cache
export DEK_CACHE=/dev/shm/dek_cache

# 关键约束:明文 DEK 不写日志、不进异常堆栈、不做序列化输出
grep -rn "Plaintext" /var/log/app/*.log && echo "存在密钥泄漏风险"

4. 配置最小权限策略

只授权业务实际需要的三个动作,并限定密钥别名与调用来源:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowEnvelopeOps",
      "Effect": "Allow",
      "Action": [
        "kms:Encrypt",
        "kms:Decrypt",
        "kms:GenerateDataKey"
      ],
      "Resource": "arn:aws:kms:cn-hangzhou:1234567890:key/xxxxxxxx-xxxx-xxxx",
      "Condition": {
        "StringEquals": {
          "kms:ViaService": "s3.cn-hangzhou.amazonaws.com"
        }
      }
    }
  ]
}

切勿授予 kms:*,尤其要显式排除 kms:ScheduleKeyDeletion 与 kms:DisableKey。这两个动作一旦被误用,会导致全部历史数据永久无法解密。

5. 轮换与历史数据重加密

主密钥自动轮换后,新写入的数据自动使用新版本密钥,旧数据仍可正常读取。若合规要求对存量数据也做重加密,用批量任务逐条解密再加密,并在表中记录密钥版本:

def reencrypt_batch(conn, table: str, column: str, batch: int = 500) -> int:
    total = 0
    while True:
        rows = conn.execute(
            "SELECT id, {col} FROM {tbl} WHERE key_v < %s LIMIT %s".format(
                col=column, tbl=table), (CURRENT_KEY_VERSION, batch)
        ).fetchall()
        if not rows:
            break
        for rid, token in rows:
            new_token = encrypt(decrypt(token))       # 解旧 -> 加新
            conn.execute(
                "UPDATE {tbl} SET {col}=%s, key_v=%s WHERE id=%s".format(
                    col=column, tbl=table),
                (new_token, CURRENT_KEY_VERSION, rid))
        conn.commit()
        total += len(rows)
        print("已重加密 %d 条" % total)
    return total

务必按主键区间分批提交并记录水位,任务中断后可续跑;不要在单个事务里完成全表重加密,否则会长时间持锁并放大回滚成本。

6. 审计告警

KMS 的每次密钥操作都会写入审计日志。需要重点监控两类异常:非业务时段的 Decrypt 调用,以及单个身份短时间内的高频解密。同时为密钥配置过期告警,避免自动轮换被意外关闭而无人发现。

7. 无 KMS 环境的替代方案

自建环境可用 HashiCorp Vault 的 transit 引擎获得等价的「密钥不落地」能力:

vault secrets enable transit
vault write -f transit/keys/app-key type=aes256-gcm96

vault write transit/encrypt/app-key plaintext=$(base64 <<< "sensitive")

# 轮换密钥版本
vault write -f transit/keys/app-key/rotate

# 不暴露明文,直接把旧密文迁移到新密钥版本
vault write transit/rewrap/app-key ciphertext="vault:v1:xxxxx"

transit/rewrap 是信封加密之外值得记住的能力:它能在不把明文暴露给应用的前提下完成密文版本迁移。

配置验证

  • 往返测试:执行 decrypt(encrypt(b”hello kms”)),结果应与原文完全一致;
  • 轮换状态:aws kms get-key-rotation-status –key-id alias/app-kek 返回 KeyRotationEnabled: true,并核对下次轮换日期;
  • 历史数据可解:记录一条轮换前写入的密文,在轮换执行后重新解密,应成功返回原文,验证旧密钥版本被保留;
  • 权限边界:用无 Decrypt 权限的凭据调用解密,应返回 AccessDeniedException;用业务凭据尝试 kms:ScheduleKeyDeletion,同样应被拒绝;
  • 密钥不落地:对应用日志与堆转储文件全文检索密钥相关字段名,确认无明文密钥输出。
aws kms decrypt --ciphertext-blob fileb://blob.bin
# 预期(无权限时): AccessDeniedException: not authorized to perform: kms:Decrypt

常见问题

FAQ 1:主密钥轮换后,历史数据需要全部重新加密吗?

不需要。密文数据密钥中已经内嵌了生成它的主密钥版本信息,KMS 在解密时会自动匹配对应版本,因此旧数据无需任何改动即可继续读取。只有两种情况下才需要主动重加密:一是合规条款明确要求存量数据也必须使用新密钥;二是从 AES-128 升级到 AES-256 这类算法层面的变更,此时需要走步骤 5 的批量重加密流程。

FAQ 2:既然有 KMS,为什么不能把明文数据密钥缓存到磁盘?

信封加密的安全模型建立在「主密钥永不离开 KMS」之上,明文数据密钥是这套模型里唯一暴露在应用内存中的环节。一旦被写入磁盘、日志或交换分区,攻击者只要拿到该文件,就等于绕过了 KMS 的全部访问控制与审计能力——KMS 反而失去了意义。所以明文 DEK 的缓存必须限制在内存或 tmpfs 中,并缩短存活时间。

FAQ 3:轮换期间业务会受影响吗?

不会,前提是不手动禁用旧密钥版本。轮换是「新增一个版本」,不是「替换所有版本」;新数据用新版本加密,历史数据继续用旧版本解密。真正会造成业务中断的操作是禁用或计划删除密钥,这两个动作都应纳入高危权限管控。压力主要来自重加密任务本身,建议放在业务低峰期并限制并发,避免数据库写入被打满。

总结

密钥轮换的落地要点有四条:用信封加密把主密钥留在 KMS、把数据密钥随数据一起存储;用别名而非密钥 ID 引用主密钥,并开启自动轮换;权限只授予 Encrypt、Decrypt、GenerateDataKey 三个动作,显式拒绝禁用与删除;存量数据重加密必须分批提交并保留水位。做到这四点,密钥更换就从一次高风险变更,退化为一次普通的配置调整。