# TLS 证书轮换需要双向证明

证书颁发机构完全可能签发 AI 智能体所请求的证书，而服务仍在提供旧证书。我见过不少轮换任务因为下载到 PEM 文件、成功更新密钥或重载命令显示绿色就开始庆祝，最后却在最糟糕的时间发现某个监听器从未更新。**签发成功和部署成功是两个不同的主张，因此需要两套证据。**

可靠的轮换会记录 CA 签发了什么，部署完全相同的工件，再建立新的 TLS 连接来观察客户端实际收到什么。最后的比较必须使用稳定的证书身份、预期主机名和真实网络路径。如果智能体不能给出比较所需的两端证据，这项变更就仍在进行中。

## 完成条件需要明确的证据契约

只有当已签发证书的记录与部署范围内每个端点的观测结果一致时，轮换才算完成。在把凭据或命令交给智能体之前就写下这条规则。否则，智能体会把工具报告的成功当作目标，而那个成功可能只代表文件写入，并不代表客户端可见的变化。

签发记录应包含订单或请求标识符、请求的 DNS 名称、证书序列号、SHA-256 指纹、有效期、签发者以及公钥摘要。它还应说明完整证书链和私钥存放在哪里，但不能把私钥复制进日志。证书指纹标识整个叶证书，公钥摘要则能让运维人员判断重新签发的证书沿用了密钥还是更换了密钥。

部署记录回答另一组问题：哪个目标收到了工件，哪份配置引用它，哪个进程接受了重载，以及操作发生的时间。控制平面 API 返回成功可以写进这里，但它不是服务已经生效的证明。它只能证明一次状态转换尝试到达了部署系统。

观测记录来自部署后建立的新连接。它需要包含请求的主机名、解析或强制指定的地址、端口、观测到的序列号和指纹、验证结果、时间戳及验证器位置。位置很重要，因为内部探针、外部探针和位于企业代理之后的探针可能到达不同的 TLS 终止点。

完成判断应保持机械化：

```text
complete = issuance.valid
        && deployment.accepted
        && every(expected_endpoint,
                 observation.chain_valid
                 && observation.name_valid
                 && observation.leaf_fingerprint == issuance.leaf_fingerprint)
```

这样会暴露一种棘手状态：证书已经存在，部署也已被接受，但线上指纹仍不一致。把这个状态称为 `deployed_unverified`，不要称为 `complete`。精确的状态名可以防止仪表盘或智能体摘要悄悄把两个不同的主张合并起来。

## ACME 签发不能证明部署

ACME 能证明证书颁发机构在完成所需授权后接受了订单并签发证书，但这个协议不能证明应用正在提供该证书。RFC 8555 描述了客户端的四个主要步骤：提交证书订单，证明对请求标识符的控制权，用证书签名请求完成订单，然后等待并下载证书。协议边界到取回证书为止。

这个边界很容易被忽略，因为许多 ACME 客户端会在续期后执行部署钩子。钩子位于 CA 事务之外。文件路径变化、容器挂载旧密钥、重载指向错误进程、远程 API 接受异步更新或某个节点无法连接，都可能让它失败。发生这些故障时，订单本身仍然可以完全有效。

记录最终的 ACME 订单对象或等价的 CA 响应。对于 ACME 订单，`status: valid` 加上已填充的证书 URL 可以证明签发成功。解析下载的证书后，记录其中的标识符和返回的叶证书指纹。不要用新文件是否存在来标识工件，临时文件、符号链接和重复使用的文件名都会让路径成为糟糕的证据。

证书透明度也不能闭合这条证据链。日志条目可以表明某个公开证书已为某个名称签发，却不能说明负载均衡器、入口控制器、Web 服务器或 CDN 现在是否提供该证书。CT 是独立的签发证据，不是部署监控器。

这种区分会改变错误处理方式。签发失败意味着智能体从未获得预期凭据。部署失败意味着它已经获得一份敏感凭据，这份凭据可能已经存入系统，却没有投入服务。后一种情况通常需要清理、重试或回滚逻辑，也应触发单独的告警。

## 工件到达监听器前先验证

