# 计时侧信道测试应将拒绝视为结果

代理不需要拿到秘密值，也能了解到危险信息。如果它可以反复调用操作网关，再按耗时对结果分类，就可能知道保险库是否锁定、某个凭据名称是否存在、请求是否通过授权，或网关是否已经连接到远程服务。

这些信息已经足以改变它的行为。知道某个凭据存在的编程代理，可能会持续请求访问对应服务。遭到入侵的代理则可以把结果当作侦察信息。单次调用中的泄漏看起来也许无关紧要，但当调用方能够控制输入、重复请求并精确测量时间时，它就会变得有用。

MITRE 将这类问题记录为 CWE-208，即可观察的计时差异。它的描述范围很广，也很准确：当操作耗时存在可观察差异时，与安全相关的内部状态就可能泄漏。常见例子是密码检查。操作网关也有一个不太熟悉但本质相同的版本：调用方已经处在开发者的工作流中，还可以不知疲倦地发起大量结构化工具调用。

解决办法不是让每个操作都精确耗时相同。只要涉及 HTTP 请求或 SSH 连接离开本机，这个承诺通常就不现实。更实际的做法是找出代理不应区分的状态，让这些状态经过相近的本地处理，并保留差分测试套件，及时发现新的快速路径。

## 把保险库状态视为调用方不应推断的数据

保险库锁定、缺少凭据、操作被拒绝，以及远程凭据无效，是不同的运维状态。但它们不必自动变成代理需要知道的不同事实。

先写下每个调用方允许知道什么。这听起来很明显，但团队经常跳过这一步，任由实现顺序决定披露策略。提前查找保险库会暴露凭据是否存在。提前检查授权会暴露某个进程是否拥有获批会话。立即返回本地错误会暴露保险库处于锁定状态。每个捷径单独看都可能合理，但合在一起就给了调用方一个状态探针。

对于操作网关，请分别回答这些问题：

- 这个进程是否可以请求某项操作？
- 保险库当前是否可用，可以执行该操作吗？
- 该操作是否存在凭据映射？
- 远程服务是否接受了该凭据？
- 调用方能否直接看到这些答案，还是只能收到操作结果？

关键区别在于**授权状态**和**凭据状态**。授权回答某次代理运行是否可以调用某项操作。凭据状态回答网关是否拥有执行该操作所需的材料。如果内部把二者混在一起，计时结果就可能意外告诉代理某个凭据存在，因为网关在批准之后检查了它。

Sallyport 的保险库网关是绝对的：保险库锁定时，所有操作都会被拒绝。这是一条清晰的安全边界，但仍然值得进行计时测试，因为调用方可以观察拒绝是如何产生的。目标不是假装锁定的保险库能够完成远程请求，而是确保本地拒绝处理不会形成容易重复的特征，从而泄露调用方不需要知道的信息。

OWASP 在 Authentication Cheat Sheet 中也说明了同一点。如果某条失败路径执行了更多工作，仅靠统一的错误文本并不能消除枚举泄漏。虽然这份指南讨论的是身份验证，但工程上的启示同样适用：不同执行路径外面即使包着相同消息，经过时间仍然会泄漏差异。

## 四种结果需要两套不同的计时契约

把所有结果都强行拉齐，是一个很常见的建议，因为说起来简单。但只要网关可以访问网络，这种做法就不对。你需要两套计时契约，而不是一个无法实现的统一时长。

第一套契约针对**本地结果**。这些状态下，网关应在发送请求前拒绝操作，例如保险库锁定、会话授权缺失或被拒绝、操作没有配置凭据，或逐次审批被拒绝。对于不应向代理披露更多信息的状态，应让它们经过统一的本地响应封装。

第二套契约针对**已发送的结果**。有效凭据和无效凭据都可以到达同一个 HTTP 测试服务器或 SSH 测试主机。它们的总耗时包含传输、连接复用、服务器处理和响应传递。你无法让公共互联网保持恒定时间，但可以确保网关以相近的路径注入、发送、记录并映射两类结果，再用受控服务器在测试期间保持远程部分稳定。

