作者视角:10 年经验安全工程师,专注安全加固与风险防范 栈:HashiCorp Vault 1.15、Spring Boot 3.2 / jjwt 0.12.6、Nginx 1.25、K8s 1.27、OpenSSL 3.1 结论先行:密钥轮换(rotation)不是"生成新密钥、替换旧密钥"两步,而是"新旧重叠 → 灰度切换 → 观察 → 吊销"四段流水线。绝大多数轮换事故,都死在第二段:切换窗口内只有新密钥生效,存量凭据全部失效。
一、现象:一次"按计划执行"的季度轮换,打挂了 6 个下游服务
给一套金融类 SaaS 做季度安全巡检,他们按合规要求每季度轮换一次 API 签名密钥(HMAC-SHA256,32 字节)。流程是:安全组在配置中心把 api.signing.key 从 K1 改成 K2,通知各下游"下周一开始用新密钥"。
结果当天 09:02 上线后:
- 网关侧验签失败率从 0.03% 跳到 41.7%,持续 3 分 40 秒;
- 6 个下游服务里,2 个(一个 Python 2.7 的老批处理、一个硬编码密钥的移动端 BFF)没有跟上,直接对上游 500;
- 老批处理当天漏跑 3 个批次,对账差异 ¥182,000,人工补跑耗时 6 小时;
- 客诉工单 27 张,值班同学回滚到 K1 花了 11 分钟(回滚脚本本身也有 bug,第一次执行报错)。
事后复盘一句话:轮换被当成了"原子操作",但它本质是一个分布式状态切换,天然需要过渡期。
二、根因:三个被普遍忽略的机制问题
1. 密钥有"在途存量",旧密钥不可能瞬间归零
任何密钥签发出去之后,都存在于:客户端内存、本地缓存文件、已发出的请求、日志里的旧 token、甚至被攻击者截获的流量回放中。"换密钥"改变的是签发侧,不影响验证侧之前已发出去的一切。所以必须给旧密钥留一个"宽限窗口"(grace period),让存量凭据自然衰减。
2. 轮换窗口内,"谁能验、谁能签"的矩阵必须显式设计
轮换期的正确状态是双密钥重叠:
| 阶段 | 签发用 | 验证用 | 说明 | |---|---|---|---| | T0 正常 | K1 | K1 | 单密钥 | | T1 重叠 | K2(新签) | K1 + K2(双验) | 新签用 K2,验证两个都认 | | T2 收敛 | K2 | K2 | 宽限期结束,K1 吊销 |
错误做法只有两种:T1 只验 K2(存量全挂,本次事故);或 K1、K2 都能签(攻击者拿旧密钥继续签新 token,宽限期变成攻击窗口)。"新签只走新密钥、验证兼容双密钥"是唯一安全的中间态。
3. 轮换的"顺序"和"回滚"没有对称设计
密钥泄露后的应急轮换,正确顺序是先吊销/失效旧密钥的验证、再保证新密钥可用(顺序反了等于"换了锁、钥匙还在旧门上",见 postId 1450);而计划性轮换,顺序是先让新密钥可验证、再切签发、最后吊销旧密钥。两种场景的"新旧顺序"恰好相反,SOP 里必须分开写,混用必炸。
三、具体做法:一套可复现的执行清单
1. 密钥带 ID,验证端按 kid 路由(核心手段)
密钥不要叫 signing.key,要带唯一 ID(JWS 头部的 kid,或自定义 header 字段):
{ "alg": "HS256", "kid": "2024-q3-01" }
验证端逻辑:
// 伪代码:按 kid 选密钥,kid 不存在 → 直接拒绝,不做全局兜底
SecretKey key = keyring.get(claims.getKid()); // keyring 内含 K1、K2
if (key == null) throw new JwtException("unknown kid");
keyring 用 Vault 的 KV v2 或 KMS 实现(vault kv get secret/keys/api-signing@2),每个版本不可变,轮换 = 加一个新版本 + 调整 active 与 retired 指针,而不是覆盖写。
2. Vault 侧的操作序列(计划性轮换)
# T1 开始:新增 K2 版本(KV v2,旧版本自动保留可读)
vault write secret/keys/api-signing kid=2025-q1-01 value=$(openssl rand -hex 32)
# 调整指针:active 切到 K2,K1 进入宽限期(保留但标记 retired)
vault write secret/keys/api-signing active=2025-q1-01 retired=2024-q3-01 retired_until=2025-01-20T00:00:00Z
# 确认所有签发方已切 K2(看网关指标:kid=2024-q3-01 的新签请求数 → 0)
# T2 收敛:
vault write secret/keys/api-signing active=2025-q1-01 retired=""
vault kv destroy-version secret/keys/api-signing @1 # 删除 K1 明文,留审计
3. 宽限窗口怎么定:用数据,不用感觉
窗口长度 = 存量凭据最长生命周期 + 客户端最大缓存时间 + 缓冲。例如:token TTL 7 天 + 移动端离线缓存 3 天 + 缓冲 2 天 ≈ 12 天。本次事故如果给了 24 小时宽限 + kid 路由,41.7% 的验签失败会降到接近 0(只有"既没升级又不带 kid"的老客户端失败)。
4. 应急轮换(泄露场景)反向 SOP
- 先把 K1 从验证端移除(网关配置热更新,约 10 秒);
- 再确认 K2 已在所有签发方生效;
- 吊销依赖 K1 签发的所有活跃 session(按 kid 批量失效,而不是全量登出);
- 审计 K1 在宽限期内的最后 72 小时调用记录,区分"合法存量"与"异常调用"。
5. 回滚必须是对称的一键操作
本次事故回滚用了 11 分钟且第一次执行失败,根因是"轮换脚本"和"回滚脚本"是两个东西。正确做法:轮换动作只改指针(active/retired),不改数据,回滚 = 指针换回 + 宽限期延长,一条命令:
vault write secret/keys/api-signing active=2024-q3-01 retired=2025-q1-01 retired_until=+72h
四、踩过的坑与反直觉之处
- "全量登出"是最差的应急手段。 按 kid 定向失效,只踢用旧密钥签发的会话;全量登出等于拿 99% 无辜用户给 1% 的泄露事故陪葬。
- 验证端做了"未知 kid 时回退到默认密钥"的兜底,等于把轮换做废了。 兜底路径会让带旧 kid 的 token 永远有人认,宽限期形同虚设,还会掩盖签发方未升级的问题。未知 kid 必须 401。
- 轮换频率本身不是安全目标。 每季度轮换一次,如果每次都有 27 张客诉,那轮换的"可用性成本"已经超过了它防的"泄露风险"。正确指标是:轮换期间的验签失败率 < 0.1%,且可回滚时间 < 5 分钟。
- 对称密钥(HMAC)轮换比非对称(RSA)贵一个量级。 RSA 只需分发公钥,HMAC 需要把 32 字节秘密推送到每一个签发方——推送失败的节点就是故障点。能用非对称的场景别偷懒用 HMAC。
- 日志里的旧 token 比你想的活得久。 一次事故里,攻击者正是用日志平台里 40 天前的旧 token(K1 签名)重放成功的——密钥吊销后 40 天仍有效,因为会话黑名单没按 kid 建。
五、结论与可复用清单
结论:密钥轮换的本质是一次"带过渡期的分布式状态切换",设计三要素缺一不可——带 ID 的双密钥重叠、数据驱动的宽限窗口、对称可回滚的指针切换。
清单(可直接抄进 SOP):
- [ ] 每把密钥有全局唯一 kid,验证端按 kid 路由,未知 kid 直接拒绝
- [ ] 密钥存 KV v2 / KMS,版本不可变,轮换只动 active/retired 指针
- [ ] 重叠期规则:新签只走新密钥,验证兼容双密钥
- [ ] 宽限窗口 = 最长 token TTL + 最大客户端缓存 + 缓冲,写进 SOP 而非口头约定
- [ ] 应急轮换与计划性轮换分开写 SOP(新旧顺序相反)
- [ ] 轮换与回滚是同一条指针命令的正反方向,回滚时间目标 < 5 分钟
- [ ] 监控指标:按 kid 分组的签发量与验签失败率;签发量归零前不许吊销
- [ ] 会话失效按 kid 定向做,禁止无差别全量登出