部署之前，确认叶证书包含请求的名称、预期有效期、预期签发者，以及与私钥匹配的公钥。这个关卡无需接触生产环境，就能发现格式错误的证书包和文件选错问题。它不能替代线上探测。

RFC 9525 对身份规则的表述很明确：客户端独立构造可接受的参考标识符，再与证书中的标识符比较。对于普通 DNS 服务，相关名称位于 `subjectAltName` 扩展中。该 RFC 明确拒绝退回到类似域名的 Common Name。只检查 `subject=CN=...` 的智能体可能批准一张现代客户端应当拒绝的证书。

用真实输出字段检查叶证书工件：

```bash
openssl x509 -in leaf.pem -noout \
  -serial -fingerprint -sha256 -dates -issuer -subject \
  -ext subjectAltName
```

典型输出形态如下：

```text
serial=03A17C...
sha256 Fingerprint=6B:19:8A:...
notBefore=Jul 24 08:15:00 2026 GMT
notAfter=Oct 22 08:14:59 2026 GMT
issuer=C=US, O=Example CA, CN=Example Issuing CA
subject=CN=api.example.net
X509v3 Subject Alternative Name:
    DNS:api.example.net, DNS:www.example.net
```

如果 OpenSSL 版本支持，可把 `openssl x509 -checkhost api.example.net -noout` 用作自动化关卡。OpenSSL 文档明确说明，`-checkhost` 用于检查证书是否与主机匹配。解析退出状态，不要解析可能随版本变化的友好输出文本。

比较公钥时不要暴露私密材料。下面的命令分别对两侧以 DER 编码的公钥求哈希，两行结果一致就表示证书和私钥配对：

```bash
openssl x509 -in leaf.pem -pubkey -noout \
  | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256

openssl pkey -in private-key.pem -pubout -outform DER \
  | openssl dgst -sha256
```

绝不要把 `private-key.pem` 的内容、命令跟踪输出或含有密钥的环境变量放入智能体上下文。让受约束的执行器完成比较，只返回摘要和退出状态。智能体需要的是操作结果，不是执行操作时使用的秘密。

## 文件写入成功后仍需激活

部署包含两个操作：以原子方式放置新材料，然后让 TLS 终止器加载它。智能体经常只执行第一个操作，却推断第二个也已发生。长时间运行的服务器通常把已解析证书保存在内存中，因此替换磁盘文件不一定会改变新握手所用的证书。

先把文件暂存到目标旁边，设置所有者和严格权限，验证配置，再重命名到位。同一文件系统上的重命名可以避免暴露只写了一部分的 PEM 文件。在实时验证通过之前，保留上一份可用文件或带版本的密钥引用，立即清理会毁掉最快的回滚路径。

激活方式取决于终止器。nginx 文档说明，HUP 会让主进程验证新配置、启动新工作进程，并让旧进程平稳退出。如果应用配置失败，nginx 会继续使用旧配置。这种行为有利于可用性，但如果智能体只记录它发送了 HUP，就会制造完美的误报。Apache 的平稳重启同样会检查语法，在旧连接结束的同时启动新一代进程。托管负载均衡器和 CDN 可能先接受更新，稍后才完成操作。

记录激活命令或 API 操作 ID、退出状态和目标进程身份。随后检查服务专用的状态或日志，确认它已接受操作。不要把固定休眠当成证明。等待 30 秒有时能掩盖最终一致性，有时却浪费 29 秒。轮询有明确期限的文档化状态更加清楚。

激活失败应在公共验证重试给服务制造大量请求前停止工作流。如果语法验证失败、进程身份意外变化，或控制平面报告终止错误，就保留旧证书并把部署标记为失败。如果激活报告成功但线上端点仍然提供旧证书，则应在限定窗口内继续探测，因为平稳替换和分布式传播都需要时间。

## 验证客户端实际收到的内容及其名称

决定性的检查会建立新的 TLS 连接，发送预期的 Server Name Indication 值，验证证书链和主机名，并记录叶证书。查看本地文件无法回答客户端实际收到什么。复用已有的 keep-alive 连接也不行，因为 TLS 握手早已选定证书。

使用 OpenSSL 同时进行 SNI 和主机名验证：

