【図解】Webhookの署名とは?HMACで送り主と改ざんを確かめるしくみをGitHub・Stripe・Microsoft Graphの例で初心者向けに解説

REST・GraphQL・gRPC・Webhook の記事で、Webhook は「サービスの側から、こちらが用意した URL へ通知が届く」しくみだと書きました。ただ、その受け口の URL はインターネットに公開されるので、届いた通知が本当にそのサービスから来たのかをどう見分けるのかが気になっていました。この記事では、Webhook の「署名」のしくみを、GitHub・Stripe・Slack・Microsoft Graph の公式ドキュメントで確かめながら、順番に図にしました。

この記事でわかること
  1. Webhook の受け口には誰でも送れるので、送り主を確かめる必要があること
  2. 署名は、2者だけが知る秘密の値と本文から作る「封印」のようなもの(HMAC)
  3. 受け取った側が同じ計算をして比べる流れと、そのときの注意点
  4. 時刻と ID で、同じ通知の使い回しを防ぐ方法
  5. GitHub・Slack・Stripe・Microsoft Graph での形の違い

なぜ Webhook に署名が要るのか

Webhook では、通知を受け取る側が受け口(エンドポイント)の URL を用意し、インターネットに公開します。送り手のサービスは、イベントが起きるとその URL に HTTP の POST を送ります。

ここで困るのが、URL さえ分かれば誰でも同じ形の POST を送れることです。受け口から見ると、本物の通知も、誰かが送ったそっくりな通知も、ただの HTTP リクエストに見えます。Stripe のドキュメントは、確かめずに受け取ると、偽の通知で注文の処理やアカウントへのアクセスの付与、記録の書き換えが動いてしまうおそれがあると説明しています。

本物の送り手GitHub・Stripe など誰かURL を知っていれば送れるPOST「支払いが完了しました」POST「支払いが完了しました」Webhook の受け口インターネットに公開した URLどちらも同じ形のリクエストに見える見た目では本物か区別できない → 署名で「本物の送り手から、書き換えられずに届いた」かを確かめる
図1 受け口には、本物の送り手以外からも同じ形の POST が届きうる

そこで、送り手は通知に署名を付けて送り、受け口は署名を確かめてから処理します。署名で確かめられるのは、次の2つです。

  • 本物の送り手から来たこと:署名を作れるのは、秘密の値を知っている送り手だけです。
  • 途中で書き換えられていないこと:本文が1文字でも変わると、署名が合わなくなります。

署名のしくみ:合言葉で作る「封印」

Webhook の署名には、多くのサービスが HMAC を使っています。HMAC は RFC 2104 で決められた、ハッシュ関数と秘密の鍵を使ってメッセージを認証するしくみです。

たとえるなら、送り手と受け手の2人だけが知っている合言葉(同じ秘密の値)を使って、手紙の中身から「封印」を作るようなものです。封印は、中身と合言葉の両方から作るので、次のようになります。

  • 同じ中身と同じ合言葉からは、いつも同じ封印ができる。
  • 中身が1文字でも違うと、まったく違う封印になる。
  • 合言葉を知らない人は、正しい封印を作れない。
本文Hello, World!秘密の値(両者だけが知る)HMAC-SHA256で計算757107ea0eb2509f…署名本文Hello, World!!秘密の値(両者だけが知る)HMAC-SHA256で計算6b2274b6b366c126…まったく違う署名になる↓ 本文の最後に「!」を1文字足すと同じ本文と同じ秘密の値からは、いつも同じ署名ができる。どちらかが違えば、署名は合わない
図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 など)。

署名を確かめる流れ

送り手と受け口の間で、署名がどう作られ、どう確かめられるかを順番に見ます。

