# 已撤销的 SSH 主机密钥还能让代理进入吗？

已撤销的 SSH 主机密钥应当在用户身份验证之前阻止代理，无论代理使用什么名称或地址。如果测试只用常用主机名尝试一次，它证明的东西远比看起来少。SSH 配置可以把多个名称映射到同一台服务器，非默认端口会改变 known-hosts 的查找标记，而复用连接的客户端无需再次检查主机密钥就能打开另一个通道。

应把撤销视为加密身份的属性，然后测试所有能够呈现或复用该身份的路径。这意味着需要密钥撤销列表、干净的客户端状态、明确列出的连接变体，以及针对现有连接的单独处理流程。密钥变更警告有用，但它不是同一种控制。

## 撤销必须跟随密钥，而不是主机名

主机密钥在 SSH 密钥交换期间验证服务器身份。RFC 4253 描述了客户端接收服务器公钥、检查它是否属于目标服务器，并验证服务器对交换数据所作签名的过程。撤销应当在这里生效：服务器呈现被禁止的公钥时，客户端必须在考虑用户密钥、密码或远程命令之前拒绝它。

OpenSSH 提供两种看起来相似、覆盖范围却不同的机制。known-hosts 文件中以 `@revoked` 开头的行，把主机模式和已撤销密钥绑定在一起。连接使用的查找名称与该行匹配时，它才会生效。别名或另一种地址写法可能匹配不到该模式，即使服务器呈现的是同一把密钥。OpenSSH 的 `sshd` 手册规定，匹配到的撤销记录绝不能被接受，但“匹配”二字不能忽略。

客户端的 `RevokedHostKeys` 选项更适合事故响应中的撤销。它指向一个逐行列出公钥的文本文件，或一个 OpenSSH 密钥撤销列表 (KRL)。OpenSSH 会把服务器呈现的身份与该文件对照，不受用户输入 `build-test`、`build-test.example` 还是 IP 字面地址的影响。我用 `@revoked` 记录处理指定主机的 known-hosts 状态；当某个加密身份必须在任何地方失效时，我用 `RevokedHostKeys`。

这两种机制都不能与 `StrictHostKeyChecking=yes` 混为一谈。严格检查会拒绝未知主机和密钥已变更的主机，从而阻止静默的首次使用信任。它不会把一把原本受信任的密钥声明为已撤销。一个测试如果删掉旧的 known-host 记录，然后把未知主机错误当成成功，它测到的只是空的信任数据库，而不是撤销。

## 构建一台可以故意破坏的测试主机

使用一次性的 SSH 服务器，并为它准备专用端口、主机密钥、用户账户和客户端目录。不要在共享的预发布主机上演练撤销。你需要替换密钥、停止进程和关闭控制套接字，而不必担心还有谁依赖它们。

下面的目录结构把证据集中在一个位置。不同操作系统上的 `sshd` 路径和权限模型不同，因此应在团队已经用于 SSH 集成测试的容器、虚拟机或测试夹具中运行守护进程。

```bash
set -eu
work="$PWD/ssh-revocation-fixture"
mkdir -p "$work/client" "$work/server"
chmod 700 "$work/client"

ssh-keygen -q -t ed25519 -N '' \
  -C revoked-host-test -f "$work/server/ssh_host_ed25519_key"
ssh-keygen -q -t ed25519 -N '' \
  -C agent-test-user -f "$work/client/id_ed25519"

ssh-keygen -lf "$work/server/ssh_host_ed25519_key.pub"
```

最后一条命令会输出形状如下的一行：

```text
256 SHA256:<base64-fingerprint> revoked-host-test (ED25519)
```

把这个指纹写入测试日志。绝不要从工单复制一个指纹，再假定夹具中的文件含有同一把密钥。应从测试服务器实际加载的公钥文件计算指纹。

配置测试守护进程，让它只使用这一把主机密钥。用客户端公钥填充它的 `AuthorizedKeysFile`，禁用密码，并把它绑定到环回地址或隔离地址。最小夹具还应设置明确的 `PidFile` 和详细日志。目标是可重复：一个监听器、一个主机身份和一条身份验证路径。

加入撤销之前，先让一次严格连接成功，并运行一个无害的标记命令，例如 `printf BASELINE_OK`。应通过经过身份验证的配置流程获取主机密钥，而不要在你正要保护的同一条网络路径上，使用未经审查的 `ssh-keyscan`。`ssh-keyscan` 只会取回密钥，不会证明密钥由谁提供。

## 把已撤销身份放进 KRL

