Webhook署名の解説
最終更新:
Webhook署名とは、送信側サービスが各配信に付ける短い文字列、つまり送信者と受信者だけが共有するシークレットを使って、リクエストの正確なバイト列に対して計算されたHMACです。その目的は、受信サーバーが何かを実行する前に2つの問いに答えられるようにすることにあります。本当に名乗っているサービスから来たのか。ボディは送られたとおりの形で届いたのか。本記事では、その検証が実際にどう働くかを説明します。HMACが何を対象とするか、パース済みのオブジェクトではなく生ボディに対して確認しなければならないのはなぜか、比較を定数時間で実行しなければならないのはなぜか、タイムスタンプがリプレイ攻撃をどう限定するか、そしてなぜプロバイダごとにヘッダーの形式が違うのか。まず実物の配信を見たい場合(ヘッダー、署名、生ボディをまとめて)は、送信元を Webhookテスター に向けて、届いたものを点検してみてください。
秘密のURLは認証ではない理由
最も古いWebhookの防御は、推測できないURL、つまり送信側サービスだけが知る長いランダムなパス部分です。パスワードのように感じますが、振る舞いは住所と同じで、住所は漏れます。そのURLはアクセスログ、プロキシのログ、監視ダッシュボード、バグレポートのスクリーンショット、そして送信側サービス自体の設定ページに書き込まれ、サポートエンジニアなら誰でも、あるいはそれらのシステムのどれか1つを侵害した者なら誰でも読めます。パスワードは保存時にハッシュ化し、ユーザーごとにローテーションできますが、URLは使われる場所すべてに逐語的にコピーされ、そのコピー1つ1つが恒常的な招待状になります。
より深い問題は、エンドポイントがリクエストの到着後にそれをどう扱うかです。Webhookハンドラは請求書を支払い済みにし、アカウントを作成し、ライセンスを付与し、データを削除します。どれも偽造する価値のある操作であり、偽造リクエストのコストは、攻撃者がインターネットのどこかから送るHTTP POST 1回分にすぎません。TLSはここでは役に立ちません。TLSが認証するのは、接続してきた相手に対してあなたのサーバーであり、接続してきたのが誰かについては何も言わないからです。署名は信頼の置き場所を「どこを叩けばいいか知っている」から「リクエスト自体には決して現れないシークレットを知っている」へ移します。これは範疇としてより強い主張です。
HMAC署名が実際に証明すること
送信者と受信者は、エンドポイント登録時に生成されるシークレットを共有します。配信ごとに、送信者は定義されたバイト列に対してHMAC、つまり鍵付きハッシュを計算します。通常は生のリクエストボディで、その前にタイムスタンプが付くことが多く、配信IDが含まれることもあります。結果はヘッダーに入って送られます。受信者は、受け取ったバイト列に対して自分のシークレットコピーでHMACを再計算し、2つの値を比較します。一致すれば2つのことが同時に証明されます。送信者がシークレットを保持していたこと、そして対象バイト列が送信者の署名したバイト列とビット単位で同一であることです。金額フィールドの1文字を変えれば、再計算した値は完全に乖離します。
2つの限界ははっきり述べておく価値があります。第一に、署名は暗号化ではありません。ボディは、見られる位置にいる者には読める形でネットワークを渡ります(プライベートに保つのはTLSの仕事です)。署名が保証するのは出所と完全性だけです。第二に、ボディの単なるハッシュでは何も証明できません。誰でも何でもハッシュ化できるからです。ハッシュをメッセージ認証コードに変えるのはシークレットです。本サイトのハッシュ生成ツールは鍵なしハッシュを計算します。チェックサムとしては問題ありませんが、誰でも再計算できるチェックサムは、誰のことも認証しません。
生ボディをバイト単位で検証する
「本物の配信なのに検証が失敗する」最も多い原因は攻撃者ではなく、受信者自身のフレームワークです。署名は、送信されたとおりのボディに対して計算されています。このキーの順序、この空白、これらのエスケープ文字、この数値の書式で。大半のWebフレームワークは、ハンドラが動く前にJSONボディをオブジェクトへ親切にパースします。そのオブジェクトを再シリアライズして検証すると、同じデータが違うバイト列になります。キーは並べ替えられ、空白が加わり、1e3 は 1000 と描かれ、ユニコードはエスケープされたりされなかったりします。意味的には同一でも暗号学的には別物で、本当に真正だった配信の検証が失敗します。Stripeのドキュメントはこの規則を率直に述べています。署名検証には生ボディが必要で、フレームワークはそれに手を加えてはなりません。
運用上の帰結は順序の問題です。何かがパースする前に生のリクエストボディを取得し、そのバイト列に対して署名を検証し、それから初めてパースして処理します。ボディを書き換えるプロキシやミドルウェア(再エンコード、正規化、整形表示)は、パーサーとまったく同様に署名を壊します。だからデバッグは、リクエストを到着した正確な形で見ることから始まります。受信ツールが与えてくれるのはその視点です。配信が Webhookテスター に届いたとき、点検するのは手の加わっていないリクエスト、送信されたとおりのヘッダーとボディであり、それに対して署名を正直に確認できます。
定数時間比較と、時計が署名の一部である理由
2つのHMAC値の比較は自明な手順に見えますが、微妙な漏れを隠しています。通常の文字列比較は最初に一致しない文字で止まるため、推測が早い段階で外れるほど速く戻り、正しい接頭辞が長くなるほど遅く戻ります。多数のリクエストを重ねると、その時間差によって攻撃者はシークレットを知らないまま、1文字ずつ測りながら有効な署名へ近づけます。定数時間比較は常に対象全体を調べ、値が最初の文字で違っても最後の文字で違っても同じ時間がかかります。プラットフォームのドキュメントはこの点について率直です。たとえばGitHubのドキュメントは、単純な等価演算子を明示的に戒め、crypto.timingSafeEqual のような定数時間関数を指し示しています。ライブラリの関数を使ってください。比較ループを自分で書いてはいけません。
新鮮さは第3の性質ですが、署名だけでは得られません。捕捉された真正な配信を逐語的にリプレイすれば、完全に有効な署名を持っています。だから多くの方式はタイムスタンプを署名対象バイト列に織り込み、受信者はタイムスタンプが許容範囲の外にある配信を拒否します。Stripeの方式では、そのドキュメントによれば既定で5分です。この範囲は意図的な妥協です。時計のずれと遅いキューを吸収できるだけ広く、捕捉されたリクエストがすぐ陳腐化するだけ狭く。範囲内での防御はべき等性に委ねられます。処理したすべての配信IDやイベントIDを記録し、重複を落とすのです。これはどのみち必要です。正直な送信者は再試行し、再試行とは善意のリプレイだからです。
プロバイダごとにヘッダーの見た目が違う理由
仕組みは標準的ですが、包装は収束しませんでした。Stripeは t=<timestamp>,v1=<signature> という形の Stripe-Signature ヘッダーを送り、複数の v1 値を同時に含めることがあります。GitHubは X-Hub-Signature-256 を送り、その値はアルゴリズム接頭辞付きの文字列 sha256=<hex digest> です。タイムスタンプがないため、GitHubの配信には組み込みの有効期限がなく、リプレイ防御は配信IDの重複排除に全面的に依存します。Standard Webhooks仕様はこの問題を決着させようとする試みです。配信ID、タイムスタンプ、署名に別々のヘッダーを使い、署名はその3つとボディに対して計算します。新しいプロバイダが互換性のない変種を発明し続けなくて済むようにするためです。これらの違いは歴史的な偶然であって、異なるセキュリティモデルではありません。どの方式でも不変なのは同じです。共有シークレット、定義された対象バイト列、そして定数時間比較です。
Stripeの形式に複数の v1 値があるのは、すべての受信者が遅かれ早かれ直面する操作、つまりシークレットのローテーションのためです。素朴にやれば(シークレットを変え、デプロイし、祈る)、ローテーションは飛行中のすべてを壊します。古いシークレットで署名された再試行や待機中の配信が突然失敗するからです。機能するパターンは並行ウィンドウです。重なりの期間中は古いシークレットと新しいシークレットの両方の署名を受け付け、新しい方で配信が検証できることを確認し、それから初めて古いものを引退させます。同じウィンドウのパターンは、キーIDと重なるキーを持つJWTの世界にも現れ、チームがそれを省いたときの失敗の仕方も同じです。JWTの失敗例の記事が述べているとおりです。ダウンタイムなしにローテーションできないシークレットは、すべきときにもローテーションされません。漏れた当日でさえも。だからこそ、地味な並行ウィンドウは後付けの工夫ではなく、プロトコルの一部なのです。