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 はそのまま再利用でき、異なるのは向きだけです。
/**
* 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 received.length === expected.length && received === expected;
}
💡 一定時間での比較
堅牢な実装にするなら、2 つの 16 進ダイジェストを一定時間(constant time)で比較し、 不一致がタイミングを通じて位置情報を漏らさないようにします。上記の長さチェックと 直接の文字列比較は、多くの内部サービスでは十分です。エンドポイントが公開されている 場合は一定時間比較の採用を検討してください。
関連: Workers での Web Crypto
これは、認証レシピが検証側で使うのと同じツールキットの署名方向です。
Auth with Pages Functions は、同じ
「Web Crypto、node:crypto なし」というテーマのもとでトークンの扱いを取り上げて
います。どちらも Node の crypto ライブラリではなく crypto.subtle に依拠しており、
向きが逆になっているだけです。ここでは署名を生成し、あちらでは呼び出し元を検証
します。