披露:本文由 Appaloft 团队撰写,介绍的是我们自己的实现和当前仍保留的运维门禁。文中没有生产密钥、账号、secret 名称或备份标识。
这周,我们给 Appaloft 的控制面秘密补上了版本化加密 envelope 和显式轮换流程。Environment、Resource、Deployment snapshot 和 dependency runtime 中的秘密会在写入时加密,只在命令入口和运行时注入的短暂窗口里出现明文。
加密本身很快就能写完。真正拖长设计时间的是后面的连锁问题:旧密文由谁读取,CI 如何取得 keyring,迁移开始后数据库发生变化怎么办,事务提交以后外部验证失败又如何恢复。
我们最后把它拆成了三个连续的边界:key custody、database migration 和 recovery proof。任何一段缺少证据,旧 key 都继续保留。
一个 keyring 同时回答写入与读取
Appaloft 使用 versioned authenticated AES-256-GCM envelope 保存控制面秘密。Envelope 带有 key id;runtime 配置则包含一个 active key 和若干 retained keys:
{
"activeKeyId": "2026-q3",
"keys": {
"2026-q3": "<active 32-byte base64 key>",
"2026-q2": "<retained 32-byte base64 key>"
}
}
新写入只使用 active key,旧 key 只负责解密轮换窗口内尚未 rewrap 的记录。这样可以先把新旧 key 一起部署,确认旧密文仍然可读,再切换写入 key。
这里最容易出现的错误,是把 key rotation 理解成一次配置覆盖。如果新配置只保留新 key,数据库里使用旧 key 的 envelope 会立刻失去读取路径。Appaloft 因此在 planning、retry、redeploy、rollback 以及 Docker、Compose、SSH、Swarm runtime materialization 前完整预检 secret envelope。缺 key、key 错误或 envelope 损坏都会阻止 mutation;adapter 不能把失败转成空字符串,也不能跳过该变量后继续报告成功。
这部分 provider-neutral 契约已经进入公开的 ADR-089 和 Appaloft key rotation workflow。具体实现与测试可以从 appaloft/appaloft#687 开始看。
密钥保管要离开密文所在的故障域
如果数据库同时保存密文和唯一的解密 key,拿到同一数据库访问权限的人就拥有完整读取能力;数据库恢复失败时,keyring 也可能一起消失。我们希望生产 keyring 有独立的访问与恢复边界,因此把完整 keyring document 放在 AWS Secrets Manager,由 GitHub Actions 在部署时读取。
CI 没有长期 AWS access key。GitHub workflow 通过 OIDC 换取短期 AWS credentials,IAM trust 限制到受保护的 production environment,权限只覆盖目标 secret 的读取。GitHub 的 AWS OIDC 指南同样建议在 trust policy 中校验 sub claim,限制哪些 workflow 可以取得云端身份。
Keyring document 同时保存 active key id 和完整 active/retained key map。AWS Secrets Manager 会为 secret value 建立 version,并以 AWSCURRENT 标出默认读取版本;它还保留 previous/pending 等 version stage,便于在 cutover 时辨认当前与先前版本。这个 provider version 解决的是 custody document 的切换,Appaloft keyring 里的 retained key 则解决数据库 envelope 的兼容读取。两者用途相邻,但不能互相替代。AWS 在 secret version 文档中说明了这些 stage 的行为。
Terraform 只创建保管结构
我们用 Terraform 创建 secret metadata、GitHub OIDC provider、IAM role 和最小读取 policy,但不创建包含 key material 的 secret version,也不接收 key value 作为变量。
原因很直接:Terraform 的 sensitive 标记主要控制 CLI 和 UI 显示,并不自动让 value 离开 state。HashiCorp 的敏感数据文档明确提醒,写入配置的 secret 可能进入 plan 和 state;state 仍需要远端存储、静态加密、访问控制和审计。
实际 keyring 由 operator 在受控终端生成,写入一个权限受限的新文件,再通过 file input 创建 secret version。成功读回 version metadata 后,临时文件被清理。命令输出只允许出现 schema version、key id、key count 和 provider version reference,不出现 key material、密文或秘密长度。
这条边界让 IaC 仍然负责长期资源和权限关系,同时避免把最敏感的 value 变成 IaC 状态的一部分。
轮换从只读计划开始
Appaloft 的轮换入口分为两个操作:
appaloft db secret-rotation plan
appaloft db secret-rotation apply \
--plan-digest sha256:<reviewed-digest> \
--backup-reference <external-backup-reference>
plan 不写数据库。它只返回 record count、variable key count、envelope 状态、safe key ids、readiness 和 deterministic digest,不返回明文或密文。
Operator 审核计划、取得数据库备份引用后,再把 digest 交给 apply。Apply 会重新计算当前计划;如果这段时间记录有变化,digest 就会过期,操作必须回到 plan。Legacy plaintext 迁移还需要单独的显式授权。
进入 transaction 以前,adapter 会预检选中的每一条记录。所有记录可读、目标 key 存在、backup reference 和 plan digest 都有效时,才在一个 transaction 中完成 rewrap。任何一条失败,整次迁移回滚。这里的 transaction 解决数据库原子性,外部 backup reference 解决 commit 以后仍需要恢复的问题。
首次迁移要先处理旧 runtime
第一次从 legacy plaintext 进入 keyring-aware runtime,比普通 key-to-key rotation 多一个部署顺序问题。
旧 runtime 可能继续写 plaintext,新 runtime 则会拒绝隐式部署 legacy rows。如果先改数据库,再部署新 runtime,部署失败后旧版本未必能理解迁移后的状态。如果先部署新 runtime,但无法冻结 secret-dependent writes,迁移计划又可能持续漂移。
我们的顺序是先准备完整 keyring 和数据库备份,再冻结会创建或读取控制面秘密的写入;随后部署 keyring-aware runtime,运行只读 plan,审核 legacy/unreadable counts,最后才决定是否 apply。没有可靠 write freeze 时,迁移停止。
这个停机条件看起来保守,但它把一个难以推断的在线竞态变成了可以解释的维护窗口。
恢复演练决定何时删除旧 key
Transaction 成功只说明数据库 rewrap 已提交。它没有证明恢复出来的数据库与保管系统里的 keyring 仍然匹配,也没有证明运行时能正确 materialize workload secret。
因此 apply 之后还要再运行 plan,确认全部记录都由 active key 保护;执行一个无害 deployment fixture;只回读目标 workload 的变量键集合与数量。旧 key 会继续保留,直到这些验证、观察窗口和数据库恢复演练全部结束。
恢复演练使用 production physical backup 创建独立数据库项目,再执行只读连接、keyring readiness 和 cleanup 检查。Supabase 的 Restore to a new project 会复制数据库 schema、数据、角色和权限,但 Storage objects、Edge Functions、Auth settings、API keys 等仍需单独处理;带外部副作用的 database extension 也要在副本中关闭。普通 preview branch 无法证明同一条 backup/restore 路径。
我们目前仍把真实恢复演练、legacy adoption 和旧 key 退役视为独立运维门禁。代码合并和测试通过没有自动关闭这些事项。
一份可执行检查表
下一次设计控制面 key rotation 时,可以先回答这些问题:
- 密文和唯一 key 是否落在同一个访问或恢复故障域?
- 新 runtime 能否同时读取 active 与 retained key,缺 key 时是否 fail closed?
- Key material 会不会进入 Terraform state、CI log、PR、shell history 或聊天?
- Apply 是否绑定审核过的当前计划,而不是一份已经漂移的 dry-run?
- 数据库 transaction 之外,是否有不可变 backup reference 和真正跑过的 restore 路径?
- 哪些证据通过以后,旧 key 才允许删除?
轮换流程的价值不在于把 key id 从 v1 改成 v2,而在于让每一个中间状态都有明确的读取路径、停止条件和恢复证据。
如果你在设计更广的部署恢复契约,可以继续读部署失败后的恢复路径和单机 Compose 的回滚边界。