Resposta curta. As assinaturas de mensagens HTTP (HTTP Message Signatures, RFC 9421, fevereiro de 2024) permitem que um cliente assine partes selecionadas de uma mensagem HTTP, chamadas componentes: valores derivados, como @method, @authority, @path e @query, e campos de cabeçalho, como content-digest. O cliente envia dois cabeçalhos: Signature-Input, que lista os componentes e os parâmetros (created, expires, nonce, keyid, alg, tag), e Signature, a assinatura calculada sobre uma base de assinatura canônica. O servidor reconstrói a base a partir da requisição que recebeu, procura a chave pelo keyid na própria configuração e verifica a assinatura. O que não está coberto não está protegido; por isso, o servidor deve exigir os componentes dos quais depende, e o corpo só fica coberto por meio do Content-Digest. É esse mesmo mecanismo, na forma do perfil Web Bot Auth, que rastreadores e agentes de IA, como o agente do ChatGPT, agora usam para se identificar perante os sites.
O formato
Uma requisição assinada traz os dois cabeçalhos, identificados por um rótulo (aqui, sig1), para que várias assinaturas possam coexistir:
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==:
A assinatura não é calculada sobre a requisição bruta. Os dois lados montam o mesmo texto, a base de assinatura (§2.5): uma linha por componente coberto, "name": value, e uma última linha "@signature-params", que repete a lista de componentes e os parâmetros exatamente como aparecem em Signature-Input. Como a linha de parâmetros também é assinada, created, expires, nonce, keyid e tag não podem ser alterados.
Os elementos básicos:
| Componente | Valor | Observações |
|---|---|---|
@method | POST | §2.2.1 |
@authority | api.example | host e porta (se não for a padrão), em minúsculas (§2.2.3) |
@path, @query | /orders, ?dry_run=1 | §2.2.6, §2.2.7; @query-param assina parâmetros individuais (§2.2.8) |
@target-uri | a URL completa | deixa de conferir se um intermediário reordenar a query string |
um campo, content-type | o valor dele, sem espaços nas extremidades | §2.1 |
um membro de dicionário, "signature-agent";key="sig1" | o membro, serializado de novo | §2.1.2 |
content-digest | sha-256=:…: | a única forma de cobrir o corpo (RFC 9530) |
Parâmetros (§2.3): created (recomendado) e expires são timestamps UNIX; nonce é um valor único para detectar repetições; keyid identifica a chave; alg indica o algoritmo pelo nome que ele tem no registro; tag identifica a aplicação, para que uma assinatura feita para um protocolo não seja aceita por outro. Os algoritmos registrados são rsa-pss-sha512, rsa-v1_5-sha256, hmac-sha256, ecdsa-p256-sha256, ecdsa-p384-sha384 e ed25519 (§3.3).
Uma implementação conferida com as especificações
O módulo abaixo assina e verifica com Ed25519. Ele analisa e serializa os campos estruturados (Structured Fields) com o pacote structured-headers (2.1.1, MIT), porque um parser de Signature-Input escrito à mão é uma fonte garantida de bugs de interoperabilidade.
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")}:`;
Antes de confiar nele, confira-o com vetores de teste publicados: o apêndice B.2.6 da RFC 9421 (uma assinatura Ed25519 sobre a requisição de teste da RFC) e o apêndice E.2.1 do rascunho do Web Bot Auth. Os dois usam a chave de teste da RFC, e só a metade pública dela é necessária:
// 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}`);
"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
As sete primeiras linhas são a base de assinatura, idêntica à que aparece impressa na RFC. Repare no que não está nela: a query string (@path não a inclui) e o corpo.
Do lado do servidor: o que exigir
A RFC 9421 §3.2 lista as etapas da verificação; as que decidem se uma assinatura significa alguma coisa cabem à aplicação: quais componentes precisam estar cobertos, qual a idade máxima aceita para uma assinatura, de onde vem a chave e quais algoritmos são permitidos (§3.2.1). A chave “DEVE” (MUST) vir de algo em que o verificador já confia; um keyid que ele não conhece faz a verificação falhar (§3.2, etapa 5). Se o algoritmo puder ser determinado por várias fontes — configuração, chave e alg —, elas precisam concordar (etapa 6.5).
Aqui, um cliente assina uma chamada a uma API de pedidos, e o servidor exige que o método, a autoridade, o caminho, a query e o digest do corpo estejam cobertos, além de um nonce novo:
// 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();
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
Todas as manipulações foram rejeitadas, cada uma em uma de três verificações. A alteração do corpo é barrada já na primeira, porque o digest não corresponde mais; as alterações da query e do método falham porque mudam um componente coberto, e a base de assinatura fica diferente. A repetição esbarra no nonce. Sem o armazenamento de nonces, a repetição teria funcionado durante todo o minuto até expires: a §7.2.2 deixa explícito que uma assinatura sobre parte de uma mensagem pode ser anexada a outra mensagem que tenha as mesmas partes. Mais dois detalhes do código: o nonce só é armazenado depois que a assinatura foi verificada, para que lixo não assinado não consiga encher o armazenamento, e o armazenamento de nonces precisa ser compartilhado entre as instâncias e mantido enquanto uma assinatura puder ser aceita.
Chaves, algoritmos e solicitações de assinatura
Chaves. A RFC 9421 deixa a distribuição de chaves a cargo da aplicação. Para um conjunto fixo de clientes de API, registre a chave pública de cada cliente no cadastro dele e use a impressão digital (thumbprint) da JWK dessa chave como keyid; em ecossistemas abertos, publique as chaves em uma URL que o verificador consiga vincular a uma identidade, como faz o Web Bot Auth. A rotação funciona como no JWKS: publique a nova chave, comece a assinar com ela e depois aposente a antiga (veja rotação de chaves JWKS sem interrupção). A regra é uma chave por cliente: o rascunho do Web Bot Auth proíbe expressamente segredos compartilhados de HMAC (§6.4), porque um segredo que todo verificador conhece não prova nada sobre quem assinou.
Algoritmos. O Ed25519 tem as menores chaves e assinaturas (64 bytes) e é determinístico; ecdsa-p256-sha256 serve para chaves guardadas em um KMS ou HSM em nuvem que não oferece Ed25519; rsa-pss-sha512 existe para infraestruturas RSA. A comparação em ES256 vs EdDSA para JWT vale sem alterações. Seja qual for a escolha, o verificador fixa o conjunto de algoritmos permitidos e rejeita qualquer outro, em vez de seguir o alg da mensagem (§3.2, etapa 6).
Como pedir uma assinatura. Um servidor pode dizer ao cliente o que assinar com Accept-Signature (§5), um dicionário com a mesma sintaxe de Signature-Input, por exemplo sig1=("@method" "@target-uri" "content-digest");created;tag="app-123". Um parâmetro nonce nesse campo pede ao cliente que inclua exatamente esse valor, o que transforma a proteção contra repetição em um esquema de desafio e resposta. O rascunho do Web Bot Auth usa o mesmo campo com 403 Forbidden quando uma origem quer que um bot assine (§5.3).
Respostas. As assinaturas também funcionam no sentido contrário. Um servidor assina a resposta com @status e cabeçalhos da resposta e pode vinculá-la à requisição a que ela responde, cobrindo componentes da requisição com o parâmetro req, por exemplo "@authority";req (§2.4). É assim que um cliente consegue saber que um webhook ou uma resposta de API veio do servidor certo e responde à requisição dele, mesmo passando por intermediários.
Comparação com DPoP e mTLS. O DPoP vincula um token OAuth a uma chave e assina apenas o método e a URL; o TLS mútuo autentica a conexão, que termina no primeiro proxy que encerra o TLS. As assinaturas de mensagens HTTP sobrevivem a proxies e podem cobrir o corpo, ao custo de ser preciso combinar quais componentes assinar.
Web Bot Auth: assinaturas para rastreadores e agentes de IA
Há muito tempo os sites identificam rastreadores por strings de User-Agent e faixas de IP, e ambos os métodos são fáceis de falsificar ou difíceis de manter. O grupo de trabalho Web Bot Auth da IETF padroniza para isso um perfil da RFC 9421, voltado a sites que atendem pessoas e a clientes como rastreadores de mecanismos de busca, serviços de arquivamento e agentes de IA. O rascunho do protocolo, draft-ietf-webbotauth-httpsig-protocol-00 (1º de setembro de 2026), exige:
- pelo menos
@authorityou@target-uricoberto, além decreatedeexpires(com prazo de expiração recomendado de no máximo 24 horas),tag="web-bot-auth"e umkeyidque seja a impressão digital SHA-256 da JWK da chave; - um cabeçalho
Signature-Agentque indica onde as chaves do bot estão publicadas, coberto pela assinatura. No rascunho atual ele é um dicionário,sig1="https://bot.example"; rascunhos anteriores usavam uma string simples,"https://bot.example"; - um diretório de chaves: um conjunto de JWKs servido em
/.well-known/http-message-signatures-directoryna origem do agente, com o tipo de mídiaapplication/http-message-signatures-directory+json. O rascunho também permite umjwks_uriou um documento de metadados do ID do cliente, escolhido por um parâmetrotype.
Nosso código de assinatura, conferido nos dois sentidos com a biblioteca web-bot-auth 0.2.0 (Apache-2.0) da Cloudflare:
// 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}`);
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
As duas implementações aceitam as assinaturas uma da outra, e a biblioteca da Cloudflare exige alg, que os exemplos do rascunho sempre trazem. O segundo resultado é o que você deve guardar: os mesmos cabeçalhos foram verificados em outro caminho do mesmo site, porque a assinatura cobre apenas @authority e o agente. O rascunho diz isso com todas as letras: uma assinatura assim “é verificada com sucesso para qualquer método, caminho ou corpo enviado a essa autoridade até expirar”. Uma assinatura de bot prova qual agente enviou tráfego para o seu site em uma janela de tempo, e não que ele fez esta requisição específica. Se precisar de mais, exija @method e @path (o rascunho recomenda que os agentes os incluam) ou um nonce, e aceite que, nesse caso, uma CDN pode rejeitar requisições cujo caminho ela reescreveu.
Na prática, em outubro de 2026
A OpenAI documenta que o agente do ChatGPT assina as requisições com a RFC 9421 e Signature-Agent: "https://chatgpt.com" (central de ajuda da OpenAI). O diretório de chaves dele, obtido para este artigo:
// 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()}`);
}
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
O kid é a impressão digital, como o rascunho exige, e o exp da chave caía sete dias depois do momento da requisição em duas buscas feitas com minutos de diferença: o diretório calcula esse valor a cada resposta, então o verificador deve buscar o diretório de novo em vez de fixar a chave. Repare também na forma de string do Signature-Agent, anterior ao dicionário do rascunho atual.
Essa diferença também importa do lado do verificador. A documentação de Web Bot Auth da Cloudflare (atualizada em 8 de outubro de 2026) diz que o verificador dela segue o rascunho anterior do diretório, espera a forma de string e reprova requisições que usam a forma de dicionário, que, segundo o rascunho da IETF, os signatários agora DEVEM (MUST) enviar. Ela também informa que o verificador ainda não valida nonces. Até que os dois convirjam, um bot que queira ser verificado pela Cloudflare envia a forma de string, e é recomendável que um site que verifica bots por conta própria aceite as duas, como o rascunho permite aos verificadores.
Fora do mundo dos bots, o fediverso está migrando do draft-cavage-http-signatures-12, anterior à RFC, para a RFC 9421: segundo o FEDERATION.md do Mastodon, as versões 4.5 e posteriores aceitam assinaturas RFC 9421 com rsa-v1_5-sha256, e a 4.7 acrescenta ed25519 e usa a RFC 9421 em requisições de saída quando um servidor rejeita o formato antigo.
Erros comuns
- Confiar em uma assinatura sem verificar o que ela cobre. Uma assinatura válida apenas sobre
@authoritynão diz nada sobre o caminho nem sobre o corpo. Mantenha os componentes obrigatórios em uma lista no lado do servidor. - Cobrir o corpo sem
Content-Digest. Não existe componente para o corpo; esse papel é do cabeçalho de digest, e o servidor precisa compará-lo com os bytes que recebeu. - Reconstruir
@authorityou@target-uria partir de uma URL interna atrás de um proxy. Use o host público que o cliente assinou; veja o apêndice B.3 da RFC sobre proxies que terminam o TLS. - Tirar a chave da própria mensagem. O
keyidé uma chave de busca na sua configuração ou em um diretório no qual você decidiu confiar, nunca uma URL para buscar às cegas. - Não ter estratégia contra repetição. Um
expirescurto, um armazenamento de nonces para requisições que alteram estado, ou ambos. - Serializar campos estruturados à mão. A ordem dos parâmetros, as aspas e as sequências de bytes precisam ser exatas; uma biblioteca como a structured-headers elimina uma classe de bugs.
Fontes
- RFC 9421: HTTP Message Signatures, §2, §3, §7, apêndice B
- RFC 9530: Digest Fields
- RFC 8941: Structured Field Values for HTTP
- Grupo de trabalho Web Bot Auth da IETF e draft-ietf-webbotauth-httpsig-protocol-00
- Cloudflare: Web Bot Auth e a biblioteca web-bot-auth
- OpenAI: lista de permissões do agente do ChatGPT
- FEDERATION.md do Mastodon