从 AWS KMS 到用户私钥托管:把加密这条链路一次讲清楚
Posted on 三 22 4月 2026 in Journal
| Abstract | 从 AWS KMS 到用户私钥托管:把加密这条链路一次讲清楚 |
|---|---|
| Authors | Walter Fan |
| Category | Journal |
| Status | v1.0 |
| Updated | 2026-04-22 |
| License | CC-BY-NC-ND 4.0 |
从 AWS KMS 到用户私钥托管:把加密这条链路一次讲清楚
短大纲
- 先把
KMS key、data key、业务数据这三个角色分开 - 讲明白 KMS 和 Secrets Manager 到底各管什么
- 把加密、存储、解密这条链路从头走一遍
- 落到“为特定用户托管私钥”这个场景,给出表结构、IAM 和代码样例
- 最后收一份容易照着做、也容易避坑的 CheckList
一、很多人不是不会用 KMS,而是脑子里少了一张总图
第一次碰 AWS KMS,最容易陷进去的不是 API,而是概念。
脑子里总会连环冒出几个问题:KMS key 到底在哪儿,data key 为什么要多加这一层,encrypted data key 存哪儿,调用 KMS Decrypt 时到底是谁在解密,如果我要替某个用户托管一份私钥,锅又该由谁来背。
这些问题单看都不难,串起来就容易打结。安全设计里最怕的,不是复杂,而是看着简单,于是想当然。一想当然,明文 key 就进日志了,EncryptionContext 就乱填了,数据库里就多出一列“先存着以后再说”的敏感字段。后面想补救,往往比重写还贵。
一句话先把结论压住:
AWS KMS 负责保护主密钥,并执行受控的密码学操作;你的应用负责用 data key 加密真正的业务数据,并保存业务密文和 encrypted data key。KMS key 本体不会交给你。
这件事,在 AWS 官方的 data keys 文档 和 Decrypt API 文档 里其实都说得很清楚。只是文档是分散的,工程问题是连起来的。
二、先把三个角色认清:谁是主角,谁是临时工
这套模型里,至少有三层东西,别混。
1. KMS key
这是主密钥,长期存在,由 AWS KMS 管理。你可以授权它、轮换它、审计它,但不能把它像配置文件那样下载出来塞进代码。
2. data key
这是临时对称密钥,真正拿来加密你的业务数据。调用 GenerateDataKey 之后,KMS 会返回两份东西:
- 一份明文
Plaintext - 一份被 KMS key 加密过的
CiphertextBlob
前者给你“当场办事”,后者给你“事后留档”。
3. 业务数据
这才是你真正在乎的东西,比如:
- 用户私钥
- refresh token
- 第三方 API credential
- 某个租户独有的敏感配置
业务数据不是 KMS 直接替你保管的。AWS 在 data keys 文档 里说得很直白:KMS 生成、加密、解密 data key,但不会替你存、管、追踪 data key,更不会替你拿 data key 去加密业务数据。这部分活,还是得应用自己干。
三、KMS 和 Secrets Manager,不是一回事
很多团队一听到“秘密”“密钥”,脑子里就把 KMS 和 Secrets Manager 搅成一锅粥。其实它们的活并不一样。
Secrets Manager 更像什么
它更像一个“少量高价值 secret 的托管柜”,适合放:
- 数据库密码
- 第三方 API key
- 服务级 JWT 私钥
- 定期轮换的基础设施凭证
KMS 更像什么
它更像一个“密钥管理与加解密操作中心”,适合干这些事:
- 保护主密钥
- 生成 data key
- 解密 encrypted data key
- 配合 IAM / key policy 做权限边界
- 把关键操作打进审计链路
一句不中听但有用的话:
Secrets Manager 当然也能放秘密,但它不是给你拿来堆海量用户级私钥对象的。
如果你只有几十个服务级 secret,用 Secrets Manager 很舒服。如果你要托管十万、百万级别的用户私钥,通常更靠谱的路径是:业务数据放你的数据层,保护方式走 KMS-backed envelope encryption。
四、为什么非要多出一把 data key
很多人初看会觉得:都已经有 KMS 了,为什么不直接让 KMS 加密所有东西?
因为 KMS 的强项不是替你高频搬砖,而是守住总闸门。
AWS 推荐的模式叫 envelope encryption,中文通常叫“信封加密”。意思很朴素:
KMS key 保护 data key
data key 保护业务数据
这样做有三件事同时成立:
- 主密钥始终待在 KMS 里,不出门。
- 真正的大块数据由你的应用本地高效加解密。
- 权限控制点集中在 KMS,而不是散落在每一台应用机上。
这就像银行金库和现金抽屉的关系。金库不负责每天找零,但它决定谁有资格去开抽屉。你要是让金库去干收银员的活,系统设计就有点魔幻了。
五、完整链路:加密时到底发生了什么
假设现在有个很具体的需求:服务端要托管某个用户专用的私钥。
比较稳妥的加密流程,大致长这样。
5.1 总体流程图
@startuml
title 用户私钥托管的信封加密链路
skinparam backgroundColor white
skinparam shadowing false
skinparam roundcorner 12
skinparam defaultTextAlignment center
skinparam componentStyle rectangle
actor "应用服务" as App
rectangle "AWS KMS" as KMS
database "业务数据库" as DB
App --> KMS : GenerateDataKey\n(user_id, tenant_id, purpose)
KMS --> App : plaintext data key\nencrypted data key
App --> App : 本地用 AES-GCM\n加密用户私钥
App --> DB : 保存 private_key_ciphertext\nencrypted_data_key\ncontext / metadata
App --> DB : 读取 ciphertext + EDK
App --> KMS : Decrypt(encrypted_data_key, context)
KMS --> App : plaintext data key
App --> App : 本地解密用户私钥
@enduml

