# HTTP 请求签名如何约束 AI 代理的 API 调用

拥有 bearer token 的 AI 代理，实际自由度与任何能读取该 token 的进程相同。只要代码允许，它可以现在调用获准的端点，稍后重试，把 token 发往其他主机，还可以发送一个与启动本次运行的用户指令几乎无关的请求。服务器看到的主要只是凭据持有者。

HTTP 请求签名可以改善这一点，因为它会把授权绑定到一条具体消息。合理的设计可以让签名只对 `POST https://api.example.test/v1/releases/42` 有效，只接受代理准备的精确请求体，只在很短的时间内有效，并且只能由一个签名身份使用。这是一种有用的控制手段，但不能取代授权、审批或凭据隔离。

团队通常会在两个极端之间犯错。有些团队觉得签名太复杂，于是继续使用 bearer token，后来才发现代理可以在任何能够发起出站请求的地方使用权限过宽的 token。另一些团队则签署 HTTP 库发出的每个请求头，并构建出脆弱的验证器，普通代理、时钟偏差或库升级都可能让生产环境中断。正确的设计是签署那些会改变操作含义或目标的字段，再把新鲜度检查和验证流程做得足够简单，便于日常运行。

## 签名约束消息，而 bearer token 只证明持有凭据

Bearer token 只回答一个问题：这个请求是否带有当前仍被接受的秘密？它通常不会把秘密绑定到请求方法、目标、请求体或时间。OAuth 访问令牌可以包含作用域、受众和过期时间，这些机制确实有帮助，但持有者仍可在过期前使用每个获准的作用域。

请求签名回答的是一个更窄的密码学问题：签名密钥的持有者是否授权了这组明确的请求组件？服务器会重建被签名的输入，使用已注册的公钥或共享秘密进行验证，检查时间范围，然后执行正常授权。顺序很重要。签名验证证明消息完整性和签名者身份，授权则决定该身份是否可以执行操作。

以一个使用 bearer token 的部署服务为例，该 token 仅允许创建发布版本。代理先创建一个无害的 staging 发布版本。之后，提示注入指示同一个代理创建 production 发布版本。如果作用域同时覆盖两个环境，token 就会让两个请求都通过。单纯把 token 换成签名请求，无法修复这个授权错误。如果签名身份可以创建 production 发布版本，它就能签署 production 请求。

签名确实能提供普通 bearer 请求头没有的保护：

- 被窃取的签名请求会很快过期，不能永久重放。
- 复制的签名通常不能从 `POST /v1/staging/releases` 挪到 `POST /v1/production/releases`。
- 如果签署了请求体摘要，修改 JSON 请求体会导致验证失败。
- 服务可以识别签名凭据，而不必把它当作可重复使用的请求头值接受。
- 验证器可以准确记录签名者批准了哪些字段。

不要在所有架构中都把它描述成访问令牌的替代品。许多系统会同时使用两者。访问令牌可以识别被委托的用户或工作负载，请求签名则把单条消息绑定到代理专用的签名凭据。在另一些系统中，签名请求直接认证调用方，服务器再根据签名身份推导权限。

这一区别对 AI 代理有实际意义：签名会把操作变成一个可以检查、审批和记录的具体对象。Bearer token 更像一种环境中的隐含能力。只有当工具边界阻止代理提取签名秘密时，这种差异才真正有助于审查。

## 使用 RFC 9421，不要自创规范化字符串

RFC 9421《HTTP Message Signatures》定义了声明受保护组件和发送签名的结构化方式。它避免了常见的私有格式：一端用换行拼接字段，另一端以不同方式规范化 URL，验证失败后双方又把问题归咎于密码学。

RFC 区分了两个概念。`Signature-Input` 声明标签、被覆盖的组件，以及 `created`、`expires`、`nonce`、`alg` 和 `keyid` 等参数。`Signature` 在同一个标签下携带生成的密码学值。验证器根据声明的组件和参数构建签名基，然后进行验证。

一个简短的请求可能如下：

```http
POST /v1/releases/42?environment=staging HTTP/1.1
Host: api.example.test
Content-Type: application/json
Content-Digest: sha-256=:rPMyV6WTE4Duf0JApE9tXvDYy9EzrgFQbq3e2XTCwbs=:
Signature-Input: sig1=("@method" "@authority" "@path" "@query" "content-digest" "date");created=1735689600;expires=1735689660;keyid="agent-release-17";alg="ed25519"
Signature: sig1=:BASE64_SIGNATURE_BYTES:
Date: Wed, 01 Jan 2025 00:00:00 GMT

{"version":"2025.01.01","notes":"staging validation"}
```

