- 即时通讯
- 后端
【免费下载链接】WuKongIM
More than just IM 不只是即时通讯(IM)
导读
本文解读 WuKongIM 仓库中的架构决策记录 ADR 0044:Use separate deployment and Codex SSH identities,阐述自动化 Chat Lifecycle 云租期(Cloud Lease)为何要为"部署运维"与"Codex 实时诊断"各签发一把相互独立的临时 Ed25519 SSH 身份,并深入其落地实现:wkchatlifecycle的seal-deployment-identity/open-deployment-identity命令、materialize的公钥绑定,以及stage-orchestrate.sh中的完整调用链。读完本文,你将理解这套"一把加密凭证给 GitHub 部署、一把本地私钥给 Codex 诊断、双双随租期失效"的隔离模型,以及它如何用 NaCl 密封盒保证凭证可审计、可绑定、不可越权。
背景:自动化 Chat Lifecycle 需要临时直接访问
在 WuKongIM 的自动化 Chat Lifecycle 流程中,系统会在阿里云按量付费(PostPaid)租用四台同规格主机(三台 4 vCPU / 8 GiB 的 WuKongIM 服务节点 + 一台承载 worker、协调、监控、分析与公共代理职责的 4 vCPU / 8 GiB 主机),见 ADR 0041:Use four PostPaid hosts for automated chat lifecycle。为保证"负载节点可直达、服务节点保持私有",ADR 0042 明确:自动化运行期间负载节点仅在租期内开放 22 端口 SSH 与 80 端口 HTTP,三个服务节点保持私有离线、禁用密码与 root 密码登录,租期结束时所有规则与 authorized keys 一并清除。
在这种"临时测试服务器、租期结束即销毁"的拓扑下,谁有权 SSH 进去、以什么身份进去,直接决定了整个流程的可审计性与安全边界。ADR 0044 正是在此背景下回答"部署动作与诊断动作应该用同一把钥匙吗"的问题。
双身份设计:一把给部署,一把给诊断
ADR 0044 的结论可以浓缩为一句话:
每个 Chat Lifecycle Cloud Lease 授权两个相互独立的临时 SSH 身份——一把由 GitHub 加密持有的身份用于部署、监控、收尾与证据救援;一把本地生成的 Ed25519 身份,其私钥仅保留在 Codex 手中用于实时诊断。两者都随 Lease 过期失效,并且只有在证明库存归零(zero inventory)之后才会被删除。
拆开来看,这把"双身份"模型包含四个关键属性:
| 维度 | 部署身份(Deployment Identity) | 诊断身份(Diagnostic / Codex Identity) |
|---|---|---|
| 私钥持有方 | GitHub 侧,以加密信封形式流转,私钥主体经密封后随 Artifact 传递 | 本地生成的 Ed25519 私钥,仅保留在 Codex 手中 |
| 用途 | Deployment(部署)、监控、finalization(收尾)、证据救援 | 实时诊断(live diagnosis) |
| 生成方式 | 由编排脚本ssh-keygen生成,经seal-deployment-identity密封 | 由本地脚本ssh-keygen生成,公钥注入 Run Plan |
| 失效方式 | 随 Lease 过期(expires_at强校验) | 随 Lease 过期 |
| 删除时机 | 证明 zero inventory 之后 | 证明 zero inventory 之后 |
这样分离的直接收益写在 ADR 原文中:保持可审计性(preserves auditability),并防止 GitHub 的部署凭证变成 Codex 的诊断秘密。即使 Codex 的本地环境或诊断会话被攻破,攻击者得到的也只是租期内的诊断身份,而不是那条能驱动整个部署链路的 GitHub 部署凭证;反过来,GitHub Actions 工件链路上的泄露也不会直接暴露 Codex 的诊断私钥。
部署身份的密封与打开:源码级实现
双身份机制的核心代码位于 cmd/wkchatlifecycle/deployment_identity.go,由wkchatlifecycle工具以两个 cobra 子命令暴露:seal-deployment-identity与open-deployment-identity。
密封命令:把部署私钥锁进加密信封
seal-deployment-identity的职责是"将一把 Lease 作用域内的部署 SSH 身份密封为加密信封",其完整参数如下:
wkchatlifecycle seal-deployment-identity \ --recipient <wrapping-key OpenSSH Ed25519 公钥> \ --identity <mode-0600 部署 Ed25519 私钥> \ --request-id <请求身份> \ --lease-id <Lease 身份> \ --source-sha <40 位十六进制源码提交> \ --plan-digest <64 位十六进制 Cloud Lease Plan 摘要> \ --expires-at <不可变的 Lease 过期时间 RFC3339 UTC>对应源码中的 flag 定义见 deployment_identity.go 第 102-110 行,七个参数全部markRequired强制必填。密封过程如下:
- 元数据校验
validDeploymentIdentityMetadata:request-id/lease-id需匹配accessIdentityPattern(^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$),source-sha需为 40 位小写十六进制(hex40Pattern),plan-digest需为 64 位十六进制(hex64Pattern),expires-at必须是UTC 时区的 RFC3339 时间(第 182-189 行)。 - 解析 recipient 公钥(
parseEd25519PublicKey,实现在 access.go 第 194-212 行),只接受单条、无 authorized_keys 选项、无附加内容的ssh-ed25519公钥。 - 读取部署私钥(
readPrivateIdentityBody),强制私钥必须是常规文件且权限位不含 group/other 位(info.Mode().Perm()&0o077 != 0即拒绝),大小在 0 到 64 KiB 之间,且必须是 Ed25519 类型(第 208-245 行)。 - 计算部署公钥的 authorized_keys 文本与 SHA256 指纹(
ssh.MarshalAuthorizedKey/ssh.FingerprintSHA256)。 - 组装明文 payload(
deploymentIdentityPayload),其中private_key_base64承载私钥主体。 - 用
box.SealAnonymous(匿名密封,不需要发送方密钥,只需 recipient 公钥)对明文加密,算法标识为x25519-xsalsa20-poly1305-sealed-box(常量accessEncryptionAlgorithm,access.go 第 31 行)。 - 输出
encryptedDeploymentIdentityEnvelope信封 JSON,包含 schema、algorithm、request-id、lease-id、source-sha、plan-digest、recipient-fingerprint、deployment-public-key、deployment-fingerprint、expires-at 与 ciphertext-base64。
信封 schema 常量定义在 deployment_identity.go 第 19-22 行:
wukongim.chat_lifecycle.deployment_identity/v1:明文 payload schemawukongim.chat_lifecycle.encrypted_deployment_identity/v1:信封 schema
这意味着即使信封被放在 GitHub Actions Artifact 这种有界存储上,也无法直接读出任何私钥材料——测试TestDeploymentIdentityEnvelopeRoundTripIsLeaseBoundAndPrivate专门断言"加密后的部署身份不包含明文密钥材料"(bytes.Contains(sealed.Bytes(), privateBody)必须为 false)。
打开命令:只有持 wrapping 私钥的人能还原
open-deployment-identity是密封的逆操作,参数为:
wkchatlifecycle open-deployment-identity \ --envelope <加密信封 JSON> \ --identity <mode-0600 wrapping-key Ed25519 私钥> \ --request-id / --lease-id / --source-sha / --plan-digest <精确匹配> \ --now <可信的当前 RFC3339 UTC 时间> \ --output <新部署私钥输出路径>它的校验链(第 114-172 行)层层收紧:
- 信封 JSON 严格解析(
readStrict:DisallowUnknownFields、拒绝尾随数据),并通过validEncryptedDeploymentIdentityEnvelope验证 schema、算法、元数据格式、指纹前缀SHA256:、deployment_public_key必须以ssh-ed25519开头、密文长度有界。 - 请求参数必须与信封中的
request_id、lease_id、source_sha、plan_digest逐一精确相等。 --now必须解析为 UTC 时区,且必须早于信封中的expires_at——过期即拒绝,实现"随 Lease 过期失效"。- wrapping 私钥的 SHA256 指纹必须与信封
recipient_fingerprint一致,防止用错钥匙。 box.OpenAnonymous解密失败(ok == false)即拒绝。- 解密出的 payload 必须与信封完全匹配(
payloadMatchesEnvelope),且私钥主体解码后重新推导出的公钥,必须同时匹配信封的deployment_fingerprint与deployment_public_key——防止信封内容被篡改后解密出另一把钥匙。 - 最后
writePrivateAtomic以0600 权限、原子写(临时文件 + rename)落盘,且绝不覆盖已存在文件(测试TestOpenDeploymentIdentityRejectsMismatchExpirationAndExistingOutput验证失败场景下不会创建输出、已有文件内容不被改动)。
诊断身份的注入:materialize 与 access 命令
Codex 诊断身份(--codex-diagnostic-pubkey)与部署身份(--deployment-pubkey)在 Run Plan 物化阶段被同时绑定。wkchatlifecycle materialize命令(cmd/wkchatlifecycle/main.go 第 199-227 行)接收两个独立参数:
--codex-diagnostic-pubkey:Codex 诊断 Ed25519 公钥--deployment-pubkey:派生的部署 Ed25519 公钥
两者均标记为必填,与source-sha、operator、request-id、repository、bundle-digest、now、attempt一起传入chatlifecyclerun.Materialize。这把"公钥入计划"的设计意味着:诊断公钥与部署公钥在计划层面就是两个独立字段,后续分别进入bootstrap_access(引导访问)与部署激活流程,互不混用。
与部署身份配套的是同一工具中的seal-access/open-access命令(cmd/wkchatlifecycle/access.go 第 70-156 行):它们把 UI 凭据(Manager / Demo 地址、operator 用户名、密码、租期过期时间)以同样的box.SealAnonymous密封为encryptedAccessEnvelope,recipient正是Codex 的 Ed25519 公钥。也就是说:
- 部署身份信封的 recipient 是 GitHub 侧的 wrapping 公钥(由
WK_CHAT_WRAPPING_PUBLIC_KEY提供); - 访问凭据信封的 recipient 是 Codex 的诊断公钥;
- 两个信封体系都遵循"密封在部署侧、由各自私钥持有者打开"的对称结构。
值得一提的是 access.go 第 214-296 行 实现了readEd25519Identity与ed25519PublicToX25519:将 Ed25519 公钥的 Edwards y 坐标映射为 X25519 的 Montgomery u 坐标(u = (1+y)/(1-y) over 2^255-19),私钥侧通过SHA-512(seed)前 32 字节并做位掩码(bit 0 &= 248、bit 31 &= 127、bit 31 |= 64)派生 X25519 私钥,并用curve25519.X25519探测标量做合法性验证。这是"用 OpenSSH Ed25519 密钥承载 NaCl sealed box"的关键桥接,也解释了为何所有身份必须严格限定为 Ed25519 类型。
端到端调用链:stage-orchestrate.sh 里的实际编排
理解双身份如何落地的最终答案在编排脚本 scripts/chat-lifecycle/stage-orchestrate.sh 中。完整流程如下:
第一步:生成部署密钥并物化计划(第 476-490 行):
deployment_key="$WK_CHAT_WORK_DIR/deployment-${chat_stage}-${attempt}" ssh-keygen -q -t ed25519 -N '' -C "wukongim-chat-lifecycle-${WK_CHAT_REQUEST_ID}-${chat_stage}-${attempt}" -f "$deployment_key" chmod 0600 "$deployment_key" deployment_public_key="$(<"${deployment_key}.pub")"随后materialize同时注入两把公钥:
"$WK_CHAT_TOOL" materialize --template "$WK_CHAT_TEMPLATE" --source-sha "$WK_CHAT_SOURCE_SHA" \ --operator "$WK_CHAT_OPERATOR" --codex-diagnostic-pubkey "$WK_CHAT_CODEX_DIAGNOSTIC_PUBKEY" \ --request-id "$WK_CHAT_REQUEST_ID" --repository "$GITHUB_REPOSITORY" \ --bundle-digest "$bundle_digest" --deployment-pubkey "$deployment_public_key" \ --now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --attempt "$attempt" \ --committed-micros "$committed_micros"第二步:密封部署身份(第 518-522 行):
"$WK_CHAT_TOOL" seal-deployment-identity \ --recipient "$WK_CHAT_WRAPPING_PUBLIC_KEY" --identity "$deployment_key" \ --request-id "$WK_CHAT_REQUEST_ID" --lease-id "$planned_lease_id" \ --source-sha "$WK_CHAT_SOURCE_SHA" --plan-digest "$planned_plan_digest" \ --expires-at "$planned_expires_at" >"$attempt_dir/encrypted-deployment-identity.json"注意--expires-at直接取自 Run Plan 的lease_plan.expires_at,把部署身份的寿命硬绑定到 Lease 过期时间;--plan-digest取自 release selector,保证信封与将要下发的 Lease Plan 一一对应。
第三步:把信封交给部署 Actions。脚本通过dispatch_and_wait cloud-deployment-activate.yml将encrypted_deployment_identity_json作为参数传给部署 workflow(第 404 行),随后从部署产物中取回encrypted-deployment-identity.json并用jq校验其 schema、request/lease/source/plan 绑定关系与公钥前缀(第 564-569 行)。部署侧再以open-deployment-identity打开信封还原部署私钥(测试 scripts/chat_lifecycle_workflows_test.go 断言部署 workflow 必须包含open-deployment-identity与WK_CHAT_LIFECYCLE_WRAPPING_PRIVATE_KEY环境变量)。
第四步:用部署身份建立 SSH 通道。脚本导出WK_CLOUD_SSH_KEY="$deployment_key"后调用 scripts/cloud-deployment/write-ssh-config.sh,生成以wkdeploy用户、IdentitiesOnly yes、BatchMode yes、StrictHostKeyChecking accept-new、独立 known_hosts 的 SSH config,三个服务节点通过ProxyJump wukong-load经负载节点跳转(write-ssh-config.sh 第 44-70 行)。后续所有ssh -F "$WK_CLOUD_SSH_CONFIG"操作——启动 stage 服务、轮询 run-start.json、读取 journal 游标——都走这把部署身份。
第五步:失败路径上的清理。release_failed_attempt在释放租期后立即rm -f部署私钥/公钥,并删除attempt_dir下所有encrypted-deployment-identity.json(第 289-293 行);成功路径则在 handoff 完成后rm -f "$deployment_key" "${deployment_key}.pub"(第 748 行)。cleanup_on_exit的 EXIT trap 保证即使脚本异常退出,本地部署密钥也会被清除(第 247-255 行)。这正对应 ADR 中"只在证明 zero inventory 后删除"的约束——租期释放动作cloud-lease-release.yml会返回带zero_inventory字段的 release.json,只有确认零库存后才算彻底收尾。
本地实验室路径 scripts/chat-lifecycle/direct-lab.sh 同样贯彻双身份:initialize_request_state一次生成两把密钥——diagnostic_ed25519(注释wukongim-chat-lab-diagnostic-$request_id)与deployment_ed25519(注释wukongim-chat-lab-deployment-$request_id)(第 356-359 行),并在start_request的 materialize 调用中分别以--codex-diagnostic-pubkey "$(<"$directory/diagnostic_ed25519.pub")"与--deployment-pubkey "$(<"$directory/deployment_ed25519.pub")"注入(第 434-437 行)。
测试与契约:隔离机制的可验证性
双身份机制被多层测试锁定:
- cmd/wkchatlifecycle/deployment_identity_test.go:
TestDeploymentIdentityEnvelopeRoundTripIsLeaseBoundAndPrivate:完整密封→打开回环,验证信封无明文私钥、schema 正确、Lease 绑定正确、打开后字节一致且权限为 0600;TestSealDeploymentIdentityAcceptsCommentedRecipient:允许 recipient 公钥带注释;TestEd25519RecipientRejectsAuthorizedKeyOptionsAndAdditionalKeys:拒绝带 authorized_keys 选项或多条公钥的模糊输入;TestOpenDeploymentIdentityRejectsMismatchExpirationAndExistingOutput:错误 wrapping 私钥、错误 request/lease、过期时间均拒绝打开,且失败时绝不创建输出文件、绝不覆盖已有文件。
- cmd/wkchatlifecycle/access_test.go:覆盖 access 信封的同类回环与拒绝路径。
- scripts/chat_lifecycle_workflows_test.go:从编排脚本静态断言
seal-deployment-identity、open-deployment-identity、encrypted-deployment-identity.json与WK_CHAT_LIFECYCLE_WRAPPING_PRIVATE_KEY等关键元素必须存在。 - scripts/chat_lifecycle_direct_lab_test.go 与 scripts/chat_lifecycle_direct_lab_deploy_integration_test.go:断言本地请求状态目录必须同时包含
diagnostic_ed25519与deployment_ed25519两套文件,验证双身份是初始化状态的固有组成部分。
安全边界小结
从设计到实现,ADR 0044 的双 SSH 身份隔离可以归纳为四层边界:
- 密钥来源隔离:部署私钥由编排脚本在租期开始时生成,密封后私钥主体只以密文形式存在于 Artifact;诊断私钥由本地生成,从不进入 GitHub 工件链。
- 持有者隔离:部署身份的 recipient 是 GitHub 侧 wrapping 公钥,只有 GitHub 部署流程能打开;Codex 诊断身份的公钥进入 Run Plan 的 bootstrap access,私钥只留在 Codex 手中。
- 租期绑定:信封中的
expires_at与lease_id、plan_digest、source_sha、request_id强绑定,打开时必须逐项精确匹配且时间未过期。 - 销毁纪律:无论成功、失败还是 operator 手动终止,部署密钥都在租期释放(zero inventory 证明)之后被立即删除,绝不跨租期保留。
这套机制与 ADR 0034(run-scoped internal capability tokens)、ADR 0037(本地运行 Codex 加密分析会话) 一脉相承:所有特权都做成"租期作用域、最小化持有、到期即毁"。双身份正是这一原则在 SSH 访问层面的一次具体落地——既满足了操作者对临时测试服务器直接访问的要求,又没有让任何单一凭证同时承担"部署"与"诊断"两种职责。
- 即时通讯
- 后端
【免费下载链接】WuKongIM
More than just IM 不只是即时通讯(IM)
相关推荐
WuKongIM Cloud Simulation 运行域内部能力令牌:256 位隔离凭证的设计与落地
WuKongIM Cloud Simulation 运行域内部能力令牌:256 位隔离凭证的设计与落地 导读 WuKongIM 的 Cloud Simulati
即时通讯后端WuKongIM 云部署离线 Bundle 密封与校验:wkcloudbundle 命令的设计与实现
WuKongIM 云部署离线 Bundle 密封与校验:wkcloudbundle 命令的设计与实现 导读 cmd/wkcloudbundle 是 WuKong
即时通讯后端如何4步让B站日常任务全自动:BiliBiliToolPro部署完整指南
如何4步让B站日常任务全自动:BiliBiliToolPro部署完整指南 每天签到、看视频、投币,少做一步就少一份经验,坚持几天就忘了。BiliBiliTool
后端任务调度工作流自动化
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考