跳到主要内容
ecdsa.com
← 全部文章

HTTP 消息签名(RFC 9421):用于 API 和机器人

TLS 保护的是连接;请求一旦经过代理,TLS 就无法说明它是谁发出的。HTTP 消息签名对请求中关键的部分签名,让服务器能够自行检查这些部分。本文逐字节介绍其格式,给出一个对照 RFC 测试向量和 Cloudflare 库验证过的实现,并说明爬虫和 AI 智能体如今如何用它证明自己的身份。

简短回答:HTTP 消息签名(RFC 9421,2024年2月)让客户端对 HTTP 消息中选定的部分签名,这些部分称为“组件”:既有 @method、@authority、@path 和 @query 这类派生值,也有 content-digest 这类头字段。客户端发送两个头部:Signature-Input 列出组件和参数(created、expires、nonce、keyid、alg、tag),Signature 则是针对规范化的“签名基础”计算出的签名。服务器根据自己收到的请求重建签名基础,按 keyid 在自己的配置中查找密钥,然后验证签名。未被覆盖的内容不受保护,因此服务器必须要求覆盖它所依赖的组件;消息体则只能通过 Content-Digest 覆盖。同一机制经过裁剪定制后称为 Web Bot Auth,如今爬虫和 AI 智能体(例如 ChatGPT 的智能体)正是用它向网站表明身份。

格式

已签名的请求同时携带这两个头部,两者都以“标签”(此处为 sig1)为键,因此多个签名可以共存:

下文运行 api.mjs 输出的两个签名头部(输出的前两行)
Signature-Input: sig1=("@method" "@authority" "@path" "@query" "content-type" "content-digest");created=1792500460;expires=1792500520;nonce="efJP6sr_BCwi5qPLrNO3Ww";keyid="x34Se3XYBjrAMq38kQtGsGbCaxWCSVcdWIXk6UxriM8";alg="ed25519";tag="orders-api"
Signature: sig1=:U+ydGQT6crfYbivRojgFzn3ZnAvMhwMeTq9b9LxbFEm460FXzxjlcUDsLBvO4Sfvz9HsgcJ8phf1V+CrbjL0BA==:

签名的对象并不是原始请求。双方构建同一段文本,即签名基础(§2.5):每个被覆盖的组件占一行,格式为 "name": value;最后一行 "@signature-params" 按 Signature-Input 中的原样重复组件列表和参数。由于参数行也被签名,created、expires、nonce、keyid 和 tag 同样无法被改动。

各组成部分:

组件值说明
@methodPOST§2.2.1
@authorityapi.example主机名及非默认端口,转为小写(§2.2.3)
@path、@query/orders、?dry_run=1§2.2.6、§2.2.7;@query-param 对单个参数签名(§2.2.8)
@target-uri完整 URL中间节点调整查询参数顺序后即失效
字段,例如 content-type去除首尾空白后的值§2.1
字典成员,例如 "signature-agent";key="sig1"重新序列化后的该成员§2.1.2
content-digestsha-256=:…:覆盖消息体的唯一方式(RFC 9530)

参数(§2.3):created(推荐使用)和 expires 是 UNIX 时间戳;nonce 是用于检测重放的唯一值;keyid 指明密钥;alg 指明注册表中的算法;tag 指明应用,使为某个协议生成的签名不会被另一个协议接受。已注册的算法有 rsa-pss-sha512、rsa-v1_5-sha256、hmac-sha256、ecdsa-p256-sha256、ecdsa-p384-sha384 和 ed25519(§3.3)。

对照规范检验过的实现

下面的模块使用 Ed25519 签名和验证。它借助 structured-headers 包(2.1.1,MIT 许可证)解析和序列化结构化字段,因为手写的 Signature-Input 解析代码向来是互操作性问题的一大来源。

http-sig.mjs(Node.js 24.22,structured-headers 2.1.1)
import { createHash, sign as edSign, verify as edVerify } from "node:crypto";
import { parseDictionary, serializeInnerList, serializeItem } from "structured-headers";