送り手GitHub など受け口自社のサーバ業務の処理注文の確定など⓪ 秘密の値を両方に設定① イベントが起きる② 本文と秘密の値で署名を計算③ POST 本文 + 署名ヘッダ秘密の値そのものは送らない④ 届いた本文と秘密の値で署名を計算し直す⑤ 届いた署名と比べる(一致するか)⑥ 一致したら処理へ⑦ 2xx を返す一致しなければ処理せずに断る(GitHub の Python の例は 403)
図3 Webhook の署名を作って確かめる流れ

秘密の値を両方に設定する

最初に、送り手と受け口がまったく同じ秘密の値を持ちます(図3の⓪)。両方が同じ値を持つので、共有鍵(共通鍵)とも呼ばれます。

この値は、ネットワークの上で自動でやり取りされるものではありません。Webhook を登録するときに、管理者が送り手の管理画面で値を入れる(または発行された値を表示する)。そして、同じ値をコピーして、受け口のサーバにも設定します。つまり、登録のときに1回だけ、人が管理画面を通して手渡しする形です。

送り手GitHub・Stripe など受け口自社のサーバ管理画面のWebhook の設定Secret安全な保管場所サーバが読める場所管理者① 値を入れるまたは発行された値を表示② 同じ値を置くコピーして設定する登録のときに1回だけ、人が手で渡すそのあとの通知(毎回)③ 本文 + 署名だけを送る秘密の値そのものは流れない
図4 秘密の値は、登録のときに管理画面を通して手で渡す
サービス秘密の値の決め方受け口への渡し方
GitHubWebhook の設定画面の「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 として読み、書き出し直してから計算すると、空白や項目の並び順、文字コードが変わり、意味は同じでも署名が合わなくなります。

届いた本文(バイト列){“id”: “evt_1”, “amount”: 500}◯ そのまま計算するHMAC で計算送り手と同じ署名× JSON として読み、書き出し直してから計算する{“amount”:500, “id”:”evt_1″}空白や並び順が変わるHMAC で計算署名が合わない中身の意味が同じでも、1バイトでも違えば署名は合わない。受け取ったままの本文で計算する
図5 受け取ったままの本文で計算する

Stripe は、フレームワークが本文を書き換えないようにし、生の本文(raw body)で確かめるよう求めています。Slack も、JSON などに変換する前の本文を使うよう書いています。GitHub は、プロキシやロードバランサが本文やヘッダを書き換えると確かめに失敗すること、本文は UTF-8 として扱うことを注意しています。

2. 比べるときは、かかる時間が変わらない方法で

文字列を普通の == で比べると、多くの実装では最初に違う文字が見つかった時点で比べるのをやめます。すると、どこまで合っていたかによって、比べ終わるまでの時間がわずかに変わります。GitHub は「普通の == を使わない」と書き、どの位置で違っても同じ時間で比べる関数(Python の hmac.compare_digest など)を使うよう勧めています。Stripe、Slack、Standard Webhooks も同じことを書いています。

3. 時刻と ID で使い回しを防ぐ

署名が正しくても、本物の通知を写しておいて、あとでそのまま送り直されると、署名は正しいまま届きます。これをリプレイ(使い回し)と呼びます。防ぎ方は2つあります。

時刻10:0010:0510:30許す幅(例:5分)本物の通知時刻:10:00ID:evt_1署名:正しい同じ通知をもう一度送る時刻:10:00(中身は同じ)ID:evt_1署名:正しい(そのまま)写しを取っておく確かめ方1:時刻10:30 に受け取り。5分を過ぎている → 断る確かめ方2:IDevt_1 はもう処理した → 二度は処理しない時刻も署名する中身に入れておけば、時刻だけを書き換えることはできない
図6 時刻と ID で、同じ通知の使い回しを断る
  • 時刻を見る:送った時刻を署名する中身に入れておき、受け取った時刻と比べて古すぎれば断ります。時刻も署名されているので、時刻だけを新しく書き換えると署名が合わなくなります。
  • 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」という点は同じで、違うのは本文に何を足してから計算するかと、ヘッダの書き方です。

