AccessToken 短 JWT 签名密钥轮换 Runbook¶
适用:连接数据面短 JWT 签名子系统(AccessToken 强化 Epic TFRM-153 · M1a/TFRM-154 + 多活轮换 M1b/TFRM-163)。 本签名面独立于管理域 OIDC OP(
OIDC_SIGNING_KEY*,见 07-security.md),密钥对/kid 命名空间不共用。 公钥分发 = CR 字段渲染(TFRM-194/195,取代原 Infisical→ESO):Manager 把 JWKS 写进 TFRCluster CR,Operator watch 近实时渲染。
1. 背景与零中断原理¶
Manager 用 ACCESS_TOKEN_SIGNING_KEY(PRIMARY 私钥,env 注入、永不落库)签发短 JWT(默认 TTL 5min)。
验签方 TFRobotServer(S1) 不在线回源 Manager,而是读 robot pod 本地文件 /etc/tfrs/jwks/jwks.json 中的 JWKS:
Manager 公钥注册表(signing_keys 表)
│ admin-service 写 CR(纳管时自动注入 / 轮换后调 republish-jwks 端点)
▼
TFRCluster.spec.accessTokenJwks ──Operator watch(近实时 push)──▶ ns ConfigMap tfrs-access-token-jwks
│ 目录挂载(kubelet 同步)
▼
robot /etc/tfrs/jwks/jwks.json ──▶ TFRobotServer 本地验签(热重载,不重启 Pod)
公钥是 public 数据(与 /.well-known/jwks.json 同源),故走 CR 字段渲染(非密通道)而非 ESO 加密同步——
事件驱动 push 近实时(Operator watch + ConfigMap→pod kubelet 同步 ~2min),取代原 ESO 三层轮询(5m+1m+1m)。
因为 JWKS 仍有传播延迟(~2min),裸轮换(直接换 env key 并重启)会在新 kid 公钥传播到全部验签方之前 就开始用它签名 → 验签方拿不到新公钥 → 验签中断。
解法(照搬 OTLP PRIMARY/SECONDARY 纪律):新增可选 ACCESS_TOKEN_SIGNING_KEY_SECONDARY 槽。
注册表用三态状态机维护「可暴露集」:
| status | 含义 | 是否在 JWKS | 是否用于签名 |
|---|---|---|---|
active |
PRIMARY 签名 key(恒一行,DB partial unique index 硬约束) | ✅ | ✅ |
retiring |
SECONDARY 预发布进站 key / 出站旧 key(验签用) | ✅ | ❌ |
retired |
终态退役(密钥历史,已移出 JWKS) | ❌ | ❌ |
- 前向零中断:进站 key 先放 SECONDARY 槽预发布进 JWKS(
retiring),等传播窗后再交换提升为active。 - 后向零中断:旧 key 降
retiring后在 JWKS 中至少保留 grace 窗(覆盖在途 token 的 TTL),过窗才剪到retired。
缺省 = fail-closed:
spec.accessTokenJwks缺省 → Operator 不建 ConfigMap → Server 取不到公钥 → 拒签 (与 epoch 缺省 fail-open 相反)。故新纳管集群由 admin-service 在创建 TFRCluster CR 时自动注入 JWKS (createTFRCluster),消除「首个机器人前必跑发布」footgun;存量集群/轮换后用 republish 端点补推。
2. 关键参数(单一信源 = internal/shared/signing/service.go)¶
| 参数 | 值 | 含义 |
|---|---|---|
| 退役 grace | DeriveKeyRetirementGrace(TTL) = max(30min, TTL×3),默认 TTL=5min ⇒ 30min |
旧 key 降级后在 JWKS 至少保留时长;代码强制(reconcile 剪枝守卫) |
| 预发布等待 | KeyPrePublishWaitGuidance = 30min(保守值) |
步骤①→②之间 operator 应等待的传播窗;CR 渲染实际 ~2min,30min 留充足安全余量;operator 计时,非代码强制 |
改
ACCESS_TOKEN_TTL会自动改派生 grace,无需手工对齐两处。
3. 轮换三步流(以 PRIMARY 从 keyA→keyB 为例)¶
每步 = 改 Infisical 中
tfrs-manager项目对应环境的 env + 重新部署 user-service(启动 reconcile 写 signing_keys) + 调 admin-servicePOST /api/v1/admin/signing-keys/republish-jwks端点把 JWKS 重推到所有活跃集群 CR。 私钥生成:openssl genrsa 2048 | base64 -w0(≥2048 位,PKCS#1/#8 均可)。kid 用新值(如tfrm-at-2)。
步骤 0 — 起始态¶
ACCESS_TOKEN_SIGNING_KEY=keyA、ACCESS_TOKEN_SIGNING_KID=tfrm-at-1,无 SECONDARY。JWKS = {tfrm-at-1(active)}。
步骤 ① 预发布进站 key¶
设置 SECONDARY = 新 key:
ACCESS_TOKEN_SIGNING_KEY = keyA # 不变,仍签名
ACCESS_TOKEN_SIGNING_KID = tfrm-at-1 # 不变
ACCESS_TOKEN_SIGNING_KEY_SECONDARY = keyB # 新增
ACCESS_TOKEN_SIGNING_KID_SECONDARY = tfrm-at-2 # 新增
republish-jwks 端点重推 JWKS 到所有活跃集群。
此时 JWKS = {tfrm-at-1(active), tfrm-at-2(retiring)},仍用 keyA 签名。
等待 ≥ 预发布等待窗(默认 30min),确保全部验签方都已同步到 tfrm-at-2 公钥。
步骤 ② 交换提升¶
交换 PRIMARY / SECONDARY(new 升 active,old 降 retiring 拿 grace):
ACCESS_TOKEN_SIGNING_KEY = keyB
ACCESS_TOKEN_SIGNING_KID = tfrm-at-2
ACCESS_TOKEN_SIGNING_KEY_SECONDARY = keyA
ACCESS_TOKEN_SIGNING_KID_SECONDARY = tfrm-at-1
步骤 ③ 下线旧 key¶
清空 SECONDARY:
ACCESS_TOKEN_SIGNING_KEY = keyB
ACCESS_TOKEN_SIGNING_KID = tfrm-at-2
ACCESS_TOKEN_SIGNING_KEY_SECONDARY = # 清空
ACCESS_TOKEN_SIGNING_KID_SECONDARY = # 清空
retired(移出 JWKS,留作历史)。
JWKS = {tfrm-at-2(active)}。轮换完成。
安全网:即便误删 SECONDARY 过早(跳过 grace),reconcile 也不会立刻剪掉仍在 grace 窗内的旧 key—— 它会保持
retiring(继续在 JWKS),直到下一次 reconcile 检测到其retired_at已过窗才剪。 唯一例外:从未签名过的预发布进站 key(retired_at为 NULL)被放弃时立即剪(无在途 token 依赖)。
4. 校验¶
/.well-known/jwks.json(user-service 公网)含期望 kid——这是 reconcile 后的权威发布面。republish-jwks端点响应(results[]逐集群状态):每个活跃集群应pushed;skipped= CR 不存在(集群未就绪),failed= 连通/冲突(据 message 排障重试)。key_count应等于当前 JWKS 含 key 数。- 集群侧(kubectl):
TFRCluster.spec.accessTokenJwks含期望 kid;ns ConfigMaptfrs-access-token-jwks的jwks.json已同步;robot pod/etc/tfrs/jwks/jwks.json已更新(Operator watch 近实时,无需重启 Pod)。 - 确认
scripts/env-keys.txt已含两个 SECONDARY 变量(白名单过滤,否则.env.prod注入不到)。
5. 回滚¶
任意步骤异常,把上一步的 env 原样写回 Infisical + 重新部署 user-service + 调 republish 端点重推即可—— reconcile 幂等、可暴露集始终对齐 env 当前持有的 key,旧 key 在 grace 窗内仍在 JWKS,故回滚同样零中断。