```bash
openssl s_client \
  -connect api.example.net:443 \
  -servername api.example.net \
  -verify_hostname api.example.net \
  -verify_return_error \
  </dev/null 2>/dev/null \
| openssl x509 -noout -serial -fingerprint -sha256 -dates -issuer
```

`-servername` 选项控制 SNI，许多共享监听器依靠它选择虚拟主机。`-verify_hostname` 检查身份，`-verify_return_error` 则让验证错误终止命令，而不是只打印诊断后继续。OpenSSL 的 `-showcerts` 可以显示服务器发送的证书链，但文档说明，这只是服务器提供的列表，本身不代表证书链已经通过验证。

不要为了让自动化通过而加入 `-k`、`--insecure` 或等价的跳过验证标志。curl 手册指出，普通 TLS 请求既会检查 CA 信任，也会检查证书是否与主机名匹配。不安全的探针可能确认指纹，却漏掉用户必然遇到的证书链断裂或名称错误。

只匹配指纹同样不够。服务器可能提供预期的叶证书，却缺少中间证书链，导致一部分客户端失败。完成关卡应同时要求信任路径和名称检查通过，并且叶证书身份符合预期。如果吊销检查或证书透明度策略属于真实客户端配置，也要运行该配置，不能声称基础 OpenSSL 探针涵盖了这些检查。

每次观测都应在全新进程中运行，或明确禁用连接复用。失败时记录 stderr，但在其进入智能体上下文之前先做脱敏。失败类别应保持清晰：连接、握手、信任路径、身份或指纹不一致。

## 每个 TLS 终止点都是独立目标

一个主机名不等于部署范围。范围是所有可能为该主机名终止 TLS 的位置，包括负载均衡器地址、CDN 节点、入口副本、区域前门、IPv4 和 IPv6 路径，以及客户端可以直接到达的源站监听器。一次通过的连接只能证明某条路由在某个时刻的状态。

预期端点清单应来自基础设施状态，而不是验证器偶然收到的 DNS 答案。动态 DNS 和任播让公开网络上的穷举采样很困难，因此要定义可辩护的集合：控制平面中每个已配置的监听器或证书挂载点，再加上来自重要客户端区域的探针。如果供应商管理边缘集群，就验证供应商的部署状态，并从多个位置进行外部抽样。

针对特定地址测试时，curl 的 `--resolve` 比把 URL 换成 IP 地址更安全，因为它会为 SNI 和证书验证保留原主机名：

```bash
curl --fail --silent --show-error \
  --resolve api.example.net:443:192.0.2.18 \
  --output /dev/null \
  https://api.example.net/health
```

对每个已知地址重复该请求，支持时还应包含放在方括号中的 IPv6 地址。随后对同一地址运行 OpenSSL 指纹探针，同时保留 `-servername api.example.net`。HTTP 状态成功检查的内容超过 TLS，因此应选择不会改变状态的轻量端点。证书观测本身仍是此次轮换的完成证据。

代理路径必须写清楚。开发者笔记本上的探针可能看到企业 TLS 检查证书。集群内部探针可能绕过公共 CDN。记录验证器位置和网络路径，并在签发者或指纹表明探针根本没有到达预期终止点时拒绝该观测。

滚动变更期间，旧指纹和新指纹混合出现是正常的临时状态，但不能因为多数节点已更新就宣布成功。完成要求范围内所有端点收敛，或者明确决定把失败端点移出服务。

## 让智能体输出可比较的记录

AI 智能体应产生结构化证据，让另一个程序无需解释散文即可比较。诸如“续期成功，网站看起来正常”这样的自由文本摘要会抹掉目标身份、时间戳和不一致。可以保留智能体给人的解释，但状态转换必须由类型明确的字段决定。

紧凑的事件对可以写成：

```json
{"type":"certificate.issued","rotation_id":"rot_7f2","order_id":"ord_91c","dns_names":["api.example.net"],"serial_hex":"03A17C","leaf_sha256":"6B:19:8A:...","spki_sha256":"9f4c...","not_before":"2026-07-24T08:15:00Z","not_after":"2026-10-22T08:14:59Z"}
{"type":"certificate.observed","rotation_id":"rot_7f2","host":"api.example.net","address":"192.0.2.18","port":443,"leaf_sha256":"6B:19:8A:...","chain_valid":true,"name_valid":true,"observed_at":"2026-07-24T08:19:22Z","vantage":"external-us-east"}
```