5.2 第一步:向 KMS 要一把 data key
应用调用 GenerateDataKey,指定一个对称 KMS key。KMS 返回:
Plaintext:明文 data keyCiphertextBlob:被 KMS key 包起来的 data key
如果你还没想好 EncryptionContext 要不要用,我的建议是:只要数据和租户、用户、用途有关,就别偷懒。
一个常见的 context 长这样:
{
"tenant_id": "acme",
"user_id": "user_123",
"purpose": "user-private-key",
"version": "v1"
}
注意,它不是 secret。AWS 在 Encryption Context 文档 里明确说了,这玩意儿会出现在日志与审计信息里,所以别把手机号、邮箱、身份证之类敏感内容塞进去。
5.3 第二步:应用本地加密真正的数据
拿到明文 data key 以后,应用自己在本地加密用户私钥。常见做法是 AES-256-GCM。
这里有个容易被忽略的小点:如果你用的是 GCM,除了密文,你还得保存 nonce/IV。做法有两种:
- 单独存
nonce - 直接把
nonce前缀到密文 blob 里
第二种更省心,我自己更偏爱。
再多说一句,本地加密层最好也带 AAD。比如把 tenant_id/user_id/purpose/version 再作为 AES-GCM 的 AAD 绑一层。这样即便数据库里两条记录错位,解密时也更容易第一时间炸出来,而不是悄悄返回一份“格式看着像对的垃圾”。
5.4 第三步:立刻丢掉明文 data key
这一步没什么浪漫色彩,但非常重要。
明文 data key:
- 不该落盘
- 不该写日志
- 不该进缓存
- 不该长时间留在内存里
它就是个临时工。干完活,就该下班。
5.5 第四步:保存业务密文和 encrypted data key
真正应该进数据库的,是这几样东西:
encrypted_private_keyencrypted_data_keykms_key_arnencryption_context_jsonalgorithmstatuscreated_at
也可以再加:
key_versionrotated_atrevoked_at
工程系统能不能长期用,常常不取决于第一天有没有跑通,而取决于半年后你还认不认得这些字段是干什么的。
六、数据库到底该存什么
如果你用关系型数据库,一个比较顺手的表结构可以是这样:
CREATE TABLE user_private_keys (
id BIGSERIAL PRIMARY KEY,
tenant_id VARCHAR(128) NOT NULL,
user_id VARCHAR(128) NOT NULL,
purpose VARCHAR(64) NOT NULL DEFAULT 'user-private-key',
key_version INTEGER NOT NULL DEFAULT 1,
kms_key_arn TEXT NOT NULL,
encrypted_data_key BYTEA NOT NULL,
encrypted_private_key BYTEA NOT NULL,
encryption_algorithm VARCHAR(32) NOT NULL DEFAULT 'AES-256-GCM',
encryption_context_json JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
rotated_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
status VARCHAR(32) NOT NULL DEFAULT 'active'
);
CREATE INDEX idx_user_private_keys_lookup
ON user_private_keys (tenant_id, user_id, status);
这里我刻意没有单独放 nonce 字段,因为我更建议把 nonce 直接拼在 encrypted_private_key 前面。这样接口更简单,读记录时也不容易漏字段。
一句话概括这个表的设计:
库里存的是“业务密文 + 加密后的 data key + 可复原的上下文”,而不是任何明文 key。
七、解密时到底是谁在解
这是最容易绕晕的一段。
7.1 先从数据库取出两样东西
你先拿到:
encrypted_private_keyencrypted_data_key
如果之前用了 EncryptionContext,这时也要把它一并取出来。
7.2 再调用 KMS Decrypt
你把 encrypted_data_key 传给 Decrypt,KMS 返回明文 data key。
这里的关键点是:
KMS Decrypt解开的不是你的用户私钥,而是 encrypted data key。
用户私钥本身,还是由你的应用在本地解。
7.3 真正干活的 KMS key 在哪儿
很多人会继续问:那 KMS 不是也得有密钥才能解吗?
当然要有。只是这把密钥不由你拿,也不由你管,而是由 KMS 在它自己的受保护环境里用。
对称 KMS key 的场景下,AWS 文档说明,KMS 通常可以从对称密文 blob 的元数据中识别出对应的 KMS key,所以 Decrypt 时 KeyId 往往可以省略。不过在工程实践里,我仍然倾向于显式传 KeyId 做一道硬限制。能多一道护栏,何乐而不为。
7.4 EncryptionContext 为什么这么要命
AWS 在 Encryption Context 文档 里说得很明确:它会被加到认证数据里,解密时必须带回完全一致、区分大小写的值。对不上,解密就失败。
这正是它的价值所在。
它不是备注,不是装饰,而是边界的一部分。
八、落到“为特定用户托管私钥”这个场景,应该怎么设计
先说一句可能有点扫兴的话。
8.1 能不托管私钥,最好别托管
如果业务允许:
- 让用户自己持有私钥
- 服务端只存公钥
- 或者把签名动作放到 KMS / HSM 一侧
那通常更干净。
因为一旦你决定“我要在服务端恢复一份用户私钥”,你就要同时承担:
- 密钥托管责任
- 访问控制责任
- 审计责任
- 轮换与吊销责任
- 泄露后的事故责任
这锅不轻。
8.2 真要托管,就按“专线专用”的思路来
比较稳妥的做法是:
- 给这类私钥使用专门的 KMS key,不和别的业务 secret 混在一起。
- 使用
GenerateDataKey为每条记录或每个用户生成独立 data key。 - 用 data key 本地加密私钥。
- 只保存
encrypted_private_key和encrypted_data_key。 - 使用
EncryptionContext绑定tenant_id/user_id/purpose/version。 - 把
kms:Decrypt权限收窄到特定服务角色,并要求 context 匹配。
一个最小可用的 IAM policy,大致可以这么写:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowUserPrivateKeyEnvelopeOps",
"Effect": "Allow",
"Action": [
"kms:GenerateDataKey",
"kms:Decrypt"
],
"Resource": "arn:aws:kms:us-west-2:123456789012:key/11111111-2222-3333-4444-555555555555",
"Condition": {
"StringEquals": {
"kms:EncryptionContext:purpose": "user-private-key"
}
}
}
]
}
AWS 在 KMS access best practices 里一直强调 least privilege。这话大家都听过,真正做到的团队并不多。安全的难点从来都不是“不知道”,而是“嫌麻烦”。
8.3 数据库被拖走以后,会发生什么
这时候你的底线应该是:
- 攻击者拿到的是
encrypted_private_key - 还有
encrypted_data_key - 还有一堆看上去很懂事的 metadata
但如果没有:
- 对应的 KMS 权限
- 正确的
EncryptionContext - 通过你服务端授权链路的能力
他仍然恢复不出明文私钥。
这才叫“托管”,不是“换个地方存明文”。
九、几个最容易踩的坑
1. 把 Secrets Manager 当成用户私钥仓库
少量服务级 secret,没问题。海量用户级私钥,别这么干。后面运维、成本、检索、权限边界都会让你后悔。
2. 把明文 data key 或私钥打进日志
很多事故不是黑客太强,而是 debug 太勤。对象一 print,半个事故报告就写好了。
3. EncryptionContext 填得太随意
要么不用,要用就设计好字段,并且版本化。别今天叫 userId,明天改成 user_id,后天再加个大小写变化。KMS 不陪你玩“差不多就行”。
4. 本地加密没考虑 nonce / AAD
只记得“我用了 AES-GCM”,不等于你真的把工程细节做对了。
5. 应用角色权限太宽
kms:* 这种权限,写起来爽,出事时也很爽。只是爽的是事故复盘,不是你。
6. 没想好轮换与吊销
真正到生产里,私钥会过期、用户会离职、租户会迁移、算法会升级。设计里没有 version、status、rotated_at,后面就只能靠祈祷。
十、一段最小可行的伪代码
加密时
resp = kms.generate_data_key(
KeyId="arn:aws:kms:us-west-2:123456789012:key/11111111-2222-3333-4444-555555555555",
KeySpec="AES_256",
EncryptionContext={
"tenant_id": "acme",
"user_id": "user_123",
"purpose": "user-private-key",
"version": "v1",
},
)
plaintext_data_key = resp["Plaintext"]
encrypted_data_key = resp["CiphertextBlob"]
aad = b"tenant=acme|user=user_123|purpose=user-private-key|version=v1"
nonce, encrypted_private_key = local_encrypt_aes_gcm(
key=plaintext_data_key,
plaintext=user_private_key_pem,
aad=aad,
)
store_to_db(
tenant_id="acme",
user_id="user_123",
encrypted_private_key=nonce + encrypted_private_key,
encrypted_data_key=encrypted_data_key,
encryption_context_json={
"tenant_id": "acme",
"user_id": "user_123",
"purpose": "user-private-key",
"version": "v1",
},
)
wipe_from_memory(plaintext_data_key)
解密时
row = load_from_db(tenant_id="acme", user_id="user_123")
resp = kms.decrypt(
KeyId=row["kms_key_arn"],
CiphertextBlob=row["encrypted_data_key"],
EncryptionContext=row["encryption_context_json"],
)
plaintext_data_key = resp["Plaintext"]
blob = row["encrypted_private_key"]
nonce = blob[:12]
ciphertext = blob[12:]
aad = b"tenant=acme|user=user_123|purpose=user-private-key|version=v1"
private_key_pem = local_decrypt_aes_gcm(
key=plaintext_data_key,
nonce=nonce,
ciphertext=ciphertext,
aad=aad,
)
wipe_from_memory(plaintext_data_key)
这段伪代码背后的核心逻辑,其实只有一句:
KMS 帮你恢复 data key,你自己负责恢复真正的数据。
总结
如果只记一件事,我希望是这一句:
KMS 不替你长期保存用户私钥明文,也不会把主密钥交给你。它做的是保护主密钥、生成 data key、并在授权通过时把 encrypted data key 还原成明文 data key。真正的数据加解密,仍然发生在你的应用里。
这条链路压缩成一张脑图,大概是这样:
总结思维导图
@startmindmap
* AWS KMS 与用户私钥托管
** 三个角色
*** KMS key
*** data key
*** 业务数据
** 加密流程
*** GenerateDataKey
*** 本地加密私钥
*** 丢弃明文 data key
*** 保存密文与 encrypted data key
** 解密流程
*** 从库中取出密文与 EDK
*** KMS Decrypt 解开 EDK
*** 应用本地解开私钥
** 关键边界
*** 主密钥不出 KMS
*** EncryptionContext 必须一致
*** least privilege
*** 明文 key 不落盘
** 托管私钥建议
*** 能不托管就别托管
*** 专 key 专用途
*** 记录版本与状态
*** 预留轮换与吊销
** 常见坑
*** 把 Secrets Manager 当用户密钥仓库
*** 日志泄露明文 key
*** context 乱填
*** IAM 权限过宽
@endmindmap

给明天就能做的 5 条建议
- 先把你的敏感数据分层:哪些是服务级 secret,哪些是用户级数据,别全塞进一个桶里。
- 如果要托管用户私钥,先画一张“加密时”和“解密时”的时序图,看看明文 key 有没有任何落盘机会。
- 给
EncryptionContext定一个固定 schema,比如tenant_id/user_id/purpose/version,别让不同服务各写各的。 - IAM policy 从
kms:GenerateDataKey和kms:Decrypt起步,能收多窄就收多窄,别图省事。 - 在代码评审里专门检查一遍日志、异常、debug 输出,确认没有把明文 key 或私钥内容带出去。
扩展阅读
- AWS 官方文档:
Generate data keys - AWS API 文档:
GenerateDataKey - AWS API 文档:
Decrypt - AWS 官方文档:
Encryption context - AWS 最佳实践:
Identity and access management best practices for AWS KMS
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。