本文へスキップ
ecdsa.com
← すべての記事

HTTP メッセージ署名(RFC 9421):API とボットでの使い方

TLS は接続を保護しますが、リクエストがプロキシを通過した後は、誰がそのリクエストを送ったのかについて何も示しません。HTTP メッセージ署名はリクエストの重要な部分に署名するので、サーバーは自分でそれを検証できます。この記事では、形式をバイト単位で示し、RFC のテストベクトルと Cloudflare のライブラリで検証した実装を紹介し、クローラーや AI エージェントがいまこの仕組みを使って自らの身元を証明している方法を解説します。

要点:HTTP メッセージ署名(RFC 9421、2024年2月)を使うと、クライアントは HTTP メッセージのうち選んだ部分に署名できます。この部分をコンポーネントと呼び、@method、@authority、@path、@query などの派生値や、content-digest などのヘッダーフィールドがあります。クライアントは 2 つのヘッダーを送ります。コンポーネントとパラメーター(created、expires、nonce、keyid、alg、tag)を列挙する Signature-Input と、正規化された署名ベースに対する署名である Signature です。サーバーは受け取ったリクエストから署名ベースを組み立て直し、keyid をもとに自身の設定から鍵を探して検証します。署名の対象に含まれないものは保護されないため、サーバーは自らが依存するコンポーネントを必須にしなければなりません。また、ボディを対象に含める手段は Content-Digest しかありません。同じ仕組みを Web Bot Auth としてプロファイル化したものが、ChatGPT エージェントなどのクローラーや AI エージェントが現在ウェブサイトに対して自らを名乗るための方法です。

形式

署名付きリクエストは両方のヘッダーを持ち、それぞれの値はラベル(ここでは sig1)をキーにしているので、複数の署名を共存させられます。

後述の api.mjs を実行したときの 2 つの署名ヘッダー(出力の最初の 2 行)
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 の形の行を 1 行ずつ並べ、最後に "@signature-params" の行を置いたものです。この最後の行は、コンポーネントのリストとパラメーターを Signature-Input に現れるとおりにそのまま繰り返します。パラメーターの行にも署名するため、created、expires、nonce、keyid、tag も書き換えられません。

構成要素は次のとおりです。

コンポーネント値備考
@methodPOST§2.2.1
@authorityapi.exampleホストとデフォルト以外のポート、小文字化(§2.2.3)
@path、@query/orders、?dry_run=1§2.2.6、§2.2.7。@query-param なら個々のパラメーターに署名(§2.2.8)
@target-uri完全な URL中継者がクエリの順序を変えると検証できなくなる
フィールド(content-type)前後の空白を除いた値§2.1
辞書のメンバー("signature-agent";key="sig1")再シリアライズしたメンバー§2.1.2
content-digestsha-256=:…:ボディを対象に含める唯一の方法(RFC 9530)

パラメーター(§2.3):created(推奨)と expires は UNIX タイムスタンプです。nonce はリプレイ検出のための一意な値です。keyid は鍵を、alg はレジストリに登録されたアルゴリズムを指定します。tag はアプリケーションを示し、これによって、あるプロトコル向けに作られた署名が別のプロトコルで受け入れられることはなくなります。登録済みのアルゴリズムは rsa-pss-sha512、rsa-v1_5-sha256、hmac-sha256、ecdsa-p256-sha256、ecdsa-p384-sha384、ed25519 です(§3.3)。

仕様と照合した実装

以下のモジュールは Ed25519 で署名と検証を行います。構造化フィールド(Structured Fields)の解析とシリアライズには structured-headers パッケージ(2.1.1、MIT)を使います。Signature-Input を手書きのコードで解析すると、相互運用性のバグがほぼ確実に生まれるからです。

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

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

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

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

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

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

信頼する前に、公開されているテストベクトルと照合してください。対象は RFC 9421 の付録 B.2.6(RFC のテストリクエストに対する Ed25519 署名)と、Web Bot Auth のドラフトの付録 E.2.1 です。どちらも RFC のテスト鍵を使っており、必要なのはその公開鍵の部分だけです。

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

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

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

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

最初の 7 行が署名ベースで、RFC に掲載されているものと一致します。ここに含まれていないものに注意してください。クエリ文字列(@path には含まれません)とボディです。

サーバー側:何を必須にするか

RFC 9421 §3.2 は検証の手順を列挙していますが、署名に意味があるかどうかを左右する手順はアプリケーションに委ねられています。どのコンポーネントを対象に含めなければならないか、署名がどれだけ古くてもよいか、鍵をどこから取得するか、どのアルゴリズムを許可するか(§3.2.1)は、アプリケーションが決めます。鍵は、検証者がすでに信頼している情報源から取得しなければなりません(MUST)。検証者が知らない keyid は検証失敗になります(§3.2 のステップ 5)。アルゴリズムが設定、鍵、alg のように複数の箇所からわかる場合、それらは一致しなければなりません(ステップ 6.5)。