KRL 让测试关注密钥，而不是目标名称的拼写。直接用夹具加载的公钥生成 KRL，并在任何网络连接之前查询它：

```bash
work="$PWD/ssh-revocation-fixture"
ssh-keygen -k -f "$work/client/revoked-hosts.krl" \
  "$work/server/ssh_host_ed25519_key.pub"

if ssh-keygen -Q -f "$work/client/revoked-hosts.krl" \
  "$work/server/ssh_host_ed25519_key.pub"; then
  printf '%s\n' 'FAIL: fixture host key is not revoked'
  exit 1
else
  printf '%s\n' 'OK: fixture host key is revoked'
fi
```

这里反转的退出状态很容易让人出错。`ssh-keygen` 手册规定，`-Q` 查询到任何已撤销密钥或遇到错误时返回非零值；零表示所有被查询密钥都没有撤销。不要丢弃 stderr，并在外围测试日志中区分“无法读取 KRL”和“有效的撤销匹配”。

现在把该文件放到代理所用客户端配置的最广作用域：

```sshconfig
Host *
    BatchMode yes
    StrictHostKeyChecking yes
    UserKnownHostsFile ./ssh-revocation-fixture/client/known_hosts
    RevokedHostKeys ./ssh-revocation-fixture/client/revoked-hosts.krl
    ConnectTimeout 5
    ConnectionAttempts 1
```

如果 `RevokedHostKeys` 文件不存在或无法读取，OpenSSH 会有意采取关闭式失败：拒绝该配置覆盖的所有目标完成主机身份验证。这比忽略文件安全，但也可能让损坏的部署看起来像成功的撤销测试。上面的预检查询能证明文件可读，而且含有目标密钥。

每行一个公钥的纯文本文件也能工作。当密钥或主机证书很多时，KRL 的复杂度才有回报，因为它可以撤销普通密钥、证书序列号、证书密钥 ID，或由某个 CA 签发的密钥。选择分发和检查流程能够处理的最简单格式，然后测试实际部署的格式。

## 按主机名限定的标记需要对抗性测试

在夹具中保留一个故意较弱的测试，用来说明为什么需要 KRL。构建第二份客户端配置，不设置 `RevokedHostKeys`，只依靠下面这条 known-hosts 记录：

```text
@revoked revoked-lab ssh-ed25519 AAAA...fixture-public-key...
```

以 `revoked-lab` 连接，并确认 OpenSSH 拒绝匹配的记录。然后以 `127.0.0.1` 连接同一个监听器，同时为 `[127.0.0.1]:2222` 准备一条单独受信任的 known-host 记录。如果这种地址形式成功，夹具就复现了覆盖缺口：密钥没有变化，但查找名称不再匹配撤销标记。这个演示只能放在隔离环境中，因为它的目的就是证明某项控制并不充分。

不要粘贴示例中缩写的 `AAAA...` 值。应从真实公钥文件构造记录，确保算法和 base64 密钥数据完全一致。安全的夹具命令可以读取公钥文件中的算法与密钥字段，并在前面加上标记和目标主机模式。用 `ssh-keygen -F revoked-lab -f known_hosts` 验证结果；再对字面地址执行查询，表明那里没有这条记录。

这个失败解释了为什么把所有已知别名加入 `@revoked` 行并不可靠。每次有人添加短名称、DNS 记录、本地 hosts 文件记录、非默认端口、隧道别名或 `HostKeyAlias` 时，清单都会变化。哈希后的 known-host 名称更难人工审查，不过 `ssh-keygen -F` 仍能搜索。通配符标记虽然扩大匹配范围，但某个目标仍必须落在该模式内才会撤销公钥，而且意图范围会更难审核。

KRL 测试应使用同一台服务器、同一把用户密钥、同一条网络路径和同样的受信任 known-host 记录。只改变撤销机制。启用 `RevokedHostKeys` 后，`revoked-lab` 和 `127.0.0.1` 在呈现夹具密钥时都必须失败。这个配对实验把关于别名的抽象警告变成审核者可以看到并复现的故障。

还要测试配置优先级。OpenSSH 按“最先取得的值”处理配置，所以前面的主机专用设置可能抵消后面预期的默认值。对每个目标运行 `ssh -G`，逐字节比较解析后的 `revokedhostkeys` 路径。配置审核只是在某个文件中找到了 `RevokedHostKeys` 这个词，并不能证明代理真的使用它。