/** Value of one covered component (RFC 9421 §2.1, §2.2). req: { method, url, headers: Map of lowercased names }. */
function componentValue(req, name, params) {
  const url = new URL(req.url);
  switch (name) {
    case "@method": return req.method.toUpperCase();
    case "@authority": return url.host.toLowerCase(); // default ports are already dropped by URL
    case "@path": return url.pathname;
    case "@query": return url.search || "?";
    case "@target-uri": return url.href;
    case "@scheme": return url.protocol.slice(0, -1);
  }
  const raw = req.headers.get(name);
  if (raw === undefined) throw new Error(`covered field "${name}" is missing`);
  if (params.has("key")) {
    // A member of a Dictionary Structured Field (§2.1.2), re-serialized.
    const member = parseDictionary(raw).get(params.get("key"));
    if (!member) throw new Error(`dictionary member ${params.get("key")} of "${name}" is missing`);
    return serializeItem(member);
  }
  return raw.trim();
}

/** The signature base (§2.5): one line per component, then @signature-params. */
export function signatureBase(req, components, params) {
  const lines = components.map(([name, p]) => `${serializeItem([name, p])}: ${componentValue(req, name, p)}`);
  const signatureParams = serializeInnerList([components.map(([n, p]) => [n, p]), params]);
  return { base: [...lines, `"@signature-params": ${signatureParams}`].join("\n"), signatureParams };
}

/** Signs with Ed25519 and returns the two header values for one label (§3.1, §4). */
export function signRequest(req, { label, components, params, privateKey }) {
  const c = components.map((x) => (Array.isArray(x) ? [x[0], new Map(Object.entries(x[1]))] : [x, new Map()]));
  const { base, signatureParams } = signatureBase(req, c, new Map(Object.entries(params)));
  const sig = edSign(null, Buffer.from(base), privateKey).toString("base64");
  return { "signature-input": `${label}=${signatureParams}`, signature: `${label}=:${sig}:` };
}

/** Verifies one labelled signature (§3.2) and enforces the caller's policy. */
export function verifyRequest(req, { label, resolveKey, required = [], maxAge = 300, now = Math.floor(Date.now() / 1000), seenNonces }) {
  const input = parseDictionary(req.headers.get("signature-input") ?? "").get(label);
  const signature = parseDictionary(req.headers.get("signature") ?? "").get(label);
  if (!input || !signature) throw new Error(`no signature labelled ${label}`);
  const [items, params] = input;
  const components = items.map(([name, p]) => [name, p]);
  const covered = components.map(([n, p]) => (p.has("key") ? `${n};key=${p.get("key")}` : n));
  for (const name of required) if (!covered.includes(name)) throw new Error(`"${name}" is not covered`);
  const created = params.get("created");
  const expires = params.get("expires");
  if (typeof created !== "number" || created > now + 5) throw new Error("created missing or in the future");
  if (now - created > maxAge || (typeof expires === "number" && now >= expires)) throw new Error("signature expired");
  if (params.has("alg") && params.get("alg") !== "ed25519") throw new Error(`alg ${params.get("alg")} not accepted`);
  const key = resolveKey(params.get("keyid"), params.get("tag")); // never from the message itself
  const { base } = signatureBase(req, components, params);
  if (!edVerify(null, Buffer.from(base), key, Buffer.from(signature[0]))) throw new Error("signature does not verify");
  // Record the nonce only for a valid signature, so forged requests cannot fill the cache.
  const nonce = params.get("nonce");
  if (seenNonces) {
    if (nonce === undefined) throw new Error("nonce required");
    if (seenNonces.has(nonce)) throw new Error("nonce already used");
    seenNonces.add(nonce);
  }
  return { keyid: params.get("keyid"), covered, created, tag: params.get("tag") };
}

/** Content-Digest (RFC 9530) so that a signature can cover the body. */
export const contentDigest = (body) => `sha-256=:${createHash("sha256").update(body).digest("base64")}:`;

在信任这段代码之前,先用已公开的测试向量检验它:RFC 9421 附录 B.2.6(对 RFC 测试请求的 Ed25519 签名)和 Web Bot Auth 草案的附录 E.2.1。两者都使用 RFC 中的测试密钥,而且只需要它的公钥部分:

vectors.mjs
// Published test vectors: RFC 9421 Appendix B.2.6 and the Web Bot Auth draft's Appendix E.2.1.
// Both are verified with the RFC's Ed25519 test public key (Appendix B.1.4) and nothing else.
import { createPublicKey } from "node:crypto";
import { calculateJwkThumbprint } from "jose";
import { signatureBase, verifyRequest } from "./http-sig.mjs";