上面的摘要值只是展示线路格式，并不是示例 JSON 的摘要。生产客户端必须根据实际要发送的精确字节计算摘要。如果签名后再次序列化 JSON，就等于主动制造验证失败。

RFC 9421 有意保留了灵活性。这对中间件和不同 HTTP 版本很有用，但也意味着 API 合约必须规定一个精确的配置文件。明确允许的算法、必须覆盖的组件、签名最长有效期、`keyid` 格式、接受的摘要算法，以及是否必须使用 nonce。如果合约只说「请求必须签名」，每个客户端作者都会做出不同假设。

如果服务可以注册公钥，Ed25519 是合理的默认选择。服务器保存公钥，公钥记录丢失也不会暴露签名秘密。HMAC 签名适合一个可信组件与 API 共享秘密的场景，但秘密必须存在于两端。对于代理工作流，这通常会增加可重复使用秘密泄露的位置。

除非协议约束迫使你这样做，否则应避免私有方案。私有格式经常在一个客户端中签署原始 URL，在另一个客户端中签署解码后的路径，而验证器又使用不同的主机表示。RFC 9421 提供了定义好的组件标识符和结构化字段。使用它们，然后测试你发布的精确配置。

## 绑定决定操作的 method、目标和 query

代理应签署所有会改变请求去向或调用哪个服务器操作的字段。对大多数 API 调用来说，最有用的最小集合是 `@method`、`@authority`、`@path` 和 `@query`。如果想用一个组件覆盖完整目标 URI，也可以使用 `@target-uri`，但不要在没有明确理由的情况下同时签署两种格式。

`@method` 可以防止有人把原本用于 `GET` 的签名重用于 `DELETE`。这听起来很明显，但手写方案经常省略它，因为工程师以为路径已经暗示了操作。REST API 往往让不同方法共用同一路径，而方法会改变操作本身。

`@authority` 绑定主机和端口。它能防止为一个 API 来源签发的签名，在接受相同凭据的另一个来源上通过验证。这对拥有 preview、staging 和 production 主机的组织尤其重要。用于 staging 的签名身份不应因为代理或重定向改变了主机，就获得 production 权限。

`@path` 和 `@query` 同样需要认真处理。许多 API 把有意义的参数放在 query 中：

```http
POST /v1/invoices/817/refund?amount=2500&currency=USD
```

如果签名只覆盖路径，能够篡改传输中请求的攻击者就可以改变金额或币种。如果服务器从 query 中读取 `dry_run`、`environment`、`force`、`page_size`、`include_deleted` 或租户选择器，这些值就是操作的一部分，应签署 `@query`。

RFC 9421 还定义了 `@query-param`，可覆盖某个具体命名参数。对于明确把部分 query 参数排除在安全决策之外的协议，例如追踪数据，它很有用。对于代理使用的内部 API，签署完整 query 通常更不容易出错。每个参数都会成为获准请求的一部分，审查者也不必记住例外清单。

不要随意处理 URL 规范化。验证器必须使用 RFC 9421 和所选库规定的组件语义。不要手动解码百分号转义再重新编码，也不要在选定的组件定义没有要求时对重复 query 参数排序。请求目标在成为方便的应用对象之前，首先是线路上的字节。

重定向应有明确规则：不要自动把已签名请求带到不同 authority 的重定向目标。覆盖原始 authority 的签名在新主机上应当失败，这才是正确行为。客户端应接收重定向，应用明确的允许列表，构造新请求，再为新请求签名。对于修改型请求，许多团队应直接拒绝重定向。

## 签名请求体需要摘要，不能靠对 JSON 的乐观假设

当请求体会影响结果时，应签署 `content-digest`。这几乎包括所有 JSON 修改请求、多部分上传、表单提交和批量操作。如果不同媒体类型会让服务器以不同方式解析同一组字节，签署 `content-type` 也有意义。