系统配置与用户配置也可能分叉。工程师的 `/etc/ssh/ssh_config` 也许指向组织统一的 KRL，但代理可能用 `ssh -F private-config` 启动，而这会让 OpenSSH 使用另一份配置文件。反过来，干净的 CI 账户可能通过测试，却没有模拟生产运行器通过命令行注入选项的行为。应在代理动作边界捕获完整调用，并在这里明确撤销文件路径。

权限也属于验收测试。根据 OpenSSH 手册，`RevokedHostKeys` 读取失败会拒绝所有主机身份验证。连接前以代理的操作系统用户检查文件，并查询目标密钥。这样才能分清三个都表现为 SSH 命令失败的结果：有效的撤销匹配、缺失或不可读的撤销文件，以及格式损坏的 KRL。

分发还有时间差问题。如果多台运行器都能启动代理，只在一台机器发布 KRL 并不算完成撤销。记录 KRL 的内容摘要，并要求每台运行器在允许新的 SSH 工作之前报告该摘要。使用原子替换流程，避免读取者看到只写了一部分的文件。分发后，应在每一种不同的客户端镜像或配置类别上运行反向测试，而不只是生成 KRL 的那台机器。

回滚也必须有可测试的含义。因为工单关闭就从 KRL 删除指纹，可能在被盗私钥仍然存在时恢复信任。更好的做法是替换服务器身份、分发新的信任记录，并保留旧身份的撤销状态。如果策略允许以后移除撤销，必须提供旧私钥无法再出现的证据，并保留一个呈现该旧密钥的回归测试。

因此，一个已撤销身份的验收记录应包含公钥指纹、公钥算法、KRL 摘要、已测试的目标形式、每种形式捕获到的有效配置，以及新握手结果。复用会话的结果要单独记录，因为它回答的是另一个问题。审核者才能判断故障出在身份匹配、路径覆盖、配置、分发还是传输清理。

## 新连接必须在标记命令运行前失败

强制第一个反向用例建立新的传输。即使用户 SSH 配置启用了连接共享，也要在命令行禁用它；让客户端保持非交互；在远程命令中放入一个一旦执行就很醒目的标记。

```bash
work="$PWD/ssh-revocation-fixture"
config="$work/client/config"
log="$work/client/direct.stderr"

set +e
ssh -F "$config" \
  -o ControlMaster=no -o ControlPath=none \
  -i "$work/client/id_ed25519" \
  -p 2222 testuser@127.0.0.1 \
  'printf REVOKED_KEY_EXECUTED' \
  >"$work/client/direct.stdout" 2>"$log"
rc=$?
set -e

if [ "$rc" -eq 0 ]; then
  printf '%s\n' 'FAIL: SSH accepted a revoked host identity'
  exit 1
fi
if grep -q REVOKED_KEY_EXECUTED "$work/client/direct.stdout"; then
  printf '%s\n' 'FAIL: remote sentinel ran'
  exit 1
fi
printf 'PASS rc=%s\n' "$rc"
```

断言结果，而不是某一条精确的英文错误文本。不同 OpenSSH 版本和操作系统可能使用不同措辞。某个用例失败时，保存另一次带 `-vv` 的详细客户端输出，并查找服务器呈现的指纹和撤销诊断。TCP 超时、端口拒绝、未知主机、用户密钥缺失或账户被拒也都会返回非零值，但它们都不能证明主机密钥撤销阻止了连接。

服务器日志提供断言的另一半。客户端应在密钥交换阶段断开，早于服务器记录成功的用户身份验证，也早于会话启动。如果夹具提供的证据无法区分这些阶段，它对这个测试来说太不透明。

使用一个可读的空 KRL 或另一把未撤销的服务器密钥运行一次对照用例。该连接应当执行到 `BASELINE_OK`。没有正向对照的反向测试常常因为 DNS、路由、文件权限或夹具账户本来就坏了而“通过”。

## 每一种别名和地址形式都需要自己的用例

KRL 应当跨名称拒绝密钥，但配置解析仍可能让某种拼写绕过撤销选项。代理能够生成的每一种目标形式，都要检查其有效客户端配置和实际网络结果。

先建立一个与夹具绑定的小型矩阵：

1. 已配置的别名，例如 `revoked-lab`，其 `HostName` 指向测试地址。
2. 完全限定主机名，以及本地解析规则接受的任何短主机名。
3. IPv4 字面地址；如果监听器支持，也包括 IPv6 字面地址。
4. 非默认端口的 known-host 标记，在 known-hosts 工具中通常写成 `[host]:port`。
5. 设置了 `HostKeyAlias` 的别名，因为该选项会替换用于查找主机密钥和验证主机证书的真实主机名。