const headersOf = (obj) => new Map(Object.entries(obj).map(([k, v]) => [k.toLowerCase(), v]));
const rfcKey = createPublicKey("-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAJrQLj5P/89iXES9+vFgrIy29clF9CC/oPPsw3c5D0bs=\n-----END PUBLIC KEY-----\n");

const b26 = {
  method: "POST",
  url: "https://example.com/foo?param=Value&Pet=dog",
  headers: headersOf({
    Date: "Tue, 20 Apr 2021 02:07:55 GMT",
    "Content-Type": "application/json",
    "Content-Length": "18",
    "Signature-Input": 'sig-b26=("date" "@method" "@path" "@authority" "content-type" "content-length");created=1618884473;keyid="test-key-ed25519"',
    Signature: "sig-b26=:wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1u02Gb04v9EDgwUPiu4A0w6vuQv5lIp5WPpBKRCw==:",
  }),
};
const covered = ["date", "@method", "@path", "@authority", "content-type", "content-length"].map((n) => [n, new Map()]);
console.log(signatureBase(b26, covered, new Map([["created", 1618884473], ["keyid", "test-key-ed25519"]])).base);
const r1 = verifyRequest(b26, { label: "sig-b26", resolveKey: () => rfcKey, now: 1618884480 });
console.log(`RFC 9421 B.2.6: verifies, keyid ${r1.keyid}`);

const e21 = {
  method: "GET",
  url: "https://example.com/foo?param=Value&Pet=dog",
  headers: headersOf({
    "Signature-Agent": 'agent2="https://signature-agent.test"',
    "Signature-Input": 'sig2=("@authority" "signature-agent";key="agent2");created=1735689600;keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";alg="ed25519";expires=4889289600;nonce="n9p433xm+NJ3ph3upfBIGmsuwHw387YV7Q/F+6BSpGCVjYCqQw6rznNA8PVVLySrAWsv0hQtFioQb6E1YsauiA==";tag="web-bot-auth"',
    Signature: "sig2=:RdNFx5Bj6au3YgAMQL/RzmUlZE8QZLIaXGRpw985hWnwPfMxT228NMk6ehRS1PSl4e8PhbNZACSanGdhEwYCCg==:",
  }),
};
const r2 = verifyRequest(e21, { label: "sig2", resolveKey: () => rfcKey, maxAge: Infinity, now: 1735689700 });
const thumbprint = await calculateJwkThumbprint(rfcKey.export({ format: "jwk" }));
console.log(`Web Bot Auth E.2.1: verifies, covers ${r2.covered.join(", ")}, keyid is the key's JWK thumbprint: ${r2.keyid === thumbprint}`);
输出(node:24.22.0-bookworm-slim)
"date": Tue, 20 Apr 2021 02:07:55 GMT
"@method": POST
"@path": /foo
"@authority": example.com
"content-type": application/json
"content-length": 18
"@signature-params": ("date" "@method" "@path" "@authority" "content-type" "content-length");created=1618884473;keyid="test-key-ed25519"
RFC 9421 B.2.6: verifies, keyid test-key-ed25519
Web Bot Auth E.2.1: verifies, covers @authority, signature-agent;key=agent2, keyid is the key's JWK thumbprint: true

前 7 行就是签名基础,与 RFC 中给出的完全一致。注意其中没有的内容:查询字符串(@path 不包含它)和消息体。

服务端:应当要求什么

RFC 9421 §3.2 列出了验证步骤;而真正决定签名有没有意义的步骤取决于应用自身:必须覆盖哪些组件、签名最多可以有多旧、密钥从哪里来,以及允许哪些算法(§3.2.1)。密钥“必须(MUST)”来自验证方已经信任的来源;遇到验证方不认识的 keyid,验证即失败(§3.2,第 5 步)。如果算法可以从多个地方得知(配置、密钥和 alg),它们必须一致(第 6.5 步)。

下面的示例中,客户端对订单 API 的调用签名,服务器要求覆盖方法、主机(authority)、路径、查询和消息体摘要,并要求提供新的 nonce:

api.mjs
// A client signs an API call; the server requires method, authority, path, query and body to be covered.
import { generateKeyPairSync, randomBytes } from "node:crypto";
import { createServer } from "node:http";
import { calculateJwkThumbprint } from "jose";
import { contentDigest, signRequest, verifyRequest } from "./http-sig.mjs";

const client = generateKeyPairSync("ed25519");
const keyid = await calculateJwkThumbprint(client.publicKey.export({ format: "jwk" }));
const registeredKeys = new Map([[keyid, client.publicKey]]); // the server's list of client keys
const seenNonces = new Set(); // shared store in production, entries kept until the signature expires

const server = createServer(async (req, res) => {
  let body = "";
  for await (const chunk of req) body += chunk;
  const request = { method: req.method, url: `https://api.example${req.url}`, headers: new Map(Object.entries(req.headers)) };
  try {
    if (req.headers["content-digest"] !== contentDigest(body)) throw new Error("content-digest does not match the body");
    const ok = verifyRequest(request, {
      label: "sig1",
      resolveKey: (id) => {
        if (!registeredKeys.has(id)) throw new Error(`unknown keyid ${id}`);
        return registeredKeys.get(id);
      },
      required: ["@method", "@authority", "@path", "@query", "content-digest"],
      seenNonces,
    });
    res.writeHead(200).end(`ok, client ${ok.keyid.slice(0, 8)}...`);
  } catch (e) {
    res.writeHead(401).end(e.message);
  }
}).listen(8443);

// Client side.
const body = JSON.stringify({ sku: "A-7", qty: 2 });
const now = Math.floor(Date.now() / 1000);
const headers = { "Content-Type": "application/json", "Content-Digest": contentDigest(body) };
const signed = signRequest(
  { method: "POST", url: "https://api.example/orders?dry_run=1", headers: new Map(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])) },
  {
    label: "sig1",
    components: ["@method", "@authority", "@path", "@query", "content-type", "content-digest"],
    params: { created: now, expires: now + 60, nonce: randomBytes(16).toString("base64url"), keyid, alg: "ed25519", tag: "orders-api" },
    privateKey: client.privateKey,
  },
);
console.log(`Signature-Input: ${signed["signature-input"]}\nSignature: ${signed.signature}`);

const send = async (label, { path = "/orders?dry_run=1", method = "POST", payload = body } = {}) => {
  const res = await fetch(`http://127.0.0.1:8443${path}`, { method, body: payload, headers: { ...headers, ...signed } });
  console.log(`${label.padEnd(22)} ${res.status} ${await res.text()}`);
};
await send("signed request");
await send("same request again");
await send("body changed", { payload: JSON.stringify({ sku: "A-7", qty: 200 }) });
await send("query changed", { path: "/orders?dry_run=0" });
await send("method changed", { method: "PUT" });
server.close();
输出(node:24.22.0-bookworm-slim)
Signature-Input: sig1=("@method" "@authority" "@path" "@query" "content-type" "content-digest");created=1792500460;expires=1792500520;nonce="efJP6sr_BCwi5qPLrNO3Ww";keyid="x34Se3XYBjrAMq38kQtGsGbCaxWCSVcdWIXk6UxriM8";alg="ed25519";tag="orders-api"
Signature: sig1=:U+ydGQT6crfYbivRojgFzn3ZnAvMhwMeTq9b9LxbFEm460FXzxjlcUDsLBvO4Sfvz9HsgcJ8phf1V+CrbjL0BA==:
signed request         200 ok, client x34Se3XY...
same request again     401 nonce already used
body changed           401 content-digest does not match the body
query changed          401 signature does not verify
method changed         401 signature does not verify

每一种篡改都在三项检查中的某一项被拒绝。改动消息体的请求在第一步就失败了,因为摘要不再匹配;改动查询和方法的请求失败,是因为它们改变了被覆盖的组件,签名基础随之不同。重放的请求在 nonce 检查处失败。如果没有 nonce 存储,在 expires 到来之前的整整一分钟内,重放都能成功:§7.2.2 明确指出,对消息一部分的签名可以被附加到具有相同部分的另一条消息上。代码中还有两个细节:只有在签名验证通过之后才记录 nonce,因此未签名的垃圾请求无法填满存储;nonce 存储必须在所有实例之间共享,并且要一直保留到签名不再可能被接受为止。

密钥、算法与要求签名