IETF 在 RFC 9530 中定义了 `Content-Digest`。它使用 Structured Fields 语法携带 HTTP 消息内容的摘要。签名覆盖的是摘要请求头，而不是直接覆盖庞大的请求体；接收方会计算请求体摘要，在接受签名之前进行比较。这样，签名只需包含固定大小的精确内容表示。

安全的发送顺序很简单，而且必须保持不变：

1. 构建最终请求对象，包括 query 参数和会影响解析的请求头。
2. 将请求体序列化一次，保存生成的字节用于发送。
3. 根据这些字节计算 `Content-Digest`。
4. 根据选定组件创建 `Signature-Input`，再签署其签名基。
5. 发送未改动的请求字节和请求头。

最常见的问题并不是密码学攻击，而是更普通的工程错误。应用先把对象序列化来计算摘要并签名，之后 HTTP 辅助库又把对象序列化了一次。JSON 成员顺序、转义、空白、数字格式或时间戳字段发生变化，验证器于是正确报告摘要不匹配。为了让发布流程继续，开发者可能会移除请求体保护。这是错误的修复方式。

应把字节缓冲区、数据流或不可变请求体交给传输层。如果流式发送让你无法在传输前完成完整摘要，应使用为这种场景设计的协议，并进行充分测试。不要因为流式处理不方便，就悄悄从高影响操作中去掉请求体摘要。

请求头应采用更窄的规则：当接收方或中间件可以利用某个请求头改变请求的安全含义时，才签署它。`content-type` 是候选项。如果服务器用租户请求头选择账户，它也是候选项。如果重试和重复效果很重要，幂等请求头也应考虑签署。诊断请求头通常不需要签署。

签署 `user-agent`、`accept`、追踪 ID、连接请求头以及库发送的所有请求头，会制造脆弱的客户端。代理可能添加、合并或重写普通请求头，HTTP 也允许合理的传输变换。签名应拒绝有意义的安全变化，而不是把无害的传输差异变成服务中断。

## 新鲜度窗口应容忍偏差，但拒绝排队过久的工作

时间限制能让被捕获的签名快速失效，但如果团队假设每台工作站、容器和虚拟机的时间都完美同步，也会造成无谓事故。正确做法是设置有界的接受规则，并保持良好的时钟同步，而不是给出两小时宽限期。

在 `Signature-Input` 中使用 `created` 和 `expires`。对于代理在发送前立即签名的交互式操作，60 秒有效期通常合适。网络不稳定或工作流需要在暂时性失败后重试时，可以考虑几分钟。API 应记录一个最长有效期并强制执行。不要因为客户端觉得过期麻烦，就让它任意选择过期时间。

验证器应分别处理三种情况：

- 如果 `created` 时间超出允许的未来偏差，应拒绝请求。
- 如果 `expires` 已经过期，应拒绝请求。
- 即使请求尚未过期，只要 `expires - created` 超过 API 最大期限，也应拒绝。

服务器可以接受略微超前或落后的客户端时钟，同时拒绝陈旧工作。例如，服务可以允许适度的未来偏差和较短的过期区间。具体数值取决于客户端运行环境，但原则不变：时钟偏差容忍范围不是可以整个下午敞开的重放窗口。

不要只使用 HTTP `Date` 请求头作为新鲜度机制。它可以作为兼容性和诊断用的覆盖组件，但 `created` 和 `expires` 直接存在于签名参数中，含义更明确。如果同时签署两者，应说明不一致时服务器依据哪个值执行检查。允许两者任意一个通过，会给攻击者留下不必要的机会。

排队的代理任务会暴露一个隐藏的设计错误。假设代理在 09:00 创建签名请求，人工审批等到 09:20，工作进程在审批后发送旧请求。服务应拒绝它。审批后必须重新签名，因为获准的操作应有当前的时间和当前的目标。

对于可重试操作，可在请求头或签名的请求体字段中保存幂等标识，并覆盖该字段。客户端每次重试都可以创建新签名，而服务器则识别这是同一个逻辑操作，避免重复副作用。重复使用过期签名请求不是重试策略。

## 只有服务器记住 nonce，nonce 才能阻止重放

短有效期只能限制重放时间，不能阻止攻击者在这个窗口内反复重放被捕获的请求。是否重要取决于端点。重放无害读取影响不大，但反复发起转账、删除账户或修改基础设施可能很严重。