把这些变体放进数据文件或测试函数，不要重复粘贴 shell 代码块。对每个用户可见名称运行 `ssh -G destination`，保存解析后的 `hostname`、`port`、`hostkeyalias`、`userknownhostsfile`、`revokedhostkeys`、`proxycommand`、`proxyjump`、`controlmaster` 和 `controlpath` 值。`ssh -G` 会展开配置而不连接，因此能够发现某个 `Host` 段在不易察觉的情况下覆盖了 KRL 路径。

`Hostname` 和 `HostKeyAlias` 的职责不同。`Hostname` 选择网络目标。`HostKeyAlias` 选择 OpenSSH 在读写主机密钥以及验证主机证书时使用的名称。两者都不应削弱全局 `RevokedHostKeys` 检查，但都会改变 OpenSSH 考虑哪条受信任的 known-host 记录。这就是为什么只使用按主机名限定的 `@revoked` 行不适合事故控制。

如果启用了规范化，也必须添加对应测试。`CanonicalizeHostname yes` 可以追加配置的域名，并让 OpenSSH 使用改写后的目标重新处理配置。后面匹配的配置段可能选择另一份 known-hosts 文件，或漏掉撤销文件。测试短名称输入和得到的规范名称，然后确认两份有效配置指向同一个 KRL。

不要把 `CheckHostIP=yes` 当作别名覆盖。OpenSSH 手册说它会在 known-hosts 中额外检查目标 IP，而且使用代理命令时不可用。它可以检测关联变化，但不能取代以密钥为中心的撤销文件。代理和跳板路径仍需对最终目标做完整测试，并为每台跳板主机单独配置信任。

## 缓存连接是事故响应的一条边界

仍然存活的 SSH 复用主连接已经完成服务器身份验证。通过它的控制套接字再开一个会话，不会建立新的 TCP 连接，也不会再次交换主机密钥，因此新安装的 KRL 无法追溯拒绝该密钥。把这种现象称为撤销绕过会混淆模型。这里复用的是仍然存在、此前已经完成身份验证的传输。

应在夹具中证明这一行为，不要假设重启会自动清理。先在主机密钥仍被允许时建立复用主连接。用 `ControlPersist` 保持连接，随后把密钥加入 KRL，再证明 `ssh -O check` 仍能找到主连接。通过该套接字发送的命令可能仍会运行。把它记录为响应处理之前的预期对照，而不是撤销通过的结果。

随后执行响应动作：阻止代理发起新工作，终止相关的控制主连接，并撤销或停止拥有这些连接的代理会话。对于夹具专用套接字，本地命令形状如下：

```bash
socket="$PWD/ssh-revocation-fixture/client/cm-testuser-127.0.0.1-2222"
ssh -S "$socket" -O exit testuser@127.0.0.1
```

套接字消失后，使用 `ControlMaster=no` 和 `ControlPath=none` 重复连接；它必须因为密钥已撤销而失败。清理完成后还要测试代理平时使用的复用配置。`ControlMaster auto` 之类的机会式模式在没有主连接监听时会退回新连接，而这个新连接必须命中 KRL。

残留的套接字文件不是已经验证过的连接。OpenSSH 会尝试套接字，发现没有主连接监听，然后可能退回普通连接。把这个用例留在套件里，因为崩溃后经常会留下旧文件。安全结果仍应是在新握手阶段因撤销而失败。

因此，在正在发生的事故中撤销主机身份需要两种控制：拒绝未来的密钥交换，并终止撤销前已完成身份验证的传输。测试若只覆盖其中一项，就会给代理留下一条新路径，或一条从未关闭的旧路径。

## 一台服务器可以呈现多个身份

服务器经常加载多把主机密钥，或同时加载主机证书及其底层密钥。撤销一个指纹并不一定让端点不可达。在协商期间，客户端和服务器会选择双方支持的主机密钥算法；另一把受信任且未撤销的密钥仍可能验证同一台服务器。

先确定事故描述的真实含义。如果泄露的是一把主机私钥，客户端必须拒绝该密钥，而另一把独立保护的主机密钥在审核后可能继续使用。如果机器本身已经失陷，就撤销它能够呈现的所有主机身份，包括证书，并在机器重建前移除信任。只在工单里写“主机已撤销”却不列出身份，等于把决定交给算法协商。

应从守护进程配置和受信任记录中盘点夹具，而不是依靠一次扫描。通过限制 `HostKeyAlgorithms`，对每种已启用的主机密钥算法分别测试。已撤销密钥必须失败。每一个允许保留的身份都应有单独的正向用例和记录好的指纹。

