Коротко. HTTP Message Signatures (подписи HTTP-сообщений; RFC 9421, февраль 2024 года) позволяют клиенту подписать выбранные части HTTP-сообщения, которые называются компонентами: производные значения, такие как @method, @authority, @path и @query, и поля заголовка, такие как content-digest. Клиент отправляет два заголовка: Signature-Input со списком компонентов и параметров (created, expires, nonce, keyid, alg, tag) и Signature с подписью, вычисленной по канонической базе подписи. Сервер заново строит базу по полученному запросу, находит ключ по keyid в собственной конфигурации и проверяет подпись. Всё, что подпись не охватывает, не защищено, поэтому сервер должен требовать, чтобы подпись охватывала компоненты, на которые он опирается, а тело охватывается только через Content-Digest. С помощью того же механизма, оформленного как профиль Web Bot Auth, краулеры и ИИ-агенты, например агент ChatGPT, теперь представляются сайтам.
Формат
Подписанный запрос содержит оба заголовка; значения в них записаны под меткой (здесь sig1), чтобы несколько подписей могли сосуществовать:
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 изменить нельзя.
Из чего строится база подписи:
| Компонент | Значение | Примечания |
|---|---|---|
@method | POST | § 2.2.1 |
@authority | api.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-digest | sha-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 Fields) он разбирает и сериализует с помощью пакета structured-headers (2.1.1, MIT), потому что самописный разбор Signature-Input — верный источник ошибок совместимости.
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")}:`;
Прежде чем доверять модулю, проверьте его на опубликованных векторах: приложение B.2.6 к RFC 9421 (подпись Ed25519 для тестового запроса из RFC) и приложение E.2.1 к черновику Web Bot Auth. Оба вектора используют тестовый ключ из RFC, и нужна только его открытая часть:
// 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
Первые 7 строк — база подписи, и она совпадает с базой, приведённой в RFC. Обратите внимание, чего в ней нет: строки запроса (@path её не включает) и тела.
Сервер: что требовать
RFC 9421, § 3.2 перечисляет шаги проверки, но решения, от которых зависит, значит ли подпись хоть что-нибудь, принимает приложение: какие компоненты должны быть охвачены, насколько старой может быть подпись, откуда берётся ключ и какие алгоритмы допустимы (§ 3.2.1). Ключ ДОЛЖЕН («MUST») браться из источника, которому проверяющая сторона уже доверяет; запрос с неизвестным ей keyid проверку не проходит (§ 3.2, шаг 5). Если алгоритм известен из нескольких мест (из конфигурации, ключа и alg), все они должны указывать на один и тот же алгоритм (шаг 6.5).
В этом примере клиент подписывает вызов API заказов, а сервер требует охвата метода, authority, пути, строки запроса и дайджеста тела, а также свежего nonce:
// 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
Каждая подмена отклонена на одной из трёх проверок. Запрос с изменённым телом не проходит уже первую проверку, потому что дайджест больше не совпадает; подмена строки запроса и метода не проходит, потому что меняет охваченный подписью компонент, и база подписи получается другой. Проверка 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 подходит для ключей в облачном KMS или HSM без поддержки Ed25519; rsa-pss-sha512 предусмотрен для инфраструктуры на RSA. Сравнение из статьи ES256 или EdDSA для JWT применимо здесь без изменений. Что бы вы ни выбрали, проверяющая сторона фиксирует набор допустимых алгоритмов и отклоняет всё остальное, а не следует alg из сообщения (§ 3.2, шаг 6).
Запрос подписи. Сервер может сообщить клиенту, что подписывать, через Accept-Signature (§ 5) — словарь в том же синтаксисе, что и Signature-Input, например sig1=("@method" "@target-uri" "content-digest");created;tag="app-123". Параметр nonce в нём просит клиента включить в подпись именно это значение, и защита от повторного использования превращается в схему challenge-response. Черновик Web Bot Auth использует то же поле вместе с 403 Forbidden, когда источник (origin) хочет, чтобы бот подписывал запросы (§ 5.3).
Ответы. Подписи работают и в обратную сторону. Сервер подписывает свой ответ, охватывая @status и заголовки ответа, и может привязать его к запросу, на который отвечает: для этого он включает в подпись компоненты запроса с параметром req, например "@authority";req (§ 2.4). Так клиент может убедиться, что вебхук или ответ API пришёл от нужного сервера и относится именно к его запросу, даже если по пути были посредники.
Сравнение с DPoP и mTLS. DPoP привязывает токен OAuth к ключу и подписывает только метод и URL; взаимная TLS-аутентификация подтверждает подлинность на уровне соединения, а оно заканчивается на первом прокси, на котором завершается TLS. HTTP Message Signatures не теряются при прохождении через прокси и могут охватывать тело, но за это сторонам приходится договариваться, какие компоненты подписывать.
Web Bot Auth: подписи для краулеров и ИИ-агентов
Сайты давно распознают краулеров по строкам User-Agent и диапазонам IP-адресов; и то и другое либо легко подделать, либо трудно поддерживать. Рабочая группа Web Bot Auth в IETF стандартизирует для этого профиль RFC 9421, рассчитанный на сайты, которые обслуживают людей, и на клиентов вроде поисковых краулеров, систем веб-архивации и ИИ-агентов. Её черновик протокола, draft-ietf-webbotauth-httpsig-protocol-00 (1 сентября 2026 года), требует:
- охвата как минимум
@authorityили@target-uri, а также параметровcreatedиexpires(срок действия рекомендуется делать не больше 24 часов),tag="web-bot-auth"иkeyid, равного отпечатку JWK SHA-256 этого ключа; - заголовка
Signature-Agent, который указывает, где опубликованы ключи бота, и охвачен подписью. В текущем черновике это словарь,sig1="https://bot.example"; ранние черновики использовали простую строку,"https://bot.example"; - каталога ключей: набора JWK, доступного по пути
/.well-known/http-message-signatures-directoryв источнике агента, с медиатипомapplication/http-message-signatures-directory+json. Черновик также допускаетjwks_uriили документ метаданных идентификатора клиента (client ID metadata document); вариант выбирается параметромtype.
Наш код подписи, перекрёстно проверенный в обоих направлениях с библиотекой Cloudflare web-bot-auth 0.2.0 (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
Обе реализации принимают подписи друг друга, а библиотека Cloudflare требует alg, который всегда есть в примерах черновика. Запомнить стоит второй результат: те же заголовки прошли проверку на другом пути того же сайта, потому что подпись охватывает только @authority и указание на агента. Черновик говорит об этом прямо: такая подпись «проходит проверку с любым методом, путём или телом, отправленными на этот authority, пока не истечёт её срок действия». Подпись бота доказывает, какой агент отправлял трафик на ваш сайт в определённый промежуток времени, но не то, что этот конкретный запрос отправил именно он. Если нужно больше, требуйте @method и @path (черновик рекомендует агентам их добавлять) или nonce и учтите, что тогда CDN может отклонять запросы, путь в которых она сама переписала.
На практике: октябрь 2026 года
OpenAI указывает в документации, что агент ChatGPT подписывает свои запросы по RFC 9421 с заголовком Signature-Agent: "https://chatgpt.com" (справочный центр OpenAI). Его каталог ключей, загруженный для этой статьи:
// 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
kid — это отпечаток, как и требует черновик, а exp ключа при двух загрузках с разницей в несколько минут оба раза отстоял от момента запроса на 7 дней: каталог вычисляет его для каждого ответа, поэтому проверяющая сторона должна загружать каталог заново, а не закреплять ключ. Обратите внимание и на строковую форму Signature-Agent: она появилась раньше словаря из текущего черновика.
Это расхождение важно и для проверяющей стороны. В документации Cloudflare по Web Bot Auth (обновлена 8 октября 2026 года) сказано, что средство проверки Cloudflare следует более раннему черновику о каталоге ключей, ожидает строковую форму и отклоняет запросы со словарной формой, которую, согласно черновику IETF, подписанты теперь ДОЛЖНЫ отправлять. Там же сказано, что значения nonce оно пока не проверяет. Пока Cloudflare и черновик не придут к одному варианту, бот, который хочет пройти проверку Cloudflare, отправляет строковую форму, а сайту, который проверяет ботов сам, следует принимать обе формы, как черновик и разрешает проверяющим сторонам.
Помимо ботов, федиверс переходит с черновика draft-cavage-http-signatures-12, предшествовавшего RFC, на RFC 9421: согласно FEDERATION.md Mastodon, версии 4.5 и новее принимают подписи RFC 9421 с rsa-v1_5-sha256, а 4.7 добавляет ed25519 и использует RFC 9421 для исходящих запросов, если сервер отклоняет старый формат.
Частые ошибки
- Доверие к подписи без проверки того, что она охватывает. Верная подпись, охватывающая только
@authority, ничего не говорит о пути или теле. Задайте обязательные компоненты списком на стороне сервера. - Охват тела без
Content-Digest. Компонента для тела нет: его роль играет заголовок с дайджестом, и сервер должен сравнить дайджест с байтами, которые получил. - Восстановление
@authorityили@target-uriпо внутреннему URL, когда сервер стоит за прокси. Используйте публичный хост, подписанный клиентом; см. приложение B.3 к RFC о прокси, на которых завершается TLS. - Получение ключа из сообщения.
keyid— это значение для поиска ключа в вашей конфигурации или в каталоге, которому вы решили доверять, и никогда не URL, который можно загружать не глядя. - Нет стратегии защиты от повторов. Короткий
expires, хранилище nonce для запросов, изменяющих состояние, или и то и другое. - Ручная сериализация структурированных полей. Порядок параметров, кавычки и байтовые последовательности должны быть точными; библиотека вроде structured-headers избавляет от целого класса ошибок.
Источники
- RFC 9421: HTTP Message Signatures, § 2, § 3, § 7, приложение B
- RFC 9530: поля дайджеста (Digest Fields)
- RFC 8941: значения структурированных полей HTTP (Structured Field Values for HTTP)
- Рабочая группа IETF Web Bot Auth и draft-ietf-webbotauth-httpsig-protocol-00
- Cloudflare: Web Bot Auth и библиотека web-bot-auth
- OpenAI: агент ChatGPT в списках разрешённых
- Mastodon FEDERATION.md