密钥:RFC 9421 把密钥分发交给应用处理。对于固定的一组 API 客户端,在接入时登记每个客户端的公钥,并以其 JWK 指纹作为 keyid;对于开放的生态系统,则把密钥发布在验证方能够与某个身份关联起来的 URL 上,Web Bot Auth 就是这样做的。密钥轮换的做法与 JWKS 相同:先发布新密钥,再开始用它签名,最后停用旧密钥(参见 JWKS 零停机密钥轮换)。原则是每个客户端一个密钥:Web Bot Auth 草案明确禁止使用 HMAC 共享密钥(§6.4),因为每个验证方都知道的密钥无法证明签名者是谁。

算法:Ed25519 的密钥最小,签名也最小(64 字节),并且是确定性的;ecdsa-p256-sha256 适合存放在不支持 Ed25519 的云 KMS 或 HSM 中的密钥;rsa-pss-sha512 则用于 RSA 基础设施。JWT 选 ES256 还是 EdDSA 中的比较在这里同样完全适用。无论选择哪种算法,都由验证方固定允许的算法集合,并拒绝其他任何算法,而不是听从消息中的 alg(§3.2,第 6 步)。

要求对方签名:服务器可以用 Accept-Signature(§5)告诉客户端要对哪些内容签名。它是一个与 Signature-Input 语法相同的字典,例如 sig1=("@method" "@target-uri" "content-digest");created;tag="app-123"。其中的 nonce 参数要求客户端原样带上这个值,从而把重放保护变成挑战-响应机制。当源站希望机器人签名时,Web Bot Auth 草案会把同一字段与 403 Forbidden 一起使用(§5.3)。

响应:签名也可以反向使用。服务器用 @status 和响应头对响应签名,并可以通过 req 参数覆盖请求组件(例如 "@authority";req),把响应绑定到它所对应的请求上(§2.4)。客户端正是借此确认 Webhook 或 API 响应来自正确的服务器,并且是针对自己请求的响应,即使途经中间节点也是如此。

与 DPoP 和 mTLS 的比较:DPoP 把 OAuth 令牌绑定到密钥上,只对方法和 URL 签名;双向 TLS 对连接做身份验证,而连接在遇到第一个终结 TLS 的代理时就结束了。HTTP 消息签名可以穿过代理,也能覆盖消息体,代价是双方需要约定对哪些组件签名。

Web Bot Auth:面向爬虫和 AI 智能体的签名

长期以来,网站一直通过 User-Agent 字符串和 IP 地址段识别爬虫,而这两者要么容易伪造,要么难以维护。IETF 的 Web Bot Auth 工作组为此制定 RFC 9421 的一个专用规范(profile),适用范围是面向人类访问者的网站,以及搜索引擎爬虫、网页归档程序和 AI 智能体等客户端。其协议草案 draft-ietf-webbotauth-httpsig-protocol-00(2026年9月1日)要求:

  • 至少覆盖 @authority 或 @target-uri,并包含 created 和 expires(建议有效期不超过 24 小时)、tag="web-bot-auth",以及值为密钥 JWK SHA-256 指纹的 keyid。
  • Signature-Agent 头部,指明机器人的密钥发布在哪里,并由签名覆盖。在当前草案中它是一个字典:sig1="https://bot.example";早期草案使用的是单纯的字符串:"https://bot.example"。
  • 密钥目录:在智能体的源上通过 /.well-known/http-message-signatures-directory 提供的 JWK 集,媒体类型为 application/http-message-signatures-directory+json。草案也允许改用 jwks_uri 或客户端 ID 元数据文档,由 type 参数选择。

我们的签名实现与 Cloudflare 的 web-bot-auth 库 0.2.0(Apache-2.0)做了双向交叉检验:

bot.mjs(web-bot-auth 0.2.0)
// Web Bot Auth: our signer and verifier against Cloudflare's web-bot-auth 0.2.0, in both directions.
import { generateKeyPairSync, randomBytes } from "node:crypto";
import { calculateJwkThumbprint } from "jose";
import { sign as wbaSign, verify as wbaVerify } from "web-bot-auth";
import { signerFromJWK, verifierFromJWK } from "web-bot-auth/crypto";
import { signRequest, verifyRequest } from "./http-sig.mjs";