这会得到一个有用的矩阵：

| 结果 | 请求是否离开网关？ | 计时测试应该与什么比较？ |
| --- | --- | --- |
| 保险库锁定 | 否 | 其他不应泄露更多状态的本地拒绝 |
| 授权被拒绝 | 否 | 其他本地拒绝，在人工交互前测量 |
| 缺少凭据 | 否 | 保险库可用时的本地失败，在披露策略要求时使用相同的响应封装 |
| 无效凭据 | 是 | 同一受控上游中的有效凭据 |
| 有效凭据 | 是 | 无效凭据和其他远程错误变体 |

不要把缺少凭据直接与第三方 API 的成功调用比较，然后因为前者更快就宣布失败。这个测试只证明没有离开本机的请求比互联网请求快，这一点你本来就知道。

应分别测试这些边界。本地调用方应该难以区分你打算隐藏的本地状态。已发送的调用方应该难以仅凭网关开销区分测试用的好凭据和坏凭据。测试服务器可以在相同延迟后故意返回不同的状态码，让语义结果保持不同，同时让计时实验仍然有效。

还有一个硬性限制：如果调用方收到“缺少凭据”这样的详细错误，它就不再需要通过计时来知道凭据缺失。计时防御无法挽救一个随意报告秘密状态的 API。先决定允许的错误语义。

## 调用方可以重复时，快速失败就会变成预言机

计时泄漏很少表现为戏剧性的差异。更常见的情况是，有人在重构时加入了一个看似合理的提前返回。

设想一个网关按以下顺序执行：

1. 解析代理请求。
2. 在保险库索引中查找指定的凭据。
3. 检查代理进程是否拥有会话批准。
4. 构造并发送请求。

缺少凭据会在第二步退出。凭据存在但进程未获批准时，会继续到第三步。两种情况下都可以返回相同的外部响应：“操作不可用”。但第二条路径还执行了保险库索引命中、授权查找、审计准备，可能还会准备审批卡片。调用方可以对每种输入运行许多次，然后给结果排序。

当代理可以选择凭据别名时，泄漏会更严重。它可以探测由 `staging`、`production`、`deploy` 等名称组成的词表，也可以探测它从代码仓库中发现的名称。它不需要成功执行操作，只需要把结果分成快和慢两组。

防御顺序取决于你的披露策略，但更安全的结构大致如下：

1. 在不依赖秘密的分支中验证请求。
2. 应用绝对的保险库网关。
3. 应用进程或会话授权。
4. 只有在调用方通过了应当先于这些信息的控制后，才解析操作和凭据。
5. 对必须保持不可区分的本地失败，执行相同的日志记录、错误塑形和响应完成工作。

不要把这理解成要为未授权进程执行秘密操作。你不应仅仅为了消耗时间，就解密凭据或构造真实的授权请求头。重点是在可观察边界上避免依赖状态的捷径，而不是把拒绝变成秘密访问。

一个常见的错误修补方式是在每次拒绝前执行 `sleep(100ms)`。它会让演示图表更好看，却带来三个问题。调度器噪声和缓存行为会让实际时长仍然不同。延迟会增加合法用户的负担。更重要的是，如果底层路径仍然不同，调用方可以通过平均随机或固定填充来消除延迟影响。让必要工作趋于一致，比在不相等的工作上简单添加延迟更可靠。

## 在代理真正能看到的边界测量

网关内部的埋点有助于诊断，但不是主要的安全测量。代理观察到的是从发送工具调用到收到最终结果之间的时间。端到端差分测试应从这里开始，也在这里结束。

使用一个像代理客户端那样工作的专用测试驱动程序。它应从已知的进程身份开始，发送一个请求，等待一份完整响应，记录单调时钟的耗时，并在被测请求之外保存结果标签。运行期间不要打印标签。控制台输出、追踪导出器和调试日志可能会显著影响短暂的本地路径，反而掩盖你要寻找的回归。

一个实用的样本记录不应只有一个数字：

```json
{
  "case": "vault_locked",
  "sequence": 184,
  "elapsed_us": 12746,
  "result_class": "local_denial",
  "connection_mode": "fresh",
  "run_id": "test-run-7"
}
```

