En bref. Les signatures de messages HTTP (HTTP Message Signatures, RFC 9421, février 2024) permettent à un client de signer des parties choisies d’un message HTTP, appelées composants : des valeurs dérivées comme @method, @authority, @path et @query, et des champs d’en-tête comme content-digest. Le client envoie deux en-têtes : Signature-Input, qui énumère les composants et les paramètres (created, expires, nonce, keyid, alg, tag), et Signature, la signature calculée sur une base de signature canonique. Le serveur reconstruit la base à partir de la requête qu’il a reçue, cherche la clé par son keyid dans sa propre configuration, puis vérifie. Ce qui n’est pas couvert n’est pas protégé : un serveur doit donc exiger les composants sur lesquels il s’appuie, et le corps n’est couvert que par l’intermédiaire de Content-Digest. C’est ce même mécanisme, décliné en profil sous le nom de Web Bot Auth, qu’utilisent désormais les robots d’indexation et les agents d’IA, comme l’agent de ChatGPT, pour s’identifier auprès des sites web.
Le format
Une requête signée porte les deux en-têtes, rattachés à un libellé (ici sig1), ce qui permet à plusieurs signatures de coexister :
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 signature ne porte pas sur la requête brute. Le signataire et le vérificateur construisent tous deux le même texte, la base de signature (§2.5) : une ligne par composant couvert, "name": value, puis une dernière ligne "@signature-params" qui reprend la liste des composants et les paramètres exactement tels qu’ils figurent dans Signature-Input. Comme la ligne des paramètres est elle aussi signée, created, expires, nonce, keyid et tag ne peuvent pas non plus être modifiés.
Les éléments de base :
| Composant | Valeur | Remarques |
|---|---|---|
@method | POST | §2.2.1 |
@authority | api.example | hôte, et port s’il n’est pas celui par défaut, en minuscules (§2.2.3) |
@path, @query | /orders, ?dry_run=1 | §2.2.6, §2.2.7 ; @query-param signe des paramètres individuels (§2.2.8) |
@target-uri | l’URL complète | échoue si un intermédiaire réordonne la chaîne de requête |
un champ, content-type | sa valeur, sans les espaces de début et de fin | §2.1 |
un membre de dictionnaire, "signature-agent";key="sig1" | le membre, sérialisé à nouveau | §2.1.2 |
content-digest | sha-256=:…: | le seul moyen de couvrir le corps (RFC 9530) |
Paramètres (§2.3) : created (recommandé) et expires sont des horodatages UNIX ; nonce est une valeur unique qui sert à détecter le rejeu ; keyid désigne la clé ; alg désigne l’algorithme, pris dans le registre ; tag désigne l’application, de sorte qu’une signature produite pour un protocole n’est pas acceptée par un autre. Les algorithmes enregistrés sont rsa-pss-sha512, rsa-v1_5-sha256, hmac-sha256, ecdsa-p256-sha256, ecdsa-p384-sha384 et ed25519 (§3.3).
Une implémentation confrontée aux spécifications
Le module ci-dessous signe et vérifie avec Ed25519. Il analyse et sérialise les Structured Fields (champs structurés) avec le paquet structured-headers (2.1.1, MIT), car analyser Signature-Input à la main est une source assurée de bugs d’interopérabilité.
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")}:`;
Avant de vous y fier, vérifiez-le avec des vecteurs publiés : l’annexe B.2.6 de la RFC 9421 (une signature Ed25519 sur la requête de test de la RFC) et l’annexe E.2.1 du projet Web Bot Auth. Les deux utilisent la clé de test de la RFC, dont seule la moitié publique est nécessaire :
// 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
Les sept premières lignes forment la base de signature, identique à celle que reproduit la RFC. Remarquez ce qui n’y figure pas : la chaîne de requête (@path l’exclut) et le corps.
Côté serveur : ce qu’il faut exiger
La RFC 9421 §3.2 énumère les étapes de vérification ; celles qui décident si une signature a un sens relèvent de l’application : quels composants doivent être couverts, quel âge une signature peut avoir, d’où vient la clé et quels algorithmes sont autorisés (§3.2.1). La clé doit (« MUST ») provenir d’une source à laquelle le vérificateur fait déjà confiance ; un keyid qu’il ne connaît pas fait échouer la vérification (§3.2, étape 5). Si l’algorithme est connu par plusieurs sources (la configuration, la clé et alg), elles doivent concorder (étape 6.5).
Ici, un client signe un appel à une API de commandes, et le serveur exige que la méthode, l’autorité, le chemin, la chaîne de requête et le condensat du corps soient couverts, ainsi qu’un nonce neuf :
// 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
Toutes les manipulations ont été rejetées, chacune par l’un des trois contrôles. La modification du corps échoue dès le premier contrôle, car le condensat ne correspond plus ; celles de la chaîne de requête et de la méthode échouent parce qu’elles changent un composant couvert, si bien que la base de signature diffère. Le rejeu échoue sur le nonce. Sans le registre des nonces, le rejeu aurait réussi pendant toute la minute qui précède expires : le §7.2.2 indique explicitement qu’une signature portant sur une partie d’un message peut être attachée à un autre message qui comporte les mêmes parties. Deux autres détails du code : le nonce n’est enregistré qu’une fois la signature vérifiée, si bien que des requêtes parasites non signées ne peuvent pas remplir le registre, et le registre des nonces doit être partagé entre les instances et conservé aussi longtemps qu’une signature peut être acceptée.
Clés, algorithmes et demandes de signature
Clés. La RFC 9421 laisse la distribution des clés à l’application. Pour un ensemble fixe de clients d’API, enregistrez la clé publique de chaque client lors de son intégration et utilisez son empreinte JWK comme keyid ; pour les écosystèmes ouverts, publiez les clés à une URL que le vérificateur peut rattacher à une identité, comme le fait Web Bot Auth. La rotation fonctionne comme pour un JWKS : publiez la nouvelle clé, commencez à signer avec elle, puis retirez l’ancienne (voir Rotation des clés JWKS sans interruption de service). La règle est une clé par client : le projet Web Bot Auth interdit purement et simplement les secrets partagés HMAC (§6.4), car un secret que tous les vérificateurs connaissent ne prouve rien sur l’identité du signataire.
Algorithmes. Ed25519 offre les clés et les signatures les plus petites (64 octets) et est déterministe ; ecdsa-p256-sha256 convient aux clés conservées dans un KMS cloud ou un HSM qui ne prend pas en charge Ed25519 ; rsa-pss-sha512 est là pour les infrastructures RSA. La comparaison de l’article ES256 vs EdDSA pour les JWT s’applique telle quelle. Quel que soit votre choix, le vérificateur fixe l’ensemble des algorithmes autorisés et rejette tout le reste, au lieu de suivre le paramètre alg du message (§3.2, étape 6).
Demander une signature. Un serveur peut indiquer à un client ce qu’il doit signer avec Accept-Signature (§5), un dictionnaire de même syntaxe que Signature-Input, par exemple sig1=("@method" "@target-uri" "content-digest");created;tag="app-123". Un paramètre nonce placé là demande au client d’inclure exactement cette valeur, ce qui transforme la protection contre le rejeu en mécanisme défi-réponse. Le projet Web Bot Auth utilise le même champ avec 403 Forbidden lorsqu’une origine veut qu’un bot signe (§5.3).
Réponses. Les signatures fonctionnent aussi dans l’autre sens. Un serveur signe sa réponse avec @status et des en-têtes de réponse, et peut la lier à la requête à laquelle elle répond en couvrant des composants de la requête avec le paramètre req, par exemple "@authority";req (§2.4). C’est ainsi qu’un client peut s’assurer qu’un webhook ou une réponse d’API provient du bon serveur et répond bien à sa requête, même à travers des intermédiaires.
Comparaison avec DPoP et mTLS. DPoP lie un jeton OAuth à une clé et ne signe que la méthode et l’URL ; le TLS mutuel authentifie la connexion, qui s’arrête au premier proxy qui termine TLS. Les signatures de messages HTTP survivent aux proxys et peuvent couvrir le corps, au prix d’un accord sur les composants à signer.
Web Bot Auth : des signatures pour les robots d’indexation et les agents d’IA
Les sites web identifient depuis longtemps les robots d’indexation par leurs chaînes User-Agent et leurs plages d’adresses IP, deux signaux faciles à falsifier ou difficiles à maintenir. Le groupe de travail Web Bot Auth de l’IETF normalise à cet effet un profil de la RFC 9421, limité aux sites qui servent des humains et à des clients tels que les robots d’indexation des moteurs de recherche, les outils d’archivage et les agents d’IA. Son projet de protocole, draft-ietf-webbotauth-httpsig-protocol-00 (1er septembre 2026), exige :
- au moins
@authorityou@target-uricouvert, pluscreatedetexpires(expiration recommandée de 24 heures au plus),tag="web-bot-auth"et unkeyidégal à l’empreinte JWK SHA-256 de la clé ; - un en-tête
Signature-Agentqui indique où sont publiées les clés du bot, couvert par la signature. Dans le projet actuel, c’est un dictionnaire,sig1="https://bot.example"; les projets antérieurs utilisaient une simple chaîne,"https://bot.example"; - un annuaire de clés : un jeu de clés JWK servi à
/.well-known/http-message-signatures-directorysur l’origine de l’agent, avec le type de médiaapplication/http-message-signatures-directory+json. Le projet autorise aussi unjwks_uriou un document de métadonnées d’ID client, choisi par un paramètretype.
Notre code de signature, confronté dans les deux sens à la bibliothèque web-bot-auth 0.2.0 de Cloudflare (Apache-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}`);
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
Les deux implémentations acceptent les signatures l’une de l’autre, et la bibliothèque de Cloudflare exige alg, que les exemples du projet comportent toujours. Le deuxième résultat est celui à retenir : les mêmes en-têtes ont été vérifiés sur un autre chemin du même site, car la signature ne couvre que @authority et l’agent. Le projet le dit sans détour : une telle signature « se vérifie pour toute méthode, tout chemin ou tout corps envoyé à cette autorité, jusqu’à son expiration ». Une signature de bot prouve quel agent a envoyé du trafic vers votre site dans une fenêtre de temps donnée, et non qu’il a émis cette requête précise. S’il vous en faut davantage, exigez @method et @path (le projet recommande aux agents de les ajouter) ou un nonce, et acceptez qu’un CDN puisse alors rejeter les requêtes dont il a réécrit le chemin.
En pratique, octobre 2026
OpenAI indique dans sa documentation que l’agent de ChatGPT signe ses requêtes selon la RFC 9421 avec Signature-Agent: "https://chatgpt.com" (centre d’aide d’OpenAI). Son annuaire de clés, récupéré pour cet article :
// 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
Le kid est bien l’empreinte, comme l’exige le projet, et la valeur exp de la clé tombait sept jours après l’heure de la requête lors de deux récupérations faites à quelques minutes d’intervalle : l’annuaire la calcule à chaque réponse, si bien qu’un vérificateur doit récupérer l’annuaire à nouveau plutôt qu’épingler la clé. Notez aussi la forme chaîne de Signature-Agent, antérieure au dictionnaire du projet actuel.
Cet écart compte aussi côté vérificateur. La documentation Web Bot Auth de Cloudflare (mise à jour le 8 octobre 2026) indique que son vérificateur suit le projet antérieur sur l’annuaire, attend la forme chaîne et fait échouer les requêtes qui utilisent la forme dictionnaire, que le projet de l’IETF impose désormais aux signataires (MUST). Elle précise aussi qu’il ne valide pas encore les nonces. Tant que les deux n’auront pas convergé, un bot qui veut être vérifié par Cloudflare envoie la forme chaîne, et un site qui vérifie lui-même les bots devrait accepter les deux, comme le projet le permet aux vérificateurs.
Hors du monde des bots, le fediverse passe de draft-cavage-http-signatures-12, antérieur à la RFC, à la RFC 9421 : d’après le fichier FEDERATION.md de Mastodon, les versions 4.5 et ultérieures acceptent les signatures RFC 9421 avec rsa-v1_5-sha256, et la 4.7 ajoute ed25519 et utilise la RFC 9421 pour les requêtes sortantes lorsqu’un serveur rejette l’ancien format.
Erreurs fréquentes
- Faire confiance à une signature sans vérifier ce qu’elle couvre. Une signature valide sur
@authorityseul ne dit rien du chemin ni du corps. Fixez côté serveur la liste des composants requis. - Couvrir un corps sans
Content-Digest. Il n’existe pas de composant pour le corps ; l’en-tête de condensat en tient lieu, et le serveur doit le comparer aux octets qu’il a reçus. - Reconstruire
@authorityou@target-urià partir d’une URL interne derrière un proxy. Utilisez l’hôte public que le client a signé ; voir l’annexe B.3 de la RFC sur les proxys qui terminent TLS. - Prendre la clé dans le message.
keyidest une clé de recherche dans votre configuration ou dans un annuaire auquel vous avez choisi de faire confiance, jamais une URL à récupérer aveuglément. - Aucune stratégie contre le rejeu. Un
expirescourt, un registre des nonces pour les requêtes qui modifient l’état, ou les deux. - Sérialiser les champs structurés à la main. L’ordre des paramètres, les guillemets et les séquences d’octets doivent être exacts ; une bibliothèque telle que structured-headers élimine toute une catégorie de bugs.
Sources
- RFC 9421 : HTTP Message Signatures, §2, §3, §7, annexe B
- RFC 9530 : Digest Fields
- RFC 8941 : Structured Field Values for HTTP
- Groupe de travail Web Bot Auth de l’IETF et draft-ietf-webbotauth-httpsig-protocol-00
- Cloudflare : Web Bot Auth et la bibliothèque web-bot-auth
- OpenAI : liste d’autorisation de l’agent ChatGPT
- FEDERATION.md de Mastodon