当服务器把每个 nonce 视为某个签名身份的一次性值时，nonce 可以应对这一风险。客户端生成不可预测的值，把它放入签名参数或被覆盖的请求头，服务器在签名过期前记录成功使用。即使签名仍然有效，第二个使用相同签名者和 nonce 的请求也会失败。

团队声称使用 nonce 时经常跳过的正是这一点。如果服务器不保存 nonce，它只是一个额外的随机字符串，并不能证明唯一性。共享缓存或数据库表需要原子创建操作，否则并发重放可能同时通过验证。

在重放损害值得承担运维成本的地方使用 nonce 存储。你需要决定保存期限、容量上限、故障行为和分区方式。应保留 nonce，直到服务器可能接受该请求的最晚时间。记录应按签名身份和 nonce 共同划分，而不能只按 nonce，因为不同签名身份生成相同值并不会带来安全影响。

不要因为 nonce 听起来更安全，就对每个低风险、高流量调用强制使用它。这可能让 nonce 存储服务的故障变成无害读取也无法使用的故障。实际配置可以要求不可逆修改使用 nonce，而在其他地方依靠短期签名和幂等控制。应逐个端点写清规则。

Nonce 检查也不能取代幂等性。Nonce 表示这条签名消息只能接受一次，幂等标识则表示多次分别签名的重试尝试属于同一个业务操作。两者解决的是不同问题。

## 在应用代码看到请求之前，验证必须默认拒绝

API 网关或应用入口应在路由处理器解析操作参数、启动任务或访问下游服务之前验证请求。如果处理器在验证前读取请求体并执行工作，签名原本要提供的安全属性已经被放弃。

验证器应遵循可预测的顺序：

1. 将 `Signature-Input` 和 `Signature` 作为 Structured Fields 解析，拒绝格式错误和重复造成的歧义。
2. 选择允许的签名标签，拒绝未知算法、缺少必需组件或被禁止的组件组合。
3. 将 `keyid` 解析为有效的签名身份，并取得其验证材料。
4. 按照 RFC 9421，根据收到的请求而不是重新构造的应用 URL，重建签名基。
5. 检查密码学签名、请求体摘要、时间范围、必要时的 nonce 状态，最后执行授权。

在日志中区分密码学失败和授权失败。对外响应可以保持简单，带稳定错误码的 `401` 或 `403` 已经足够。内部应记录请求因未知 `keyid`、签名过期、摘要无效、authority 不匹配、nonce 重复还是权限不足而被拒绝。

不要把签名字节当作无害的调试信息记录。公钥签名不像 HMAC 凭据那样属于秘密，但完整请求日志往往包含授权请求头、个人数据和请求体。可记录请求标识、签名者身份、被覆盖的组件名称、符合保留策略时的摘要值，以及最终决策。原始请求捕获应只在有明确流程的事件处理中启用。

测试向量比说明文字更重要。RFC 9421 提供了示例，但你的 API 配置需要自己的测试固定值。维护必须通过验证的请求，以及必须失败的变体：修改方法、修改 query、修改请求体字节、过期签名、未来的 `created` 值、错误 authority、被篡改的 `keyid` 和重复 nonce。每个受支持的客户端实现都应运行这些测试。

签名验证器应拒绝歧义，即使宽松解析器能够猜测发送者的意图。重复请求头、不一致的组件序列化和不支持的算法都属于协议错误。代理不需要服务器善解人意，而需要服务器准确。

## 让签名材料留在代理上下文之外

把私有签名密钥或 HMAC 秘密交给 AI 编程代理，会抵消签名机制的大部分价值。密钥可能出现在工具输出、shell 历史、临时文件、错误报告中，也可能被提示诱导代理打印环境变量。即使代理行为良好，一个可重复使用的凭据也不应暴露在如此多的间接入口中。

应改为提供狭窄的操作边界。代理把方法、允许的目标、请求头和请求体交给可信的本地或远程组件。该组件验证目标是否允许，在工作流要求时获取审批，构建被覆盖的组件列表，在发送前立即签名，然后返回响应。代理既拿不到明文秘密，也拿不到可能被误转发的虚假占位符。

这个边界也让权限审查变得具体。签名身份可以限制到某个服务、环境、路由族和操作类别。如果代理只需打开 staging 发布版本，就不要授予它可以签署账单调整或 production 删除请求的凭据。签名验证之后，服务器授权仍负责强制执行这些限制。

