HMAC-SHA256 によるウェブフック署名
node:crypto を使わず、Web Crypto で Workers から送信するウェブフックに署名する
概要
Worker からウェブフックを送信するとき、受信側にはそのリクエストが本当に自分から 来たものであり、転送中に改ざんされていないことを確認する手段が必要です。標準的な アプローチは、共有シークレットと HMAC 署名を使う方法です。送信側で HMAC-SHA256(body, secret) を計算し、16 進ダイジェストをヘッダーに入れて送ります。 受信側は受け取ったボディに対して同じ HMAC を再計算し、比較します。
Cloudflare Workers には node:crypto がありません。ここで使うものはすべて Web Crypto API(crypto.subtle)であり、Workers ランタイムでデフォルトで利用できます。
署名スキーム
このパターンは本番の通知 Worker から採ったもので、次の構成です。
ヘッダー:
X-Notify-Signature: sha256=<hex>ボディ: 生の UTF-8 JSON ペイロードのバイト列
キー: 受信者ごとの
webhook_secret(UTF-8 バイト列)アルゴリズム:
crypto.subtle.signによる HMAC-SHA256
sha256= というプレフィックスは広く使われている慣習です(GitHub のウェブフックも 同じ形式を使います)。アルゴリズム名をその場に埋め込むことでダイジェストの形式が 自己記述的になり、将来別のスキームへ移行する余地も残せます。
署名の計算
/**
* Compute HMAC-SHA256 over `body` using `secret`, returned as lowercase hex.
* Uses Web Crypto subtle.sign — no Node.js dependencies.
*/
export async function computeHmacHex(body: string, secret: string): Promise<string> {
const keyBytes = new TextEncoder().encode(secret);
const bodyBytes = new TextEncoder().encode(body);
const cryptoKey = await crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sigBuffer = await crypto.subtle.sign("HMAC", cryptoKey, bodyBytes);
const sigBytes = new Uint8Array(sigBuffer);
return Array.from(sigBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}押さえておきたい点がいくつかあります。
importKey("raw", …)はシークレットを生のキー材料としてインポートします。 アルゴリズム記述子{ name: "HMAC", hash: "SHA-256" }で SHA-256 を使う HMAC を 選択します。false引数はキーを抽出不可(non-extractable)に指定し、["sign"]は用途を 署名のみに限定します。crypto.subtle.signはArrayBufferを返します。各バイトをpadStart(2, "0")で ゼロ埋めした 2 文字の 16 進文字列へ変換すると、小文字の 16 進ダイジェストが 得られます。
ウェブフックの送信
export interface WebhookPayload {
id: string;
sourcePath: string;
sourceKind: string;
sourceItemId: string;
payloadSummary: string;
firedAt: string; // ISO 8601
channels: string[];
}
/**
* POST to the webhook URL with HMAC signature header.
* Throws on non-2xx or network error.
* Caller is responsible for applying timeouts.
*/
export async function sendWebhook(
webhookUrl: string,
webhookSecret: string,
payload: WebhookPayload,
): Promise<void> {
const body = JSON.stringify(payload);
const hmacHex = await computeHmacHex(body, webhookSecret);
const res = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Notify-Signature": `sha256=${hmacHex}`,
},
body,
});
if (!res.ok) {
throw new Error(`Webhook delivery error ${res.status}: ${webhookUrl}`);
}
}body は JSON.stringify で一度だけ計算し、署名とリクエストボディの 両方 に 使っている点に注目してください。これは偶然の細部ではなく、正しさの根拠そのもの です。次節で説明します。
受信側が見るバイト列そのものに署名する
受信側は、受け取った生のリクエストボディに対して HMAC-SHA256(rawBody, secret) を再計算し、こちらのダイジェストと比較して検証 します。この比較が成功するためには、受信側が読むことになる まったく同じバイト 列 に対して署名しなければなりません。
これはボディが再シリアライズされた瞬間に崩れます。受信側が JSON をパースしてから ハッシュ計算前に再び文字列化すると、キーの順序、空白、数値の表現などのわずかな 違いが異なるバイト列を生み、結果として異なるダイジェストになります。シークレットは 正しいのに署名が無効に見える、という事態が起こります。
再パースしたオブジェクトではなく、生のボディをハッシュする
両側とも、リクエストボディの文字列そのものに対して HMAC を実行してください。送信側 では fetch のボディに入れた文字列そのものに署名し、受信側ではリクエストから読み 取った文字列そのものを検証します。JSON.parse してから JSON.stringify し、その 結果をハッシュすることは絶対に避けてください。シリアライズを行うのは送信側で一度 だけであり、受信側はそのバイト列を不透明なものとして扱う必要があります。
受信側での検証
受信側は同じ Web Crypto の手順を実行し、生のボディに対してダイジェストを再計算して 比較します。computeHmacHex はそのまま再利用でき、異なるのは向きだけです。
// Both `received` and `expected` are meant to be the same fixed-length hex
// digest, so the length check below is safe: that length is public
// (HMAC-SHA256's fixed digest size), not something derived from `secret`.
// It also guarantees the two encoded buffers are the same length, which
// crypto.subtle.timingSafeEqual() requires: it throws, rather than
// returning false, on a length mismatch.
function timingSafeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) return false;
const encoder = new TextEncoder();
return crypto.subtle.timingSafeEqual(encoder.encode(a), encoder.encode(b));
}
/**
* Verify an incoming webhook request.
* Reads the RAW body once and hashes exactly those bytes.
*/
export async function verifyWebhook(request: Request, secret: string): Promise<boolean> {
const header = request.headers.get("X-Notify-Signature");
if (!header?.startsWith("sha256=")) {
return false;
}
const received = header.slice("sha256=".length);
// Read the raw body string and hash exactly these bytes.
const rawBody = await request.text();
const expected = await computeHmacHex(rawBody, secret);
return timingSafeEqualHex(received, expected);
}ハッシュ優先の一定時間比較
timingSafeEqualHex はバイトの不一致では早期リターンしません -- 結果を決めるのは crypto.subtle.timingSafeEqual() による一定時間の比較だけです。対照的に素朴な === は 2 つの文字列が異なった瞬間にリターンしてしまうため、レスポンスのタイミングを十分な精度で計測できる攻撃者はそれを利用して正しい署名を 1 バイトずつ復元できてしまいます。crypto.subtle.timingSafeEqual() は自前のループではなく、まさにこの比較のために Workers ランタイムが自ら提供するプリミティブです -- ECMAScript はどの JS の一片についても一定時間実行を保証しないため、JIT に最適化されたループは安全な代替にはなりません。これは、このサイトの他のシークレット比較レシピ全体で使われているのと同じ、ハッシュ優先・一定時間比較の形です -- 同じ手法を、すでに固定長のダイジェストではなく可変長のシークレット(パスワード)に適用した例については パスワードゲート を参照してください。
関連: Workers での Web Crypto
これは、認証レシピが検証側で使うのと同じツールキットの署名方向です。 Auth with Pages Functions は、同じ 「Web Crypto、node:crypto なし」というテーマのもとでトークンの扱いを取り上げて います。どちらも Node の crypto ライブラリではなく crypto.subtle に依拠しており、 向きが逆になっているだけです。ここでは署名を生成し、あちらでは呼び出し元を検証 します。