主机证书又带来一个区别。你可以把证书作为公钥对象撤销，也可以在 KRL 中撤销证书序列号或密钥 ID，还可以撤销授权一类主机的 CA。这些选择影响的范围不同。OpenSSH 的 `ssh-keygen` 手册记录了序列号、密钥 ID、公钥和指纹的 KRL 指令；应使用服务器实际呈现的证书文件查询最终 KRL。

`UpdateHostKeys` 同样需要关注。OpenSSH 可以在主机使用已受信任的普通密钥完成身份验证后，学习服务器的其他密钥。这有助于有计划的轮换，但以前学到的替代密钥仍会成为候选项，除非撤销决策把它们纳入范围。对每个名称和 `[name]:port` 形式使用 `ssh-keygen -F` 导出测试客户端的 known-hosts 记录，再把每一把公钥与事故范围比较。

## 通过代理的真实路径运行反向测试

工程师账户下的终端测试，不能证明自主代理使用相同的 SSH 程序、配置、主目录、解析器、代理路径或控制套接字。最终测试套件必须调用代理实际调用的动作边界，并使用它在生产中收到的同样环境启动。

让测试工具为每个变体返回结构化证据：目标标签、解析后的主机名和端口、服务器呈现的指纹、KRL 摘要、连接共享状态、退出状态、标记是否出现，以及服务器在哪个阶段关闭尝试。不要把秘密写进该记录。有效断言很简单：已撤销身份确实出现，客户端识别出它已撤销，此后没有用户身份验证或命令执行。

隔离测试环境可以暴露意外依赖。在夹具中明确设置 SSH 配置路径、known-hosts 文件、KRL、身份文件和控制路径。如果代理不应借用无关的用户密钥，就清除或替换 `SSH_AUTH_SOCK`。限制连接时间，防止 CI 卡住。失败时保存 stderr 和服务器日志作为构件；通过报告可以只保留它们的哈希和决定性行。

如果代理通过 Sallyport 发出 SSH 动作，就应在反向测试中走这条路径：它捆绑的无状态 `sp-ssh` 辅助程序执行 SSH 动作，而 Sessions 和 Activity 日志分别记录代理运行和单次调用。如果测试也覆盖证据完整性，可用 `sp audit verify` 离线验证审计链；主机密钥拒绝仍必须来自真实动作所使用的 SSH 信任配置。

不要为了自动化方便而削弱客户端。`StrictHostKeyChecking=no`、空的 known-hosts 目标，或把诊断丢到 `/dev/null`，都会把安全测试变成连通性探测。`BatchMode=yes` 只会移除交互提示，并不能弥补缺失的信任材料。

## 撤销测试应当只因一个原因失败

把矩阵放进 CI，但让夹具足够小，以便诊断。先用允许的身份运行正向对照，预检 KRL，启动监听器，然后运行新连接变体。复用测试应放在单独的任务或阶段，因为它的设置和预期行为不同。最后停止监听器，并断言没有留下夹具主连接或套接字。

如果任何目标执行到了标记、任何有效配置缺少要求的撤销文件，或服务器提供了测试清单未分类的身份，就拒绝构建。没有结论的运行也必须拒绝。超时或不可读的 KRL 说明测试损坏，即使 SSH 命令返回了非零值。

在测试报告中保留预期指纹和 KRL 摘要。轮换改变夹具身份时，审核中应看到两个值同时变化。这一点小小的阻力可以防止一种常见故障：服务器换了新密钥，测试却还在撤销一个已经废弃的公钥文件。

以无法提示的代理进程运行套件。批准对话框、密码请求或主机确认问题都可能让任务一直等待，最后由通用超时掩盖原因。`BatchMode=yes` 应让这些路径立即失败，而正向对照证明夹具不需要交互。最外层任务仍应设置硬超时，但绝不能把该超时算作撤销证据。

为支持的每个客户端版本保留一份脱敏的详细跟踪。它让维护者可以参考配置展开、连接复用、密钥交换、主机密钥选择和拒绝的顺序。操作系统更新改变 OpenSSH 行为或措辞时，把新跟踪与参考比较；只有在指纹和失败阶段仍一致后，才能更新断言。如果团队把每个新失败都归为无害的输出变化，安全测试很快就会失效。

必须保留的棘手结果是“仍存活的主连接”。它提醒响应人员，分发 KRL 并不等于撤销会话。关闭现有传输，证明下一次尝试确实进行了密钥交换，并观察客户端拒绝你标记的准确指纹。做到这里，才能说明别名、缓存状态和地址拼写都无法再把代理带回去。