次の例では、クライアントが注文 API の呼び出しに署名し、サーバーはメソッド、オーソリティ、パス、クエリ、ボディのダイジェストが対象に含まれていることに加えて、新しい nonce を要求します。

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

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

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

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

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

どの改ざんも、3 つのチェックのいずれかで拒否されました。ボディを変えたリクエストは、ダイジェストが一致しなくなるため最初のチェックで失敗します。クエリとメソッドを変えたリクエストは、署名対象のコンポーネントが変わって署名ベースが異なるため失敗します。リプレイは nonce のチェックで失敗します。nonce ストアがなければ、expires までの 1 分間ずっとリプレイが成功していたはずです。§7.2.2 は、メッセージの一部に対する署名を、同じ部分を持つ別のメッセージに付けられることを明記しています。コードからは、ほかにも 2 つの詳細がわかります。nonce は署名の検証に成功した後でだけ保存されるので、署名のない無意味なリクエストでストアが埋まることはありません。また、nonce ストアはインスタンス間で共有し、署名が受け入れられうる間はエントリを保持しなければなりません。

鍵、アルゴリズム、署名の要求

鍵:RFC 9421 は鍵の配布をアプリケーションに任せています。API クライアントの顔ぶれが決まっている場合は、オンボーディング時に各クライアントの公開鍵を登録し、その JWK サムプリントを keyid に使います。オープンなエコシステムでは、Web Bot Auth のように、検証者が身元と結び付けられる URL で鍵を公開します。ローテーションは JWKS と同じ要領で行います。新しい鍵を公開し、その鍵で署名を始め、それから古い鍵を廃止します(ダウンタイムなしの JWKS 鍵ローテーションを参照)。クライアントごとに 1 つの鍵を使うのが原則です。Web Bot Auth のドラフトは HMAC の共有シークレットをはっきり禁止しています(§6.4)。すべての検証者が知っているシークレットでは、誰が署名したかについて何も証明できないからです。

アルゴリズム:Ed25519 は鍵と署名(64 バイト)がもっとも小さく、決定的です。ecdsa-p256-sha256 は、Ed25519 に対応していないクラウド KMS や HSM に保管した鍵に向いています。rsa-pss-sha512 は RSA の基盤のために用意されています。JWT 署名の ES256 と EdDSA の比較がそのまま当てはまります。どれを選ぶにしても、検証者はメッセージの alg に従うのではなく、許可するアルゴリズムの集合を固定し、それ以外はすべて拒否します(§3.2 のステップ 6)。

署名の要求:サーバーは Accept-Signature(§5)を使って、何に署名すべきかをクライアントに伝えられます。これは Signature-Input と同じ構文の辞書で、たとえば sig1=("@method" "@target-uri" "content-digest");created;tag="app-123" のようになります。ここに nonce パラメーターを含めると、その値をそのまま入れるようクライアントに求めることになり、リプレイ対策がチャレンジレスポンス方式になります。Web Bot Auth のドラフトは、オリジンがボットに署名を求めるときに、同じフィールドを 403 Forbidden とともに使います(§5.3)。

レスポンス:署名は逆方向にも使えます。サーバーは @status とレスポンスヘッダーを対象にしてレスポンスに署名し、req パラメーターでリクエストのコンポーネントを対象に含めることで、そのレスポンスを応答先のリクエストに結び付けられます。たとえば "@authority";req のように指定します(§2.4)。これにより、webhook や API のレスポンスが正しいサーバーから届き、自分のリクエストへの応答であることを、中継者を経由していてもクライアントが確認できます。

DPoP や mTLS との比較:DPoP は OAuth トークンを鍵に紐付けますが、署名するのはメソッドと URL だけです。相互 TLS は接続を認証しますが、その接続は TLS を終端する最初のプロキシで終わります。HTTP メッセージ署名はプロキシを経由しても失われず、ボディも対象に含められます。その代わり、どのコンポーネントに署名するかを双方で合意しておく必要があります。

Web Bot Auth:クローラーと AI エージェントのための署名