サービス・署名のヘッダHMAC-SHA256 で署名する中身(左から順につなげる)GitHubX-Hub-Signature-256本文SlackX-Slack-Signaturev0:時刻:本文StripeStripe-Signature時刻.本文Standard Webhookswebhook-signatureID.時刻.本文どれも「秘密の値 + HMAC-SHA256」。違うのは、本文に何を足してから計算するか
図7 サービスごとの「署名する中身」
サービス署名のヘッダ署名する中身使い回しの防ぎ方
GitHubX-Hub-Signature-256(sha256=+16進数)本文X-GitHub-Delivery の ID
SlackX-Slack-Signature(v0=+16進数)v0:時刻:本文X-Slack-Request-Timestamp で5分以内
StripeStripe-Signature(t= と v1=)時刻.本文t= の時刻。ライブラリの既定は5分
Standard Webhookswebhook-signature(v1,+Base64)ID.時刻.本文webhook-timestamp と webhook-id

Standard Webhooks は、Webhook の送り方と署名の形を共通の決まりとしてまとめた仕様です。ID・時刻・署名の3つのヘッダを決めていて、webhook-id を「同じ通知を二度処理しないためのキー」に使うよう書いています。

Microsoft Graph の変更通知:署名ではなく clientState

Microsoft Graph の変更通知(Webhook)は、HMAC の署名ヘッダを付けるしくみではありません。代わりに、購読(サブスクリプション)を作るときに自分で決めた秘密の文字列 clientState を渡しておき、届いた通知に同じ値が入っているかで確かめます。

自社のアプリ購読を作る側Microsoft Graph変更通知のサービス受け口notificationUrl購読を作るとき① 購読を作るnotificationUrl・clientState② validationToken 付きの POST③ 10秒以内に 200 で同じ値を返す④ 201 購読ができた変更が起きたとき⑤ 変更通知(clientState 入り)⑥ clientState が登録した値と同じか⑦ 3秒以内に 2xx を返す中身付きの通知(リッチ通知)では、validationTokens の JWT も確かめる
図8 Microsoft Graph の変更通知で受け口と送り主を確かめる流れ

購読を作るリクエストの例(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通り道を暗号化送り元の IP許可リスト署名本物・改ざんなし時刻・ID使い回しを断る種類必要なイベントかすぐ 2xx重い処理は後で受け口の手前から奥へ、順に確かめる署名だけに頼らず、ほかの守りと重ねる。署名は「本物か」、ほかは「通り道」「使い回し」「量」を受け持つ
図9 受け口で確かめる順番
守り内容
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 を受けて、社内の自動化の仕組みを動かすGitHubX-Hub-Signature-256 を計算し直して比べる。X-GitHub-Delivery で二度処理しない。送り元の IP を GitHub の一覧で絞る
決済サービスの「支払い完了」で注文を確定するStripeStripe-Signature の v1 と時刻を確かめる。イベントの ID を記録して、同じ注文を二度確定しない
チャットのコマンドで社内ツールを呼ぶSlackX-Slack-Signature と X-Slack-Request-Timestamp を確かめ、5分以上ずれていれば断る
メールの受信やファイルの変更を業務システムに知らせるMicrosoft GraphclientState を確かめる。中身付きの通知なら 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 を返して後で処理

参考にした資料

この記事は、次の標準と公式ドキュメントで内容を確かめて書きました。仕様や対応状況は変わることがあるので、最新の情報はリンク先で確かめてください。

あとがき

署名というと難しく聞こえますが、やっていることは「同じ材料で同じ計算をして、答えが合うか」だけでした。実際に GitHub のテスト用の値で計算して、ドキュメントの値と一致し、「!」を1つ足しただけでまったく別の値になったのを見て、ようやく腑に落ちた気がします。むしろ実際につまずきやすいのは、本文を読み直してしまう、時計がずれている、といった周りの部分なのだと感じました。

コメント