在签发、部署尝试、观测、重试、回滚和闭合过程中始终使用同一个 `rotation_id`。CA 订单 ID 和基础设施操作 ID 应保存在不同字段里。把它们合并成一个通用任务 ID，会让事故复盘变得没必要地困难。

重试需要自己的语义。CA 接受订单后，或控制平面接受挂载后，智能体可能没有收到响应。盲目重复请求会创建额外证书、触发速率限制或启动相互竞争的部署。给每次变更调用分配由轮换 ID 和阶段生成的幂等令牌，并在尝试替代操作前查询远程操作。记录响应描述的是新操作，还是已有操作的重放。

新鲜度也要有明确规则。部署前一小时通过的探针不能证明部署后的状态，即使它碰巧在前一次尝试中看到了相同指纹。要求 `observed_at` 晚于已接受的激活事件，并在闭合检查时规定最长观测年龄。使用可信工作流时钟或验证器时间戳，不要使用模型生成文本中的时间。

证书身份和服务身份应保存在不同字段中。指纹回答“这是已签发的叶证书吗”，主机名检查回答“这张证书对客户端请求的服务有效吗”，证书链结果回答“验证器能否建立可接受的信任路径”。把这些布尔值合并为 `tls_ok`，会迫使排障人员在证据消失后重新运行测试。

端点清单也应带有修订版本。如果基础设施在轮换中途添加监听器，闭合流程必须采用新修订并要求它的观测结果，或者绑定到已经批准的旧修订并创建后续事项。静默读取不断变化的列表会产生竞态，智能体可能恰好在未验证端点出现前结束。把清单修订号存入闭合结果，审查者才能重建当时“每个端点”的含义。

证据收集必须可以安全重复。探针应使用只读路由、较短连接超时和受限并发。会预热缓存或改变会话状态的健康 URL 不适合作为证书探针。TLS 握手发生在 HTTP 请求之前，因此最小请求或直接握手已经足够。端点达到数千个时仍要考虑速率限制，按目标调度探针，只重试失败项。

最后，把观测和解释分开。探针报告从握手解析出的字节和验证器结果，比较器决定观测是否满足当前轮换。这样，即使比较器有缺陷，也能用已保存证据重新计算而不触碰生产环境，同时防止智能体把不方便的错误改写成令人安心的摘要。

比较器应拒绝早于已接受部署尝试的观测、针对意外主机名或地址的观测，以及早于当前轮换的证据。它还应比较规范化的二进制指纹，而不是展示字符串，因为冒号和字母大小写可能不同。原始展示值仍应保留给运维人员。

不要让智能体自行决定省略哪些证据字段。在工作流中定义模式，缺少必需证据时关闭失败。签名事件可以加强来源证明，但签名无法修复薄弱陈述。即使“命令退出码为零”带有完美签名，它仍没有说明客户端收到了哪张证书。

失败的观测也应作为证据保存。错误指纹、过期证书或名称不匹配能解释任务为何保持开放，并帮助区分传播缓慢和挂载错误。用最终成功覆盖失败，会留下无法解释故障窗口的审计记录。

## 常见故障是部署分裂

部署分裂通常悄无声息地开始。智能体为 `api.example.net` 获取新证书，记录 ACME 订单有效，更新名为 `api-tls` 的密钥，并收到编排 API 的成功响应。随后它检查密钥元数据，看到新的资源版本，就关闭了工单。

公共主机名解析到两个负载均衡器地址。一个控制器监视生产命名空间中的 `api-tls` 并加载新证书。另一个监听器引用旧命名空间中同名的密钥。没有 API 调用失败，因为智能体更新了真实对象，只是没有更新控制该主机名的每个对象。

