HMAC で Webhook 署名を検証する
Webhook エンドポイントになぜ HMAC 認証が必要か、標準的な提供者の署名パターン、生ボディと定数時間の罠、そして HMAC が守らないものを整理します。
Webhook エンドポイントは公開 URL です。Stripe、GitHub、あるいはあなたの決済
処理業者がそこへイベントを POST しますが、パスを知った者なら誰でもできます。
そしてパスは漏れます。ブラウザのネットワークタブ、プロキシのログ、エラー
トラッカー、ときどき貼り付けられた curl コマンドに載ります。届いた JSON が
何であれそれに従って動くエンドポイントは、いずれ偽造された
payment.succeeded イベントに従って動くエンドポイントです。認証は任意では
ありません。Webhook 署名が存在する理由のすべてです。
単なるハッシュでは足りない理由
素朴な防御は、リクエストボディをハッシュして比較することです。何もしません。 SHA-256 のようなハッシュは公開された関数です。秘密も鍵もありません。ボディを 偽造する攻撃者は、あなたと同じようにその SHA-256 を計算して付け、あなたの チェックを通過します。非対称性がないので、セキュリティもありません。
必要なのは、共有された秘密を持つ当事者 だけ が作れるタグです。それが HMAC です。
HMAC の基礎
HMAC は鍵付きハッシュです。メッセージと秘密鍵を受け取り、固定サイズのタグを 生みます。
HMAC-SHA256(secret, message)→ 32 バイト。
秘密がなければタグを計算することも検証することもできません。送信側と受信側が 同じ秘密を持つので、両方とも与えられたボディに対してタグを計算でき、ほかの 誰もできません。ボディの 1 バイトを改ざんする攻撃者は、もう一致しないタグを 作り、鍵なしでは正しいものを生成できません。
鍵となる言葉は 共有 です。HMAC は対称です。同じ秘密が署名し、検証します。 それが、最後に改めて触れる帰結を生みます。
標準的な提供者のパターン
実際の Webhook 提供者は同じ形に収束し、その細部こそ実装が壊れる場所なので、 正確に名前を付ける価値があります。
- 提供者は 生のリクエストボディ に対して、通常は タイムスタンプ と
連結して (例:
timestamp + "." + body)HMAC-SHA256を計算します。 - 得られたタグを 16 進数か base64 でエンコードし、タイムスタンプとともに リクエストの ヘッダー に送ります。
- 受信側は生のボディとタイムスタンプを読み、共有秘密で HMAC を再計算し、 結果をヘッダーの値と比較します。
Stripe と GitHub はどちらも HMAC-SHA256 と署名ヘッダーでこのパターンに従い ます。Stripe はさらにタイムスタンプを署名し、リクエストをある時点に結び付け ます。正確なヘッダー名とエンコーディングは提供者ごとに異なるので、文字どおりの 文字列は提供者のドキュメントを見てください。しかし上記の暗号学的な形は不変 です。
受信側の擬似コード
SECRET = load_from_secrets_manager()
TOLERANCE = 300 # 秒
def verify(request):
raw_body = request.read_raw_bytes() # パース済みの JSON ではない
sig_header = request.header("X-Signature")
timestamp = request.header("X-Timestamp")
# 1. リプレイのウィンドウ
if abs(now() - int(timestamp)) > TOLERANCE:
reject("stale request")
# 2. 正確に署名されたペイロードで再計算
signed_payload = timestamp + "." + raw_body
expected = hmac_sha256(SECRET, signed_payload) # 16 進文字列
# 3. 定数時間で比較
if not constant_time_equals(expected, sig_header):
reject("bad signature")
return parse_json(raw_body) # ここで初めてパースが安全
生ボディの罠
現場で Webhook の検証が失敗する最もよくある理由は、間違ったバイトを署名する ことです。提供者は自分が送った正確なバイト列を署名しました。フレームワークが JSON をパースし、あなたがそれを再直列化して HMAC を計算すると、別の バイトを 得ます。キーの順序が変わり、空白がたたまれ、Unicode のエスケープが正規化され、 末尾の改行が消えます。再直列化した JSON に対する HMAC は、元に対する HMAC と 一致しません。
JSON ミドルウェアが触れる 前に 生のボディを捕まえてください。Express では Webhook ルートに生ボディのパーサを、積極的にパースするフレームワークでは ルートごとのオプトアウトがしばしば必要です。その正確なバイトに対して検証した 後で、パースしてください。
リプレイ保護
有効な署名付きリクエストは、攻撃者が捕まえて送り直しても依然として有効です。
HMAC だけでは「このボディは秘密を持つ誰かから来た」と言うだけで、いつ か
何回 かは何も言いません。リプレイ保護がなければ、捕まえられた
refund.created は秘密が交換されるまで再送できます。
標準的な緩和策は、あなたがすでに署名しているタイムスタンプです。
- 提供者は署名されたペイロードの中にタイムスタンプを含めます。
- 受信側は、タイムスタンプが許容ウィンドウ (5 分がよくあるデフォルト) の外に あるリクエストを拒否します。
ウィンドウは本物のトレードオフです。きつすぎると正当な再試行や、提供者と受信側 の間の時計のずれが誤った拒否を起こし、ゆるすぎるとリプレイのウィンドウが 広がります。より強い保証が欲しければ、nonce や提供者のイベント ID を記録して 重複を拒否してください。ただしそれは、保存期間と一貫性の問題を別に抱える ストレージを要求します。タイムスタンプのウィンドウは実用的な下限であり、あなた 側の冪等性 (同じイベント ID を最大 1 回だけ処理) が持続可能な答えです。
定数時間の比較
期待されるタグと受信したタグを得たら、== で比較するのは脆弱性です。通常の
文字列比較は、一致しないバイトを見つけた瞬間に返ります。それは、最初のバイトが
一致する誤った推測が、すぐに失敗するものより測定可能なだけ長くかかるという
ことです。あなたの応答時間を計れる攻撃者は、正しいタグを 1 バイトずつ復元
できます。典型的なタイミング攻撃です。
最初の差がどこにあっても、常に全長を検査する定数時間の比較を使ってください。
- Python:
hmac.compare_digest(a, b) - Node.js:
crypto.timingSafeEqual(bufA, bufB)(長さの等しいバッファ) - Go:
hmac.Equal(macA, macB)
これらはまさにこの目的のために存在します。秘密や導出したタグを比較するたびに、
== ではなくそれに手を伸ばしてください。
HMAC が与えないもの
HMAC の検証は送信者を認証し、完全性を守ります。人々がそれが与えると思い込む 次のいくつかは与え ません。
- 機密性。 ボディは接続が暗号化されていない限り平文で移動します。HMAC は 何も隠しません。TLS を使い、Webhook を HTTPS でのみ受け取ってください。
- 否認防止。 秘密が 共有 されているので、有効なタグは 秘密を持つ誰か がそれを作ったことだけを証明し、そこには受信側であるあなたも含まれます。 提供者が、あなた自身のサービスではなく、与えられたリクエストを生成したことを 第三者に証明することはできません。対称な認証は相互的であって、帰属可能では ありません。
否認防止には非対称署名が必要です。署名者が秘密鍵を握り、検証者は公開鍵だけを 握り、有効な署名は秘密鍵の保有者からしか生まれません。それが RS256、ES256、 EdDSA の背後にあるモデルです。対比は JWT 署名アルゴリズム を見てください。それでも ほとんどの Webhook 提供者はわざと対称な HMAC を選びます。より速く、当事者が ちょうど 2 者のとき秘密の配布問題が些細であり、サーバー対サーバーのイベント フィードで否認防止が目標であることはまれだからです。
ハッシュと鍵付きハッシュの区別がまだあいまいなら、 ハッシュ・暗号化・エンコーディング が、なぜ素のハッシュは秘密を持たず、鍵付きハッシュは持つのかを解きほぐします。
まとめのチェックリスト
正しい Webhook 検証はこうです。
- 生のボディのバイト を読みます。決して再直列化した JSON ではありません。
- 提供者が署名した正確なペイロードに対して、タイムスタンプを含めて
HMAC-SHA256(secret, signed_payload)を再計算します。 - 定数時間 の関数でタグを比較します。
- タイムスタンプの許容ウィンドウの外のリクエストを拒否し、イベントの処理を 冪等 に扱います。
- HTTPS で動かし、秘密をシークレットマネージャーの他の資格情報と同じように 扱い、交換の経路を前もって計画します。
連携をデバッグしながら HMAC タグを手で計算したり確認したりするとき、つまり 思っているバイトを署名しているかを確かめるとき、私たちの HMAC 生成器 は秘密・メッセージ・ハッシュ関数を受け取り、 タグを 16 進数か base64 で返します。