Respuesta breve. Las firmas de mensajes HTTP (HTTP Message Signatures, RFC 9421, febrero de 2024) permiten que un cliente firme partes seleccionadas de un mensaje HTTP, llamadas componentes: valores derivados como @method, @authority, @path y @query, y campos de encabezado como content-digest. El cliente envía dos encabezados: Signature-Input, que enumera los componentes y los parámetros (created, expires, nonce, keyid, alg, tag), y Signature, la firma calculada sobre una base de firma canónica. El servidor reconstruye la base a partir de la solicitud que recibe, busca la clave por keyid en su propia configuración y verifica la firma. Lo que no está cubierto no está protegido, así que el servidor debe exigir los componentes de los que depende, y el cuerpo solo queda cubierto a través de Content-Digest. Ese mismo mecanismo, convertido en el perfil Web Bot Auth, es el que usan ahora los rastreadores y los agentes de IA, como el agente de ChatGPT, para identificarse ante los sitios web.
El formato
Una solicitud firmada lleva los dos encabezados, indexados por una etiqueta (aquí sig1) para que puedan coexistir varias firmas:
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==:
La firma no se calcula sobre la solicitud en bruto. Las dos partes construyen el mismo texto, la base de firma (§2.5): una línea por cada componente cubierto, "name": value, y una última línea "@signature-params" que repite la lista de componentes y los parámetros exactamente como aparecen en Signature-Input. Como la línea de parámetros también se firma, tampoco se pueden cambiar created, expires, nonce, keyid ni tag.
Las piezas básicas:
| Componente | Valor | Notas |
|---|---|---|
@method | POST | §2.2.1 |
@authority | api.example | host y puerto no predeterminado, en minúsculas (§2.2.3) |
@path, @query | /orders, ?dry_run=1 | §2.2.6, §2.2.7; @query-param firma parámetros individuales (§2.2.8) |
@target-uri | la URL completa | deja de verificarse si un intermediario reordena la consulta |
un campo, content-type | su valor, sin espacios al principio ni al final | §2.1 |
un miembro de diccionario, "signature-agent";key="sig1" | el miembro, serializado de nuevo | §2.1.2 |
content-digest | sha-256=:…: | la única forma de cubrir el cuerpo (RFC 9530) |
Parámetros (§2.3): created (recomendado) y expires son marcas de tiempo UNIX; nonce es un valor único para detectar repeticiones; keyid identifica la clave; alg indica el algoritmo del registro; tag identifica la aplicación, de modo que una firma hecha para un protocolo no la acepte otro. Los algoritmos registrados son rsa-pss-sha512, rsa-v1_5-sha256, hmac-sha256, ecdsa-p256-sha256, ecdsa-p384-sha384 y ed25519 (§3.3).
Una implementación contrastada con las especificaciones
El siguiente módulo firma y verifica con Ed25519. Analiza y serializa los campos estructurados (Structured Fields) con el paquete structured-headers (2.1.1, MIT), porque analizar Signature-Input con código escrito a mano es una fuente garantizada de errores de interoperabilidad.
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 en él, compruébalo con vectores publicados: el apéndice B.2.6 del RFC 9421 (una firma Ed25519 sobre la solicitud de prueba del RFC) y el apéndice E.2.1 del borrador de Web Bot Auth. Los dos usan la clave de prueba del RFC, y solo hace falta su parte pública:
// 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
Las siete primeras líneas son la base de firma, idéntica a la que aparece impresa en el RFC. Fíjate en lo que no contiene: la cadena de consulta (@path la excluye) y el cuerpo.
En el servidor: qué exigir
El RFC 9421 §3.2 enumera los pasos de verificación; los que deciden si una firma significa algo corresponden a la aplicación: qué componentes deben estar cubiertos, qué antigüedad puede tener una firma, de dónde sale la clave y qué algoritmos se permiten (§3.2.1). La clave debe («MUST») proceder de algo en lo que el verificador ya confía; con un keyid que no conoce, la verificación falla (§3.2, paso 5). Si el algoritmo se conoce por varias vías —la configuración, la clave y alg—, todas deben coincidir (paso 6.5).
Aquí un cliente firma una llamada a una API de pedidos, y el servidor exige el método, la autoridad, la ruta, la consulta y el resumen del cuerpo, además de un nonce nuevo:
// 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
Cada manipulación se rechaza en una de tres comprobaciones. La del cuerpo falla en la primera comprobación porque el resumen ya no coincide; las de la consulta y el método fallan porque modifican un componente cubierto, así que la base de firma cambia. La repetición falla en el nonce. Sin el almacén de nonces, la repetición habría funcionado durante todo el minuto que falta hasta expires: el §7.2.2 deja claro que una firma sobre una parte de un mensaje se puede adjuntar a otro mensaje que tenga esas mismas partes. Dos detalles más del código: el nonce se guarda solo después de que la firma se verifica, para que la basura sin firmar no pueda llenar el almacén, y el almacén de nonces debe compartirse entre instancias y conservar cada nonce mientras una firma pueda aceptarse.
Claves, algoritmos y solicitudes de firma
Claves. El RFC 9421 deja la distribución de claves en manos de la aplicación. Para un conjunto fijo de clientes de una API, registra la clave pública de cada cliente al darlo de alta y usa su huella digital JWK como keyid; en ecosistemas abiertos, publica las claves en una URL que el verificador pueda vincular a una identidad, como hace Web Bot Auth. La rotación de claves funciona igual que con JWKS: publica la clave nueva, empieza a firmar con ella y después retira la antigua (consulta Rotación de claves JWKS sin interrupciones). La regla es una clave por cliente: el borrador de Web Bot Auth prohíbe expresamente los secretos compartidos de HMAC (§6.4), porque un secreto que conocen todos los verificadores no demuestra nada sobre quién firmó.
Algoritmos. Ed25519 ofrece las claves y las firmas más pequeñas (64 bytes) y es determinista; ecdsa-p256-sha256 sirve para claves guardadas en un KMS en la nube o en un HSM que no admitan Ed25519; rsa-pss-sha512 existe para la infraestructura RSA. La comparación de ES256 frente a EdDSA para JWT se aplica sin cambios. Elijas lo que elijas, el verificador fija el conjunto de algoritmos permitidos y rechaza cualquier otro, en lugar de seguir el alg del mensaje (§3.2, paso 6).
Cómo pedir una firma. Un servidor puede indicar a un cliente qué debe firmar con Accept-Signature (§5), un diccionario con la misma sintaxis que Signature-Input, por ejemplo sig1=("@method" "@target-uri" "content-digest");created;tag="app-123". Un parámetro nonce en ese campo pide al cliente que incluya exactamente ese valor, lo que convierte la protección contra repetición en un esquema de desafío y respuesta. El borrador de Web Bot Auth usa el mismo campo con 403 Forbidden cuando un origen quiere que un bot firme (§5.3).
Respuestas. Las firmas también funcionan en sentido contrario. Un servidor firma su respuesta con @status y encabezados de respuesta, y puede vincularla a la solicitud a la que responde si cubre componentes de esa solicitud con el parámetro req, por ejemplo "@authority";req (§2.4). Así es como un cliente puede saber que un webhook o una respuesta de API procede del servidor correcto y responde a su solicitud, incluso a través de intermediarios.
Frente a DPoP y mTLS. DPoP vincula un token OAuth a una clave y solo firma el método y la URL; el TLS mutuo autentica la conexión, que acaba en el primer proxy que termina TLS. Las firmas de mensajes HTTP sobreviven a los proxies y pueden cubrir el cuerpo, a cambio de tener que acordar qué componentes se firman.
Web Bot Auth: firmas para rastreadores y agentes de IA
Hace tiempo que los sitios web identifican a los rastreadores por las cadenas User-Agent y los rangos de IP, que son fáciles de falsificar o difíciles de mantener. El grupo de trabajo Web Bot Auth del IETF estandariza para ello un perfil del RFC 9421, limitado, por un lado, a los sitios que atienden a personas y, por otro, a clientes como los rastreadores de buscadores, los servicios de archivo web y los agentes de IA. Su borrador del protocolo, draft-ietf-webbotauth-httpsig-protocol-00 (1 de septiembre de 2026), exige:
- al menos
@authorityo@target-uricomo componente cubierto, además decreatedyexpires(se recomienda una caducidad de 24 horas como máximo),tag="web-bot-auth"y unkeyidque sea la huella digital JWK SHA-256 de la clave; - un encabezado
Signature-Agentque indique dónde se publican las claves del bot, cubierto por la firma. En el borrador actual es un diccionario,sig1="https://bot.example"; los borradores anteriores usaban una cadena simple,"https://bot.example"; - un directorio de claves: un JWK Set servido en
/.well-known/http-message-signatures-directoryen el origen del agente, con el tipo de medioapplication/http-message-signatures-directory+json. El borrador también admite unjwks_urio un documento de metadatos de ID de cliente, que se elige con un parámetrotype.
Nuestro firmante, contrastado en ambos sentidos con la biblioteca web-bot-auth 0.2.0 (Apache-2.0) de 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
Cada implementación acepta las firmas de la otra, y la biblioteca de Cloudflare exige alg, que los ejemplos del borrador siempre incluyen. El segundo resultado es el que hay que recordar: los mismos encabezados se verificaron en otra ruta del mismo sitio, porque la firma solo cubre @authority y el agente. El borrador lo dice sin rodeos: una firma así «se verifica con cualquier método, ruta o cuerpo enviados a esa autoridad hasta que caduca». Una firma de bot demuestra qué agente envió tráfico a tu sitio en un intervalo de tiempo, no que hiciera esta solicitud concreta. Si necesitas más, exige @method y @path (el borrador recomienda que los agentes los añadan) o un nonce, y acepta que, en ese caso, una CDN puede rechazar las solicitudes cuya ruta haya reescrito.
En la práctica, octubre de 2026
OpenAI documenta que el agente de ChatGPT firma sus solicitudes con el RFC 9421 y Signature-Agent: "https://chatgpt.com" (centro de ayuda de OpenAI). Este es su directorio de claves, descargado para este artículo:
// 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
El kid es la huella digital, como exige el borrador, y en dos descargas separadas por unos minutos el exp de la clave caía siete días después del momento de la solicitud: el directorio lo calcula en cada respuesta, así que un verificador debe volver a descargarlo en lugar de fijar la clave. Fíjate también en la forma de cadena de Signature-Agent, anterior al diccionario del borrador actual.
Esa diferencia también importa del lado del verificador. La documentación de Web Bot Auth de Cloudflare (actualizada el 8 de octubre de 2026) indica que su verificador sigue el borrador anterior del directorio, espera la forma de cadena y da por fallidas las solicitudes que usan la forma de diccionario, la que, según el borrador del IETF, los firmantes deben («MUST») enviar ahora. También indica que todavía no valida los nonces. Hasta que ambos converjan, un bot que quiera que Cloudflare lo verifique envía la forma de cadena, y un sitio que verifique bots por su cuenta debería aceptar las dos, como el borrador permite a los verificadores.
Fuera del mundo de los bots, el fediverso está pasando del draft-cavage-http-signatures-12, anterior al RFC, al RFC 9421: según el FEDERATION.md de Mastodon, las versiones 4.5 y posteriores aceptan firmas RFC 9421 con rsa-v1_5-sha256, y la 4.7 añade ed25519 y usa el RFC 9421 en las solicitudes salientes cuando un servidor rechaza el formato antiguo.
Errores frecuentes
- Confiar en una firma sin comprobar qué cubre. Una firma válida solo sobre
@authorityno dice nada de la ruta ni del cuerpo. Define los componentes obligatorios en una lista del lado del servidor. - Cubrir un cuerpo sin
Content-Digest. No existe un componente para el cuerpo; ese papel lo cumple el encabezado del resumen, y el servidor debe compararlo con los bytes que recibe. - Reconstruir
@authorityo@target-uria partir de una URL interna detrás de un proxy. Usa el host público que firmó el cliente; consulta el apéndice B.3 del RFC sobre los proxies que terminan TLS. - Tomar la clave del mensaje.
keyidsirve para buscar la clave en tu configuración o en un directorio en el que eliges confiar; nunca es una URL para descargar a ciegas. - Sin estrategia contra la repetición. Un
expirescorto, un almacén de nonces para las solicitudes que cambian el estado, o ambas cosas. - Serializar a mano los campos estructurados. El orden de los parámetros, el entrecomillado y las secuencias de bytes deben ser exactos; una biblioteca como structured-headers elimina toda una clase de errores.
Fuentes
- RFC 9421: firmas de mensajes HTTP, §2, §3, §7, apéndice B
- RFC 9530: campos de resumen (Digest Fields)
- RFC 8941: valores de campos estructurados para HTTP
- Grupo de trabajo Web Bot Auth del IETF y draft-ietf-webbotauth-httpsig-protocol-00
- Cloudflare: Web Bot Auth y la biblioteca web-bot-auth
- OpenAI: el agente de ChatGPT en listas de permitidos
- FEDERATION.md de Mastodon