使用单调时钟。时间同步运行时，墙上时钟可能跳变，不适合进行亚秒级比较。如果平台支持，就记录微秒或纳秒，然后在人们查看结果时报告毫秒。文件中的精度不代表测量准确，但它能避免你在分析前丢掉信息。

收集样本前先进行预热。第一次调用可能会启动应用、加载代码、初始化保险库句柄、创建连接池或填充缓存。这些影响在实际运行中确实存在，但常常会淹没你需要检查的稳态差异。如果冷启动对代理可见，就单独测试冷启动。不要因为第一次调用让图表变得难看，就悄悄丢弃这种不方便的行为。

随机改变各个案例的顺序。如果先运行 500 次锁定保险库调用，再运行 500 次缺少凭据调用，温度状态、垃圾回收、后台活动和连接复用都会变成干扰因素。使用带种子的随机排列交错运行案例，这样失败结果才能复现。

当客户端协议支持时，也要交替使用新建连接和复用连接。使用热连接时消失的泄漏，在代理创建短生命周期客户端时仍然可能有影响。只在复用连接时出现的泄漏，则可能暴露一个以凭据是否存在为键的缓存。

## 构建受控上游，将语义与延迟分开

无效凭据是团队最容易测试错误的案例。他们把测试工具指向真实服务，发送一个故意错误的令牌，再与成功调用比较。提供商的速率限制、区域路由、TLS 会话恢复和滥用控制都会成为结果的一部分。这不是网关计时测试。

构建一个由你控制的小型固定测试服务器。它应从网关注入的同一种请求头格式中读取已知的测试凭据，等待固定的工作时长，然后对好凭据和坏凭据返回不同的响应正文和状态。无效路径不能走捷径。

例如，下面这样的固定测试协议就足够了：

```text
Request header: Authorization: Bearer test-good
Response: 200 {"fixture":"accepted"}

Request header: Authorization: Bearer test-bad
Response: 401 {"fixture":"rejected"}

Both requests: wait until the same server-side target duration has elapsed
```

目标时长应长于普通的本地调度抖动，同时又要足够短，让套件能够快速运行。根据自己的测试机器进行测量后选择它，不要照搬博客文章中的数字。重要的是，两个固定测试分支在语义结果不同之前，都执行相同的解析、计时、等待和响应路径。

对于 HTTP，如果想隔离网关和客户端，就在回环地址上运行固定测试服务。如果想观察普通网络噪声对检测的影响，就在第二个任务中使用受控远程主机。分开保存这两类报告。回环结果告诉你本地实现是否发生了变化。远程结果告诉你，在现实传输变化下测试是否仍然足够敏感。

对于 SSH，使用测试账户和一条经过身份验证后会以已知方式退出的命令。不要反复对生产跳板机进行失败测试。SSH 服务器可能会有意放慢失败请求、锁定账户，或增加日志工作。这些都是合理的防御，但会让你的测量变成关于服务器的故事，而不是关于网关的故事。

Sallyport 使用 bearer、basic 或自定义请求头注入来发送 HTTP 请求，并使用内置的无状态 `sp-ssh` helper 执行 SSH。这是两条不同的通道，需要不同的固定测试。HTTP 请求头路径的结果看起来接近，并不能说明 SSH 设置路径没有问题，因为后者还要解析密钥、启动 helper 并协商连接。

## 比较分布，然后尝试分类状态

平均值会掩盖计时泄漏。如果 90% 的调用耗时 12 毫秒，另有 10% 因为只有现有凭据会触发缓存未命中而耗时 80 毫秒，那么平均值可能看起来与另一个案例接近，但代理仍然可以利用那一组快速结果。

保留每个案例的完整样本集。至少报告中位数、较低和较高百分位数、最小值、最大值以及样本数量。绘制直方图通常比表格更有说明力，因为它能立即显示两个分组。