大多数用户到达第一个地址并看到新证书。另一条流量路径仍然提供旧叶证书。临近过期时，故障表现为间歇出现，重试似乎能解决问题，因为 DNS 或负载均衡选择会把下一次连接送到别处。运维人员把时间浪费在怀疑缓存上，但 TLS 证书是在每次新握手中选择的。

双向证明会立即暴露这种分裂。签发记录中的指纹是 `6B:19:8A:...`，对两个地址的观测却分别返回该值和 `41:D0:72:...`。轮换保持在 `deployed_unverified` 状态，不一致的观测会指出需要修复的地址。

“在浏览器中检查证书”这个流行建议不适合作为自动化方案。浏览器可能复用连接、隐藏选中的地址、使用不同信任库，或者位于检查代理之后。事故期间它可以作为独立的抽查手段，但不应决定完成状态。固定地址且验证主机名的探针能产生智能体可以比较和重复的证据。

修复旧挂载后，再用新连接探测两个地址。保留第一次不一致、纠正部署事件和通过的观测。这段历史能证明发生了什么变化，以及工作流最终为何关闭。

## 特权操作需要窄权限和持久归属

证书轮换让智能体接触 CA API、密钥存储和生产重载路径，因此执行器应暴露范围狭窄的操作，而不是原始凭据。合适的边界是“提交此 CSR”“把指纹为 X 的工件挂载到监听器 Y”或“通过地址 A 探测主机 H”这样的操作。把可重复使用的 API 令牌交给模型只会增加风险，不会改善其推理。

每个操作的结果应包含远程操作 ID 和证据字段，同时排除凭据和私钥字节。分别授予签发、部署、验证和回滚权限。一个已经受攻击或陷入混乱的智能体即使能申请证书，也不应自动获得把它挂载到所有位置的权限。

Sallyport 可以把 API 和 SSH 凭据保存在加密保险库中，让智能体通过应用执行 HTTP 和 SSH 操作，凭据不会进入智能体上下文。它的 Activity 和 Sessions 日志来自一份只写不可读、加密且带哈希链的审计日志，适合把签发、部署和验证调用归属到具体主体，但这些调用日志不能冒充证书观测结果。

把人工批准放在影响增大的位置。针对预先声明的主机名和监听器进行例行续期，可能适合会话授权。增加新名称、更换密钥、操作通配符或触发回滚，则适合逐次批准。批准内容应绑定显示的目标和工件指纹，“允许证书更新”过于模糊，无法发现错误监听器。

审计验证器时要像审计部署器一样认真。如果同一个不受约束的智能体既能修改端点清单、执行部署，又能把自己的不完整样本标记为充分，证据契约就只是装饰。应从受控来源计算预期目标，让闭合检查拒绝缺失的观测。

## 只在收敛后关闭，并准备好回滚

闭合操作应对不可变的签发、部署和观测事件执行确定性比较。不要询问智能体轮换是否“看起来完成”。当所有预期端点都通过有效且名称匹配的证书链提供已签发叶证书时，记录准确的判断结果并关闭轮换。

为传播设置期限，不要掩盖它。在允许窗口内，只重试尚未收敛的端点，并使用有上限的退避。到达期限后，让轮换失败并附上不一致目标。是否回滚取决于有效期和服务健康状况。尚有充足有效期的旧证书暂时可能更安全，而已经过期或吊销的旧证书则要求继续向前修复。

回滚承担相同的证明责任。重新挂载以前的工件只是一次部署尝试，工作流必须在线观测到旧指纹后才能宣布恢复。如果事故涉及私钥泄露，回滚到已泄露证书并非恢复，应使用新密钥签发，并按事故计划吊销旧证书。

在连接排空和回滚策略允许清理前，保留两份证书。平稳重载可能让旧工作进程继续服务已建立连接，但新探针应到达新一代进程。通过密钥系统的受控路径删除旧私密材料，再把清理记录为完成后的操作，而不要把它当成提供新证书的前置条件。

第一项改动很小，却必须严格：取消“证书已下载”或“密钥已更新”这样的终态成功。证据一端必须有已签发指纹，另一端必须有完整端点清单中经过主机名验证的新指纹。智能体可以执行所有步骤，但只有证据匹配时，才有资格说轮换已经完成。