const bot = generateKeyPairSync("ed25519");
const publicJwk = bot.publicKey.export({ format: "jwk" });
const keyid = await calculateJwkThumbprint(publicJwk); // the draft requires the JWK thumbprint as keyid
const agent = 'sig1="https://bot.example"'; // dictionary form, as draft-ietf-webbotauth-httpsig-protocol-00 requires
const now = Math.floor(Date.now() / 1000);

// Our signer: @authority and the Signature-Agent member, tag web-bot-auth, five minutes of validity.
const ours = signRequest(
  { method: "GET", url: "https://shop.example/products", headers: new Map([["signature-agent", agent]]) },
  {
    label: "sig1",
    components: ["@authority", ["signature-agent", { key: "sig1" }]],
    params: { created: now, expires: now + 300, keyid, alg: "ed25519", nonce: randomBytes(64).toString("base64"), tag: "web-bot-auth" },
    privateKey: bot.privateKey,
  },
);
console.log(`Signature-Agent: ${agent}\nSignature-Input: ${ours["signature-input"]}`);

// Cloudflare's library verifies it. The resolver maps keyid to a key the site already trusts.
const verifier = await verifierFromJWK({ ...publicJwk, alg: "EdDSA" });
const resolver = (candidate) => {
  if (candidate.keyid !== verifier.keyid) throw new Error("unknown key");
  return verifier;
};
const asRequest = (url) => new Request(url, { headers: { "Signature-Agent": agent, "Signature-Input": ours["signature-input"], Signature: ours.signature } });
for (const [label, url] of [
  ["web-bot-auth verifies ours", "https://shop.example/products"],
  ["same headers, another path", "https://shop.example/admin"],
  ["same headers, another host", "https://other.example/products"],
]) {
  try {
    const r = await wbaVerify(asRequest(url), { resolver });
    console.log(`${label.padEnd(28)} ok, agent ${r.signatureAgent.uri}, discovery ${r.signatureAgent.type}`);
  } catch (e) {
    console.log(`${label.padEnd(28)} ${e.name}: ${e.message}`);
  }
}

// The library signs, our verifier checks.
const theirs = await wbaSign(new Request("https://shop.example/products", { headers: { "Signature-Agent": agent } }), {
  signer: await signerFromJWK({ ...bot.privateKey.export({ format: "jwk" }), alg: "EdDSA" }),
  expires: new Date(Date.now() + 300_000),
});
const checked = verifyRequest(
  {
    method: "GET",
    url: "https://shop.example/products",
    headers: new Map([["signature-agent", agent], ["signature-input", theirs.signatureInput], ["signature", theirs.signature]]),
  },
  { label: "sig1", resolveKey: () => bot.publicKey, required: ["@authority", "signature-agent;key=sig1"] },
);
console.log(`library's Signature-Input: ${theirs.signatureInput}`);
console.log(`our verifier: ok, covers ${checked.covered.join(", ")}, tag ${checked.tag}`);
输出(node:24.22.0-bookworm-slim)
Signature-Agent: sig1="https://bot.example"
Signature-Input: sig1=("@authority" "signature-agent";key="sig1");created=1792500467;expires=1792500767;keyid="47reD7FOwk8HOy0YI79OYtwg88h_Ls1I9z6vsGKdp-E";alg="ed25519";nonce="tHPV4puMjEd7xZrTuiURmxWxcfaGTSo4xaMAkpFltfVwupzv4lQwxQNfPRyTwwPODLd3sdVbkueLt9bii4TOOw==";tag="web-bot-auth"
web-bot-auth verifies ours   ok, agent https://bot.example, discovery directory
same headers, another path   ok, agent https://bot.example, discovery directory
same headers, another host   SignatureError: Signature verification failed
library's Signature-Input: sig1=("@authority" "signature-agent";key="sig1");created=1792500467;keyid="47reD7FOwk8HOy0YI79OYtwg88h_Ls1I9z6vsGKdp-E";alg="ed25519";expires=1792500767;tag="web-bot-auth"
our verifier: ok, covers @authority, signature-agent;key=sig1, tag web-bot-auth