然后让测试更接近对手。只向一个刻意简单的分类器提供耗时，让它猜测隐藏标签。先从阈值分类器开始。它选择一个分界点，例如“低于 X 微秒就是缺少凭据”，然后在没有用于选择该分界点的数据上报告准确率。如果单阈值分类器反复大幅超过朴素基线，就说明存在值得调查的可观察信号。

一个小型 Python 分析脚本可以让这种回归变得明显，但不要把它当成密码学意义上的恒定时间证明：

```python
from statistics import median

samples = {
    "vault_locked": [...],
    "credential_missing": [...],
}

for name, values in samples.items():
    ordered = sorted(values)
    p10 = ordered[int(len(ordered) * 0.10)]
    p90 = ordered[int(len(ordered) * 0.90)]
    print(name, {"n": len(values), "p10": p10,
                 "median": median(values), "p90": p90})

best = None
all_values = sorted(set(samples["vault_locked"] + samples["credential_missing"]))
for cutoff in all_values:
    correct = 0
    total = 0
    for label, values in samples.items():
        for value in values:
            guess = "vault_locked" if value <= cutoff else "credential_missing"
            correct += (guess == label)
            total += 1
    score = correct / total
    if best is None or score > best[0]:
        best = (score, cutoff)

print({"best_training_accuracy": best[0], "cutoff_us": best[1]})
```

在选择分界点之前，将样本拆分为训练组和留出组。否则脚本会对噪声过拟合，然后自我庆祝。加入更复杂的分类器时，把它当作辅助诊断。复杂模型可能发现实际代理无法利用的微小模式，而简单阈值却能暴露工程师真正会引入的尴尬泄漏。

不要设置“所有中位数必须相差不超过五毫秒”这样的通用通过条件。这个阈值在不同机器和测试环境中没有统一含义。使用受控环境中记录的基线，检查各个分布是否按预期重叠；当代码更改让本应隐藏的状态变得可以稳定区分时，让测试失败。

## 恒定时间比较解决的问题，比这里的小

开发者听到“计时攻击”时，常会想到恒定时间相等函数。比较等长的秘密、签名、MAC 和令牌时，这类函数很重要，但它无法让操作网关的完整请求路径变成恒定时间。

Go 的 `crypto/subtle` 包为内容相等的字节切片提供了 `ConstantTimeCompare`。当秘密比较本身需要与数据无关的行为时，这类原语是正确的选择。但它无法平衡以下分支：一个分支在打开保险库前就返回，另一个分支渲染审批卡片，还有一个分支建立网络连接。

比较敏感的固定格式值时，应使用恒定时间比较。然后检查周围的控制流。如果缺少凭据的路径会立即返回，那么即使其中的比较很干净，仍然会留下一个说明凭据是否存在的预言机。

这也是为什么不应把整个问题都称为“恒定时间”。对于可以发起 HTTP 和 SSH 调用的桌面网关，端到端恒定时间既无法实现，也没有必要。更准确的要求是：敏感的本地状态不应在调用方那里形成一个容易分类的计时信号。

MITRE 的 CWE-208 示例包括密码检查中的提前退出，以及对存在账户和不存在账户执行不同工作。即使完全没有密码比较，模式仍然相同。可观察差异来自决策路径，而不是某个不安全的相等运算符。

## 审批流程需要明确的测量边界

按会话审批和按调用审批会把人工加入时间线。一次完整调用的时长会包含用户看到卡片、读取进程身份、做出决定、通过 Touch ID 验证并点击的时间。这些时间因保险库状态以外的原因而变化。

不要试图用填充延迟隐藏它。这样会让交互变差，同时仍然无法让人按时钟行动。

相反，应将流程拆成自动部分和人工控制部分。自动部分从代理发送调用开始，到网关在不提示的情况下产生拒绝，或显示审批请求为止。跨不同状态测量这一部分。人工控制部分从显示请求开始，到批准、拒绝、超时或进程退出结束。可以为运维记录它，但不要把它作为计时侧信道等价性的目标。

还需要一种测试模式，在采集已发送调用的样本前建立稳定的审批状态。否则“有效凭据”分布可能第一次包含审批卡片，之后在同一会话中又跳过它。这样会产生一个很大的首样本异常值，并掩盖更细微的差异。

