REST・GraphQL・gRPC・Webhook の記事で、Webhook は「サービスの側から、こちらが用意した URL へ通知が届く」しくみだと書きました。ただ、その受け口の URL はインターネットに公開されるので、届いた通知が本当にそのサービスから来たのかをどう見分けるのかが気になっていました。この記事では、Webhook の「署名」のしくみを、GitHub・Stripe・Slack・Microsoft Graph の公式ドキュメントで確かめながら、順番に図にしました。
- Webhook の受け口には誰でも送れるので、送り主を確かめる必要があること
- 署名は、2者だけが知る秘密の値と本文から作る「封印」のようなもの(HMAC)
- 受け取った側が同じ計算をして比べる流れと、そのときの注意点
- 時刻と ID で、同じ通知の使い回しを防ぐ方法
- GitHub・Slack・Stripe・Microsoft Graph での形の違い
なぜ Webhook に署名が要るのか
Webhook では、通知を受け取る側が受け口(エンドポイント)の URL を用意し、インターネットに公開します。送り手のサービスは、イベントが起きるとその URL に HTTP の POST を送ります。
ここで困るのが、URL さえ分かれば誰でも同じ形の POST を送れることです。受け口から見ると、本物の通知も、誰かが送ったそっくりな通知も、ただの HTTP リクエストに見えます。Stripe のドキュメントは、確かめずに受け取ると、偽の通知で注文の処理やアカウントへのアクセスの付与、記録の書き換えが動いてしまうおそれがあると説明しています。
そこで、送り手は通知に署名を付けて送り、受け口は署名を確かめてから処理します。署名で確かめられるのは、次の2つです。
- 本物の送り手から来たこと:署名を作れるのは、秘密の値を知っている送り手だけです。
- 途中で書き換えられていないこと:本文が1文字でも変わると、署名が合わなくなります。
署名のしくみ:合言葉で作る「封印」
Webhook の署名には、多くのサービスが HMAC を使っています。HMAC は RFC 2104 で決められた、ハッシュ関数と秘密の鍵を使ってメッセージを認証するしくみです。
たとえるなら、送り手と受け手の2人だけが知っている合言葉(同じ秘密の値)を使って、手紙の中身から「封印」を作るようなものです。封印は、中身と合言葉の両方から作るので、次のようになります。
- 同じ中身と同じ合言葉からは、いつも同じ封印ができる。
- 中身が1文字でも違うと、まったく違う封印になる。
- 合言葉を知らない人は、正しい封印を作れない。
図2の値は、GitHub のドキュメントが「自分の確かめ方が正しいかを試すための値」として載せている秘密の値(It's a Secret to Everybody)と本文(Hello, World!)です。実際に Python で計算すると、GitHub のドキュメントにある署名と同じ値になりました。本文の最後に「!」を1つ足すと、まったく違う値になります。
GitHub のテスト用の値で HMAC-SHA256 を計算した結果
$ python3 -c "import hmac, hashlib; print(hmac.new(b\"It's a Secret to Everybody\", b'Hello, World!', hashlib.sha256).hexdigest())"
757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17
$ python3 -c "import hmac, hashlib; print(hmac.new(b\"It's a Secret to Everybody\", b'Hello, World!!', hashlib.sha256).hexdigest())"
6b2274b6b366c126fb29c1098e660deb5071a7dbdbff5fa20ede4fa152367752
署名を付けても、本文はそのまま読める形で送られます。署名が守るのは「本物か」「書き換えられていないか」で、中身を隠すのは HTTPS の役目です。GitHub も Stripe も、受け口に HTTPS を使うことを求めています。なお、HMAC は送り手と受け手が同じ秘密の値を持つ方式です。仕様によっては、公開鍵で確かめる署名を使うものもあります(Standard Webhooks の v1a など)。
署名を確かめる流れ
送り手と受け口の間で、署名がどう作られ、どう確かめられるかを順番に見ます。
秘密の値を両方に設定する
最初に、送り手と受け口がまったく同じ秘密の値を持ちます(図3の⓪)。両方が同じ値を持つので、共有鍵(共通鍵)とも呼ばれます。
この値は、ネットワークの上で自動でやり取りされるものではありません。Webhook を登録するときに、管理者が送り手の管理画面で値を入れる(または発行された値を表示する)。そして、同じ値をコピーして、受け口のサーバにも設定します。つまり、登録のときに1回だけ、人が管理画面を通して手渡しする形です。
| サービス | 秘密の値の決め方 | 受け口への渡し方 |
|---|---|---|
| GitHub | Webhook の設定画面の「Secret」に、自分で決めた文字列を入れる | 同じ文字列を、受け口のサーバが読める安全な場所に置く |
| Stripe | 受け口を登録すると、Stripe が whsec_ で始まる値を発行する。ダッシュボードでは「Reveal secret」で表示でき、API で登録した場合は応答に入って返ってくる | 表示された値をコピーして、受け口のサーバに置く |
一度渡したあとは、両方が同じ値を持ち続けます。通知のたびに秘密の値を送ることはなく、送られるのは本文と署名だけです(図4の③)。
GitHub は、秘密の値をランダムで推測しにくい(エントロピーの高い)文字列にすることと、アプリのコードやリポジトリに直接書かず、サーバが読める安全な場所に置くことを勧めています。
送り手が署名を付けて送る
イベントが起きると、送り手は本文と秘密の値から HMAC を計算し、その結果を HTTP のヘッダに入れて POST します(図3の①〜③)。送られるのは署名だけで、秘密の値そのものはネットワークに流れません。
GitHub の Webhook が届くときのヘッダの例です。X-Hub-Signature-256 が署名で、sha256= のあとに HMAC-SHA256 の値が16進数で入ります。
GitHub の Webhook のヘッダの例(GitHub のドキュメントより)
> POST /payload HTTP/1.1 > X-GitHub-Delivery: 72d3162e-cc78-11e3-81ab-4c9367dc0958 > X-Hub-Signature: sha1=7d38cdd689735b008b3c702edd92eea23791c5f6 > X-Hub-Signature-256: sha256=d57c68ca6f92289e6987922ff26938930f6e66a2d161ef06abdf1859230aa23c > User-Agent: GitHub-Hookshot/044aadd > Content-Type: application/json > Content-Length: 6615 > X-GitHub-Event: issues > X-GitHub-Hook-ID: 292430182 > X-GitHub-Hook-Installation-Target-ID: 79929171 > X-GitHub-Hook-Installation-Target-Type: repository
| ヘッダ | 意味 |
|---|---|
| X-Hub-Signature-256 | 本文を、秘密の値を鍵にして HMAC-SHA256 で計算した値。sha256= で始まる。秘密の値を設定していない Webhook には付かない |
| X-Hub-Signature | 同じ計算を SHA-1 で行った値。古いしくみとの互換のために残されているもので、GitHub は X-Hub-Signature-256 を使うよう勧めている |
| X-GitHub-Delivery | 届いた通知ごとの一意な ID(GUID) |
| X-GitHub-Event | 通知のきっかけになったイベントの名前(この例では issues) |
受け口が計算し直して比べる
受け口は、届いた本文と自分の持っている秘密の値で同じ計算をし、届いた署名と比べます(図3の④⑤)。一致すれば処理に進み、一致しなければ処理せずに断ります。
GitHub のドキュメントにある Python の例です。署名のヘッダがないときと、署名が合わないときに 403 を返しています。
受け口で署名を確かめる例(GitHub のドキュメントより)
import hashlib
import hmac
def verify_signature(payload_body, secret_token, signature_header):
"""Verify that the payload was sent from GitHub by validating SHA256.
Raise and return 403 if not authorized.
Args:
payload_body: original request body to verify (request.body())
secret_token: GitHub app webhook token (WEBHOOK_SECRET)
signature_header: header received from GitHub (x-hub-signature-256)
"""
if not signature_header:
raise HTTPException(status_code=403, detail="x-hub-signature-256 header is missing!")
hash_object = hmac.new(secret_token.encode('utf-8'), msg=payload_body, digestmod=hashlib.sha256)
expected_signature = "sha256=" + hash_object.hexdigest()
if not hmac.compare_digest(expected_signature, signature_header):
raise HTTPException(status_code=403, detail="Request signatures didn't match!")
hmac.new(…)の行:届いた本文(payload_body)と秘密の値で HMAC-SHA256 を計算する。expected_signatureの行:先頭にsha256=を付けて、ヘッダと同じ形にする。hmac.compare_digestの行:計算した値と、届いた署名を比べる。一致しなければ 403。
確かめるときの4つの注意
流れ自体は単純ですが、ドキュメントを読むと、どのサービスも同じところに注意を書いています。
1. 本文は受け取ったまま使う
署名は、送り手が送ったバイト列そのものから計算されています。受け口のフレームワークが本文を JSON として読み、書き出し直してから計算すると、空白や項目の並び順、文字コードが変わり、意味は同じでも署名が合わなくなります。
Stripe は、フレームワークが本文を書き換えないようにし、生の本文(raw body)で確かめるよう求めています。Slack も、JSON などに変換する前の本文を使うよう書いています。GitHub は、プロキシやロードバランサが本文やヘッダを書き換えると確かめに失敗すること、本文は UTF-8 として扱うことを注意しています。
2. 比べるときは、かかる時間が変わらない方法で
文字列を普通の == で比べると、多くの実装では最初に違う文字が見つかった時点で比べるのをやめます。すると、どこまで合っていたかによって、比べ終わるまでの時間がわずかに変わります。GitHub は「普通の == を使わない」と書き、どの位置で違っても同じ時間で比べる関数(Python の hmac.compare_digest など)を使うよう勧めています。Stripe、Slack、Standard Webhooks も同じことを書いています。
3. 時刻と ID で使い回しを防ぐ
署名が正しくても、本物の通知を写しておいて、あとでそのまま送り直されると、署名は正しいまま届きます。これをリプレイ(使い回し)と呼びます。防ぎ方は2つあります。
- 時刻を見る:送った時刻を署名する中身に入れておき、受け取った時刻と比べて古すぎれば断ります。時刻も署名されているので、時刻だけを新しく書き換えると署名が合わなくなります。
- ID を見る:通知ごとの ID を記録しておき、処理済みの ID は二度処理しません。
Stripe は、Stripe-Signature ヘッダに時刻(t=)と署名(v1=)を入れて送ります。署名は「時刻」「.」「本文」をつなげた文字列から計算し、Stripe のライブラリは既定で時刻と現在の差が5分までを許します。
Stripe-Signature ヘッダの例(Stripe のドキュメントより。実際のヘッダは1行)
Stripe-Signature: t=1492774577, v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd, v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39
v0= は、テスト用のイベントにだけ付く偽の署名です。Stripe は、v1 以外の方式は無視するよう書いています。Slack も時刻のヘッダ(X-Slack-Request-Timestamp)を署名に入れ、現在の時刻と5分以上ずれていれば受け付けないよう勧めています。
GitHub の署名には時刻が入っていません。その代わり、GitHub は X-GitHub-Delivery(通知ごとに一意な ID)を使って、同じ通知を二度処理しないようにすることを勧めています。
時刻で判断するので、受け口のサーバの時計がずれていると、本物の通知まで断ってしまいます。Stripe は NTP で時刻を合わせるよう書いています。また、許す幅を 0 にすると時刻の確認そのものが無効になるので、使わないよう注意しています。
4. 秘密の値を守り、入れ替えられるようにする
署名の強さは、秘密の値が漏れていないことが前提です。漏れたかもしれないときや定期的に、秘密の値を入れ替えます。Stripe では、古い秘密の値を最大24時間まで残したまま新しい値に切り替えられ、その間は秘密の値ごとに1つずつ、複数の署名が付いて届きます。受け口のコードを新しい値に切り替える時間を取れるしくみです。
サービスごとの違い
ここからは、一般的なしくみではなく、サービスごとの決まりです。どのサービスも「秘密の値 + HMAC-SHA256」という点は同じで、違うのは本文に何を足してから計算するかと、ヘッダの書き方です。
| サービス | 署名のヘッダ | 署名する中身 | 使い回しの防ぎ方 |
|---|---|---|---|
| GitHub | X-Hub-Signature-256(sha256=+16進数) | 本文 | X-GitHub-Delivery の ID |
| Slack | X-Slack-Signature(v0=+16進数) | v0:時刻:本文 | X-Slack-Request-Timestamp で5分以内 |
| Stripe | Stripe-Signature(t= と v1=) | 時刻.本文 | t= の時刻。ライブラリの既定は5分 |
| Standard Webhooks | webhook-signature(v1,+Base64) | ID.時刻.本文 | webhook-timestamp と webhook-id |
Standard Webhooks は、Webhook の送り方と署名の形を共通の決まりとしてまとめた仕様です。ID・時刻・署名の3つのヘッダを決めていて、webhook-id を「同じ通知を二度処理しないためのキー」に使うよう書いています。
Microsoft Graph の変更通知:署名ではなく clientState
Microsoft Graph の変更通知(Webhook)は、HMAC の署名ヘッダを付けるしくみではありません。代わりに、購読(サブスクリプション)を作るときに自分で決めた秘密の文字列 clientState を渡しておき、届いた通知に同じ値が入っているかで確かめます。
購読を作るリクエストの例(Microsoft のドキュメントより)
POST https://graph.microsoft.com/v1.0/subscriptions
Content-Type: application/json
{
"changeType": "created,updated",
"notificationUrl": "https://webhook.azurewebsites.net/notificationClient",
"lifecycleNotificationUrl": "https://webhook.azurewebsites.net/api/lifecycleNotifications",
"resource": "/me/mailfolders('inbox')/messages",
"expirationDateTime": "2016-03-20T11:00:00.0000000Z",
"clientState": "SecretClientState"
}
- 受け口の確認(②③):購読を作るとき、Microsoft Graph は
validationTokenを付けた POST を受け口に送ります。受け口は10秒以内に、200 とtext/plainで、URL デコードした同じ値を返します。失敗すると購読はできません。 - 通知の確認(⑥):届いた通知の
clientStateが、購読を作ったときの値と一致するかを確かめます。一致しなければ、正しい通知として扱いません。Microsoft は、この値を自分のアプリと Microsoft Graph だけが知る秘密にしておくよう書いています。 - 応答(⑦):3秒以内に 2xx を返せば届いたことになります。時間がかかる処理は、確かめてキューに入れてから 202 を返すよう勧めています。
- 中身付きの通知:リソースのデータを含む通知(リッチ通知)には
validationTokensという JWT が付き、有効期限、Microsoft ID プラットフォームが発行したものか、自分のアプリ宛てか、などを確かめます。
署名とあわせて使う守り
署名で確かめられるのは「本物か」「書き換えられていないか」です。通り道や量の問題は、ほかの守りで受け持ちます。受け口の手前から順に並べると、次のようになります。
| 守り | 内容 |
|---|---|
| HTTPS | 通知の中身を途中で読まれないようにする。Stripe は TLS 1.2 以上が必要。GitHub は証明書の検証を有効にしておくよう勧めている |
| 送り元の IP | 送り手が公開している IP アドレスだけを受け付ける。GitHub は GET /meta で IP の一覧を取れ、ときどき変わるので更新するよう書いている。Stripe は IP の許可リストと署名の両方を使うよう勧め、Microsoft Graph も通知を送る IP の一覧を公開している |
| 署名 | この記事の本題。本物の送り手から、書き換えられずに届いたかを確かめる |
| 時刻・ID | 使い回しを断る。重複して届くこともあるので、ID で二度処理しない作りにしておく |
| イベントの種類 | 必要なイベントだけを購読し、届いたイベントの種類を確かめてから処理する(GitHub の X-GitHub-Event など) |
| すぐ 2xx を返す | GitHub は10秒以内に 2xx を返すこと、Stripe も時間のかかる処理の前に 2xx を返すことを求めている。重い処理はキューに入れて後で行う |
企業での例
社内のシステムで Webhook を受けるときの例です。どれも、受け口がインターネットから届く場所にある点は同じです。
| 場面 | 送り手 | 受け口で確かめること |
|---|---|---|
| リポジトリへの push を受けて、社内の自動化の仕組みを動かす | GitHub | X-Hub-Signature-256 を計算し直して比べる。X-GitHub-Delivery で二度処理しない。送り元の IP を GitHub の一覧で絞る |
| 決済サービスの「支払い完了」で注文を確定する | Stripe | Stripe-Signature の v1 と時刻を確かめる。イベントの ID を記録して、同じ注文を二度確定しない |
| チャットのコマンドで社内ツールを呼ぶ | Slack | X-Slack-Signature と X-Slack-Request-Timestamp を確かめ、5分以上ずれていれば断る |
| メールの受信やファイルの変更を業務システムに知らせる | Microsoft Graph | clientState を確かめる。中身付きの通知なら validationTokens の JWT も確かめる |
うまくいかないとき
| 起きること | よくある原因 | 確かめること |
|---|---|---|
| 署名がいつも合わない | フレームワークが本文を JSON として読み直している。プロキシやロードバランサが本文を変えている | 生の本文で計算しているか。途中の機器が本文やヘッダを書き換えていないか |
| 署名が合わない | 秘密の値の取り違え | Stripe では、Stripe CLI で転送したイベントと、ダッシュボードで作った受け口とで秘密の値が違う。使っている値を出力して見比べる |
| 時刻が古いと言われる | 受け口のサーバの時計のずれ | NTP で時刻を合わせているか |
| 署名のヘッダが付いていない | GitHub で秘密の値を設定していない | Webhook の設定で Secret を入れたか |
まとめ
| 項目 | 内容 |
|---|---|
| なぜ要るか | 受け口の URL は公開されていて誰でも POST できる。署名で、本物の送り手から書き換えられずに届いたかを確かめる |
| しくみ | 2者だけが知る秘密の値と本文から HMAC-SHA256 を計算する。秘密の値そのものは送らない |
| 確かめ方 | 受け口で同じ計算をして、届いた署名と比べる。本文は受け取ったまま使い、比べるのは時間が変わらない関数で |
| 使い回し | 時刻を署名に入れて古いものを断る(Stripe・Slack は5分)。ID で二度処理しない |
| サービスの違い | 署名する中身とヘッダの書き方が違う。Microsoft Graph は署名ではなく clientState と validationTokens で確かめる |
| あわせる守り | HTTPS、送り元の IP、イベントの種類、すぐに 2xx を返して後で処理 |
参考にした資料
この記事は、次の標準と公式ドキュメントで内容を確かめて書きました。仕様や対応状況は変わることがあるので、最新の情報はリンク先で確かめてください。
- RFC 2104: HMAC: Keyed-Hashing for Message Authentication(IETF):HMAC がハッシュ関数と秘密の鍵でメッセージを認証するしくみであること、鍵はランダムに選ぶこと
- Standard Webhooks specification(Standard Webhooks):webhook-id・webhook-timestamp・webhook-signature、署名する中身、v1 と v1a、時刻の許容、ID による重複の防止、一定時間の比較
- Validating webhook deliveries・Webhook events and payloads・Best practices for using webhooks(GitHub Docs):X-Hub-Signature-256 の作り方、テスト用の値、Python の例、ヘッダの例と意味、秘密の値の置き場所、HTTPS・IP・10秒以内の応答・X-GitHub-Delivery
- Receive Stripe events in your webhook endpoint・Resolve webhook signature verification errors(Stripe):Stripe-Signature の例、署名する中身、5分の許容、v1 以外を無視すること、秘密の値の入れ替え、生の本文、秘密の値の取り違え
- Verifying requests from Slack(Slack):X-Slack-Signature と X-Slack-Request-Timestamp、v0:時刻:本文、5分、変換前の本文を使うこと
- Receive change notifications through webhooks・Set up Microsoft Graph change notifications with resource data(Microsoft Learn):購読のリクエストの例、clientState、validationToken と10秒、3秒以内の 2xx と 202、通知を送る IP、validationTokens の JWT の確かめ方
あとがき
署名というと難しく聞こえますが、やっていることは「同じ材料で同じ計算をして、答えが合うか」だけでした。実際に GitHub のテスト用の値で計算して、ドキュメントの値と一致し、「!」を1つ足しただけでまったく別の値になったのを見て、ようやく腑に落ちた気がします。むしろ実際につまずきやすいのは、本文を読み直してしまう、時計がずれている、といった周りの部分なのだと感じました。
コメント