两种实现都接受对方的签名;Cloudflare 的库要求带有 alg,而草案中的示例总是带着它。需要记住的是第二个结果:同样的头部在同一网站的另一个路径上也验证通过了,因为签名只覆盖了 @authority 和 Signature-Agent。草案对此说得很直白:这样的签名“在过期之前,对发往该主机的任何方法、路径或消息体都能验证通过”。机器人签名证明的是哪个智能体在某个时间窗口内向你的网站发送了流量,而不是证明这个特定的请求是它发出的。如果需要更强的保证,就要求覆盖 @method 和 @path(草案建议智能体加上这两个组件)或要求 nonce,同时要接受这样的后果:CDN 可能会拒绝那些路径被它改写过的请求。

2026年10月的现状

OpenAI 在文档中说明,ChatGPT 的智能体使用 RFC 9421 和 Signature-Agent: "https://chatgpt.com" 对请求签名(OpenAI 帮助中心)。为撰写本文,我们获取了它的密钥目录:

thumbprint.mjs
// Fetches ChatGPT's Web Bot Auth key directory and checks that each kid is the key's JWK SHA-256 thumbprint.
import { calculateJwkThumbprint } from "jose";

const url = "https://chatgpt.com/.well-known/http-message-signatures-directory";
const res = await fetch(url, { headers: { accept: "application/http-message-signatures-directory+json" } });
console.log(`${res.status} ${res.headers.get("content-type")}`);
for (const key of (await res.json()).keys) {
  const thumbprint = await calculateJwkThumbprint({ kty: key.kty, crv: key.crv, x: key.x });
  console.log(`${key.kty} ${key.crv}, kid ${key.kid}`);
  console.log(`kid is the JWK thumbprint: ${thumbprint === key.kid}, nbf ${new Date(key.nbf * 1000).toISOString()}, exp ${new Date(key.exp * 1000).toISOString()}`);
}
输出,2026年10月20日
200 application/http-message-signatures-directory+json
OKP Ed25519, kid ArwjqcCqtA5oxEWkzXGC-To_0A9whHmajCHtfe8I9aY
kid is the JWK thumbprint: true, nbf 2025-01-01T00:00:00.000Z, exp 2026-10-27T12:48:27.000Z

正如草案所要求的,kid 就是指纹;而在相隔几分钟的两次获取中,密钥的 exp 都是请求时间之后 7 天:目录在每次响应时计算这个值,因此验证方必须重新获取,而不是固定使用这个密钥。还要注意 Signature-Agent 使用的是字符串形式,它早于当前草案的字典形式。

这一差异在验证方同样重要。Cloudflare 的 Web Bot Auth 文档(2026年10月8日更新)指出,它的验证器遵循较早的目录草案,按字符串形式解析,使用字典形式的请求在它那里会验证失败——而 IETF 草案现在规定签名者必须(MUST)发送字典形式。文档还说明,它目前尚不校验 nonce。在两者统一之前,希望通过 Cloudflare 验证的机器人就发送字符串形式,而自行验证机器人的网站应同时接受两种形式,草案也允许验证方这样做。

在机器人之外,联邦宇宙(Fediverse)正在从 RFC 发布之前的 draft-cavage-http-signatures-12 迁移到 RFC 9421:根据 Mastodon 的 FEDERATION.md,4.5 及更高版本接受使用 rsa-v1_5-sha256 的 RFC 9421 签名,4.7 新增了 ed25519,并在对方服务器拒绝旧格式时对发出的请求使用 RFC 9421。

常见错误

  1. 信任签名,却不检查它覆盖了什么。仅覆盖 @authority 的有效签名,对路径和消息体什么也证明不了。必须覆盖的组件应由服务端以列表形式规定。
  2. 不用 Content-Digest 就想覆盖消息体。并不存在表示消息体的组件,摘要头部就充当这个组件,服务器必须把它与实际收到的字节进行比较。
  3. 在代理之后用内部 URL 重建 @authority 或 @target-uri。应使用客户端签名时的公开主机名;关于终结 TLS 的代理,参见 RFC 附录 B.3。
  4. 从消息本身获取密钥。keyid 只是在你的配置或你选择信任的目录中进行查找的键,绝不是可以盲目去获取的 URL。
  5. 没有防重放策略。使用较短的 expires、为会改变状态的请求维护 nonce 存储,或者两者兼用。
  6. 手动序列化结构化字段。参数顺序、引号和字节序列都必须完全准确;使用 structured-headers 这样的库可以消除一整类错误。

参考资料