一个好的套件应分别回答这些问题：

- 锁定的保险库是否会在发生任何依赖审批的凭据查找前拒绝请求？
- 无论某项操作是否配置了凭据，未获批准的进程是否都会得到相同的提示前处理？
- 会话批准后，有效和无效测试凭据是否经过到达受控上游的相近网关路径？
- 按调用审批被拒绝时，提示前是否会暴露额外的依赖凭据的工作？

Sallyport 在审批卡片中通过代码签名主体识别新的代理进程，按会话授权只在该进程运行期间有效。测试套件在测试首次调用行为时，应有意创建新的代理进程；测试已批准会话时，则应让一个已知进程保持运行。混用这两种模式会让结果无法解释。

## 审计工作不能创建依赖秘密的快速路径

审计日志经常造成最后一类计时泄漏，因为人们把它当作记录工作，而不是安全边界的一部分。被拒绝的请求可能只记录一条简短事件，而成功请求可能会分配详细记录、将其哈希到链中，再写入加密存储。如果成功本来就可见，这种差异可能合理。但当两个本地拒绝会泄露不同数量的隐藏状态时，它就很危险。

决定每种结果可以安全记录哪些字段，然后让等价的本地失败执行等价的审计工作。不需要写入虚构的凭据，也不需要发送虚拟请求。但如果代理不应区分“缺少凭据”和“保险库锁定”，就不要让前者绕过日志，而让后者执行更重的查找和记录写入。

不要在请求路径中执行审计验证。验证是运维人员的操作，目的和计时特征都不同。Sallyport 的审计链可以使用 `sp audit verify` 在密文上离线验证，无需保险库密钥。这样的设计让验证不必读取秘密，但仍然需要测试请求期间围绕日志记录的行为。

在请求解析、授权、保险库网关、凭据解析、审计追加、发送请求和响应映射附近加入内部 span。不要把这些 span 暴露给代理。当端到端测试发现可分离性时，比较不同案例的 span 总时长，定位新出现差异的阶段。

错误在于等计时回归出现后才添加详细追踪。应尽早加入埋点，通过测试设置或本地诊断设置控制它，并确保正常请求路径不会发出成本依赖凭据或结果的同步日志。

## 将差分测试放在添加分支的代码旁边

计时套件应与授权测试一起进入同一次变更审查。最容易回归的分支，通常来自普通维护改动：缺少配置时的捷径、凭据别名缓存、新增审计字段、更好的错误消息，或把保险库查找移到进程审批之前的重构。

每次涉及保险库访问、授权、操作解析、错误映射、传输设置或审计写入的更改，都运行一个紧凑的本地套件。它可以使用回环固定测试服务和适中的样本数量。发布前，在安静且受控的环境中运行更长的随机套件，并分别记录冷模式和热模式。

让测试输出审查者可以采取行动的信息。下面这种报告就比一个没有解释的聚合分数更好：

```text
case pair: vault_locked vs credential_missing
samples: 400 per case, randomized order
median: 12.7 ms vs 13.1 ms
p10-p90: 11.8-14.0 ms vs 11.9-14.3 ms
holdout threshold accuracy: 51.4%
result: within baseline
```

下面这种报告也比把问题埋在笼统的性能失败中更好：

```text
case pair: vault_locked vs credential_missing
samples: 400 per case, randomized order
median: 4.2 ms vs 19.6 ms
p10-p90: 3.9-4.7 ms vs 18.2-22.1 ms
holdout threshold accuracy: 99.1%
result: investigate credential lookup before authorization
```

第二份报告并不是说攻击者在繁忙的工作站上总能达到 99.1% 的准确率。它告诉工程师，本地代码已经造成了清晰且可重复的分离。这足以在修复分支顺序或响应封装之前阻止这次更改。

不要用任意延迟掩盖结果。把依赖秘密的工作移到正确的网关之后，删除不必要的状态特定工作，然后从代理边界重新测试。真正有用的结果不是一张完全平坦的图，而是代理无法把秒表变成保险库后面内容的清单。