ウェブサイトは長い間、User-Agent 文字列と IP アドレスの範囲でクローラーを識別してきましたが、どちらも偽装が簡単だったり、維持が大変だったりします。IETF の Web Bot Auth ワーキンググループは、この用途に向けて RFC 9421 のプロファイルを標準化しています。対象は、人間にサービスを提供するサイトと、検索クローラー、アーカイブサービス、AI エージェントなどのクライアントです。プロトコルのドラフト draft-ietf-webbotauth-httpsig-protocol-00(2026年9月1日)は、次のことを求めています。

  • 少なくとも @authority または @target-uri を対象に含め、さらに created と expires(有効期限は作成から最長 24 時間後までを推奨)、tag="web-bot-auth"、鍵の JWK SHA-256 サムプリントである keyid を付けます。
  • ボットの鍵の公開場所を示す Signature-Agent ヘッダーを、署名の対象に含めます。現行のドラフトではこれは辞書形式の sig1="https://bot.example" で、以前のドラフトでは単純な文字列の "https://bot.example" でした。
  • 鍵ディレクトリを用意します。これは、エージェントのオリジンの /.well-known/http-message-signatures-directory で、メディアタイプ application/http-message-signatures-directory+json として配信する JWK セットです。ドラフトでは、type パラメーターで選ぶ jwks_uri やクライアント ID メタデータドキュメントも使えます。

この記事の署名処理を、Cloudflare の web-bot-auth ライブラリ 0.2.0(Apache-2.0)と双方向で照合しました。

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

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

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

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

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

2 つの実装は互いの署名を受け入れます。また、Cloudflare のライブラリは alg を必須としており、ドラフトの例には常にこれが含まれています。覚えておくべきなのは 2 つ目の結果です。署名の対象が @authority とエージェントだけなので、同じヘッダーが同じサイトの別のパスでも検証に通りました。ドラフトもこの点をはっきり述べており、このような署名は「有効期限が切れるまで、そのオーソリティに送られるあらゆるメソッド、パス、ボディに対して検証に成功する」としています。ボットの署名が証明するのは、ある時間枠の中でどのエージェントがサイトにトラフィックを送ったかであって、そのエージェントがこの特定のリクエストを送ったことではありません。それ以上の保証が必要なら、@method と @path(ドラフトはエージェントがこれらを加えることを推奨しています)または nonce を必須にしてください。その場合、CDN が自らパスを書き換えたリクエストを拒否しうることは受け入れる必要があります。

実際の状況(2026年10月)

OpenAI は、ChatGPT エージェントが RFC 9421 と Signature-Agent: "https://chatgpt.com" を使ってリクエストに署名することを公表しています(OpenAI ヘルプセンター)。この記事のために取得した、その鍵ディレクトリは次のとおりです。

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

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

ドラフトの要求どおり、kid はサムプリントです。また、数分の間隔をおいて 2 回取得したところ、どちらでも鍵の exp はリクエスト時刻の 7 日後でした。ディレクトリはこの値をレスポンスごとに計算しているので、検証者は鍵をピン留めせず、取得し直さなければなりません。Signature-Agent が文字列形式であることにも注意してください。これは現行ドラフトの辞書形式より前の形式です。

この食い違いは検証側でも問題になります。Cloudflare の Web Bot Auth ドキュメント(2026年10月8日更新)によると、Cloudflare の検証処理は以前のディレクトリのドラフトに従っており、文字列形式を前提とし、辞書形式を使うリクエストを検証失敗にします。一方、IETF のドラフトは現在、署名者は辞書形式を送らなければならない(MUST)としています。同ドキュメントには、nonce をまだ検証していないことも明記されています。両者が一致するまでは、Cloudflare に検証されたいボットは文字列形式を送り、ボットを自前で検証するサイトは、ドラフトが検証者に認めているとおり、両方の形式を受け入れるべきです。

ボット以外では、フェディバースが RFC 以前の draft-cavage-http-signatures-12 から RFC 9421 への移行を進めています。Mastodon の FEDERATION.md によると、4.5 以降は rsa-v1_5-sha256 による RFC 9421 署名を受け入れ、4.7 では ed25519 が追加され、相手のサーバーが古い形式を拒否した場合は送信リクエストに RFC 9421 を使います。

よくある間違い

  1. 署名が何を対象にしているかを確かめずに信頼する:@authority だけを対象にした有効な署名は、パスやボディについて何も示しません。必須コンポーネントはサーバー側のリストとして定めてください。
  2. Content-Digest を使わずにボディを対象に含めようとする:ボディそのものを表すコンポーネントはなく、ダイジェストのヘッダーがその役割を担います。サーバーは、このヘッダーを受信したバイト列と照合しなければなりません。
  3. プロキシの背後で @authority や @target-uri を内部 URL から組み立て直す:クライアントが署名した公開ホストを使ってください。TLS を終端するプロキシについては、RFC の付録 B.3 を参照してください。
  4. メッセージから鍵を取得する:keyid は、自分の設定や信頼すると決めたディレクトリから鍵を引くための検索キーであり、無条件に取得しにいく URL ではありません。
  5. リプレイ対策がない:短い expires、状態を変更するリクエスト向けの nonce ストア、またはその両方で対策してください。
  6. 構造化フィールドを手作業でシリアライズする:パラメーターの順序、引用符、バイト列は正確でなければなりません。structured-headers のようなライブラリを使えば、この種のバグをまとめてなくせます。

出典