Sallyport 对 HTTP 操作遵循这种分离方式：应用把 API 凭据放在加密保险库中，并自行执行 HTTP 调用，因此支持 MCP 的代理只会收到结果，而不会得到明文凭据。

不要把操作网关和请求签名标准混为一谈。RFC 9421 告诉两个 HTTP 参与方如何认证选定的消息组件。操作网关则决定签名材料存放在哪里、什么时候让人看到审批，以及代理运行周围应生成什么审计记录。两者可以单独使用，但自主工具操作重要 API 时，把它们结合起来很有价值。

如果使用本地签名器，应把本地接口视为授权边界。尽可能把请求绑定到调用进程，拒绝任意目标，并确保一个代理进程不能悄悄使用另一个进程获准的会话。一个会为任何本地程序提交的任意 URL 签名的本地服务，只是把 bearer 能力搬到了 socket 后面。

## 审批和签名回答的是不同问题

人工审批记录某人允许某类代理操作，请求签名记录某个签名身份授权了一条明确的 HTTP 消息。除非设计明确把两者绑定起来，否则任何一份记录都不能证明另一份记录成立。

对于高影响调用，应让审批界面显示签名将覆盖的字段：方法、authority、路径、有安全意义的 query 参数、请求体摘要或易读的请求体摘要、签名身份和过期时间。如果用户批准的是 `POST /v1/releases/42?environment=staging`，签名者之后就不能通过未签名的 query 参数偷偷改成 production。

只对不透明请求体做摘要，可能会妨碍人工审查。密码学摘要证明字节一致，却几乎不能告诉人类请求做了什么。应同时保留两种材料：用于审查的规范请求表示，以及用于完整性验证的摘要。审查界面必须从签名器将要发送的同一个不可变请求对象生成，而不能来自另行渲染的计划。

对于一次包含许多低影响调用的短代理运行，可以采用会话级审批。删除、对外发布、财务变更，以及任何可能被攻击者隐藏在日常流量中的操作，更适合逐次审批。不要为了宣称有人控制而要求每次读取都审批。人们最终会机械点击，于是你只制造了审批疲劳，却没有得到有意义的决定。

审计记录应包含签名者身份、被覆盖的组件、签名有效期、授权决定、审批引用（如有）、响应状态和请求标识。清晰的审计轨迹能帮助运维人员在事故后回答一个简单问题：代理发送了什么，依据谁的权限发送，服务器是否接受？

## 值得演练的故障大多是普通工程故障

最危险的实现漏洞通常不是椭圆曲线被破解，而是未签名字段、规范化不一致、过期工作，以及把秘密放在代理可以读取的位置。

一种故障发生在团队签署了 `@method`、`@path` 和 `date`，却漏掉 `@query` 时。发布 API 平时接受 `?environment=staging`。后来维护变更让同一个端点接受 `?environment=production`。代理错误或恶意本地组件在签名后修改参数。签名仍然通过，处理器看到的是 production，而审计记录却误称代理发送了有效的签名请求。签名完全执行了组件列表的要求，问题在于组件列表不完整。

另一种故障发生在开发者为减少时钟相关工单，把签名期限设为 10 分钟。代理签署删除请求，把完整请求头写入调试日志，开发者又把日志复制进问题单。任何能访问该问题单的人都可以在大半个工作日内重放请求。短期限不能抹去最初的泄露，但能大幅限制其可利用时间。删除操作要求 nonce，则可以在首次接受后消除剩余重放窗口。

第三种故障与共享 HMAC 凭据有关。多个代理使用同一个秘密，因为为每个代理配置独立身份看起来太麻烦。审计发现破坏性调用后，团队只能识别共享集成，无法识别具体代理运行、用户审批或发起调用的进程。不同权限边界应使用不同签名身份。可追溯性是事件响应的一部分，不是可有可无的报表功能。

从一个修改型端点开始，在选择库之前先写好配置。明确允许的 authority、必需组件、请求体摘要规则、签名期限、时钟偏差容忍度、重放规则、签名算法和授权映射。然后构建负面测试，修改每个被覆盖的字段。如果测试能够改变有意义的请求字段却仍然通过验证，就不要发布这套配置。

一个签名请求应当能够因正确的理由而被轻松拒绝。这个标准会把设计推向更窄的权限、更短生命周期的消息、不可变请求体，以及能够展示代理操作结果的日志。
