跳转至

AccessToken 短 JWT 签名密钥轮换 Runbook

适用:连接数据面短 JWT 签名子系统(AccessToken 强化 Epic TFRM-153 · M1a/TFRM-154 + 多活轮换 M1b/TFRM-163)。 本签名面独立于管理域 OIDC OPOIDC_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-closedspec.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-service POST /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=keyAACCESS_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       # 新增
部署 user-service(启动 reconcile)→ 调 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
部署 + 调 republish 端点。此时 JWKS = {tfrm-at-2(active), tfrm-at-1(retiring)},改用 keyB 签名; 旧 keyA token(在途,≤TTL)仍可验签。等待 ≥ grace 窗(默认 30min),让在途旧 token 自然过期。

步骤 ③ 下线旧 key

清空 SECONDARY:

ACCESS_TOKEN_SIGNING_KEY            = keyB
ACCESS_TOKEN_SIGNING_KID            = tfrm-at-2
ACCESS_TOKEN_SIGNING_KEY_SECONDARY  =                 # 清空
ACCESS_TOKEN_SIGNING_KID_SECONDARY  =                 # 清空
部署 + 调 republish 端点。reconcile 检测到 tfrm-at-1 已过 grace → 剪到 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[] 逐集群状态):每个活跃集群应 pushedskipped = CR 不存在(集群未就绪), failed = 连通/冲突(据 message 排障重试)。key_count 应等于当前 JWKS 含 key 数。
  • 集群侧(kubectl):TFRCluster.spec.accessTokenJwks 含期望 kid;ns ConfigMap tfrs-access-token-jwksjwks.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,故回滚同样零中断。