注文の状態がときどき巻き戻る — 受け取る側を作る
目標
他社のシステムが送り込んでくるWebhookを受け取る側を、自分で作ります。HMAC署名の検証と定数時間の比較、リプレイを防ぐ時刻ウィンドウ、配信IDとイベントIDの2つのキーによる重複の除去、バージョンの比較による順序の入れ替わりの吸収、そして早く200を返して後から処理する構造まで付け、1日分の配信を再び流し込んで、各分岐を集計します。
なぜ重要なのか
Webhookは、こちらが呼ぶのではなく受け取るものなので、主導権が反対側にあります。受信エンドポイントは開かれている必要があるので誰でもPOSTでき、こちらの応答が遅いと相手は再送し、順序は保証されません。 そのため、受け取る側は4つを自分で判断しなければなりません。誰が送ったのか(署名)、いつ送ったのか(時刻ウィンドウ)、すでに見たものか(重複)、現在のものより新しいか(バージョン)です。このうち1つでも欠けると、偽のイベントが元帳に入ったり、注文の状態が後戻りしたりします。 重複のキーが2つあることが、特に落とし穴です。配信IDは1回の送信を、イベントIDは起きた出来事1つを指します。防ぐべきものは出来事の二重適用なので、配信IDだけで除くと、半分しか防げません。 採点ツールは、作成した文章を信じません。作成した送信側サーバーと受信側を、採点ツールが選んだポートで直接起動し、採点ツールが作ったシークレットと配信で、検証器と元帳を再度実行して、答えを合わせます。
ステップ
- /root/wh/sender.pyを作成してポート8012で起動し、
/deliveriesを/root/wh/deliveries.jsonに、共有シークレットを/root/wh/secret.txtに保存してください。 - /root/wh/verify.pyを作成して、署名を定数時間で比較し、
{"ok": ..., "reason": ...}を出力するようにしてください。 - verify.pyに時刻ウィンドウを付けて、ウィンドウ外の配信を
staleで拒否するようにしてください。 - /root/wh/ledger.pyを作成して、配信IDとイベントIDの2つのキーで重複を除くようにしてください。
- ledger.pyが、現在保存されているバージョンより新しいときだけ適用し、遅れて届いた古いバージョンを
stale_versionとして記録するようにしてください。 - /root/wh/receiver.pyを作成して、検証だけしてキューに入れ、すぐに200を返すようにし、
--drainでキューを元帳に流し込むようにしてください。 - /root/wh/replay_day.pyで1日分の配信をすべて流し込んで、/root/wh/day.dbと/root/wh/result.jsonを作成してください。
- /root/wh/wh_report.mdに、4つの節で報告してください。
参考
- 送信側サーバーの実行契約:
python3 /root/wh/sender.py --port <포트> [--secret <비밀>](プレースホルダーは順にポート、シークレットです)。/healthは{"ok": true, "events": 30, "deliveries": 41}を、/deliveriesは{"now": <기준 시각>, "tolerance": 300, "deliveries": [...]}を出力します(プレースホルダーは基準時刻です)。配信1件は{"delivery_id": ..., "signature": ..., "body": <원본 문자열>}です(プレースホルダーは元の文字列です)。 - 署名の形式:
t=<epoch>,v1=<hex>。署名の材料は"<t>.<body>"で、HMAC-SHA256です。bodyは受け取った文字列そのまま使います。再度パースしてシリアライズすると、署名がずれます。 - この1日分は、出来事30件(注文10件 × バージョン3つ)に、再送4件、同じ出来事の新しい配信3件、古いリプレイ2件、偽の署名2件を加えた41件です。バージョンが届く順序は、注文ごとに異なります。
- 検証器の実行契約:
python3 verify.py --secret <파일> --delivery <파일> [--now <epoch>] [--tolerance <초>](プレースホルダーは順にファイル、ファイル、秒です)は、{"ok": true|false, "reason": "ok"|"bad_signature"|"stale"|"malformed"}を出力します。--nowを渡さなければ、現在時刻を使います。ステップ2でも4つの引数をすべて受け取るようにしておいてください(ウィンドウはステップ3で付けます)。署名の形でなければmalformed、署名が違えばbad_signature、署名は合っていて時刻がウィンドウ外ならstaleです。 - 元帳の実行契約:
python3 ledger.py --db <sqlite> --delivery <파일>(プレースホルダーはファイルです)は、{"stored": ..., "applied": ..., "reason": ...}を出力します。reasonは、new・duplicate_delivery・duplicate_event・stale_versionです。表には、order_state(order_id, version, status)を必ず含めます。 - 受信エンドポイントの実行契約:
python3 receiver.py --port <포트> --db <sqlite> --secret <파일> [--tolerance <초>](プレースホルダーは順にポート、ファイル、秒です)は、GET /healthとPOST /webhookを提供します。配信IDはX-Delivery-Id、署名はX-Signatureヘッダーで届きます。通過すれば200{"queued": true}、署名や時刻ウィンドウで引っかかれば400です。--drainを渡すと、サーバーを起動せずに、キューを元帳に流し込んで集計を出力します。キューの表の名前はinboxです。 - 再現器の実行契約:
python3 replay_day.py --deliveries <파일> --db <sqlite> --out <파일>(プレースホルダーはファイルです)。集計の欄は、deliveries・accepted・rejected_signature・rejected_stale・duplicate_delivery・duplicate_event・stored・applied・stale_version・ordersの10個です。acceptedは署名と時刻ウィンドウを通過した配信、storedは重複ではなく元帳に入った出来事の数です。 - よくあるミス: 本文をパースして再度シリアライズして署名を計算すること、
==で署名を比較すること、配信IDだけで重複を除くこと、受け取る場所で元帳まで処理して200が遅れること。 - サーバーはバックグラウンドで起動し、
/healthが200になるまで待ってから、次に進みます。採点ツールは、起動しておいたプロセスを見ず、スクリプトを直接起動し直します。
1日分の配信を手元に揃える
/root/wh/sender.pyを作成してポート8012で起動し、/deliveriesの応答を/root/wh/deliveries.jsonに、共有シークレットを/root/wh/secret.txtに保存してください。配信は41件、出来事は30件です。
flaskで、/healthと/deliveriesの2つのパスを作ります。配信の一覧は、出来事30件に、再送、同じ出来事の新しい配信、古いリプレイ、偽の署名を加えて作ります。基準時刻を応答に一緒に載せておくと、あとでテストが時計に左右されません。
誰が送ったのかを署名で見分ける
/root/wh/verify.pyを作成して、配信1件の署名を検証し、{"ok": ..., "reason": ...}を出力するようにしてください。署名の形でなければmalformed、署名が違えばbad_signatureです。比較は必ず定数時間で行ってください。
署名の文字列t=...,v1=...を分解してtとv1を得て、"<t>.<body>"を材料にHMAC-SHA256を計算します。bodyは、受け取った文字列そのまま使う必要があります。Pythonのhmacモジュールには、長さに比例する時間で比較する関数があります。==は、最初に異なるバイトで終わるので、時間がシークレットを漏らします。
古いものを再び送り込む手
verify.pyに時刻ウィンドウを付けてください。署名が合っていても、--nowと配信のtの差が--tolerance(既定は300秒)を超えたら、staleで拒否する必要があります。前後どちらも、ウィンドウ外です。
署名だけでは、以前にやり取りされた有効なリクエストをそのまま再投入することを防げません。そのため、署名の材料にタイムスタンプが入っていて、受け取る側は現在時刻との差を見ます。未来側も防ぐ必要があります。相手の時計が進んでいることと、誰かがtを未来に書いておいたことは、区別できないからです。順序は、署名が先です。
キーは2つある
/root/wh/ledger.pyを作成して、検証を通過した配信を元帳に入れ、同じ配信IDがまた来たらduplicate_delivery、配信IDは新しいのにイベントIDがすでにあればduplicate_eventとして除くようにしてください。表は、sqliteのファイルに残す必要があります。
照会してなければ入れるという方式は、同じ瞬間に入ってきた2件を、どちらも通過させます。2つのキーをそれぞれ主キーにした表に、直ちにINSERTして、制約違反の例外を「すでにある」という信号として使ってください。配信IDの検査が先です。順序を変えると、再送がduplicate_eventとして記録されます。
注文の状態が後戻りしないように
ledger.pyがorder_state(order_id, version, status)を持ち、現在保存されているバージョンより新しいときだけ適用するようにしてください。遅れて届いた古いバージョンは、storedはtrueですがappliedはfalseで、reasonはstale_versionです。
届いた順序を信じてはいけません。イベントが持っているversionを、現在保存されている値と比べてください。初めて見る注文ならそのまま入れ、すでにあれば、より大きいときだけ更新します。同じバージョンがまた来る場合も、更新の対象ではありません。
早く200を返して後から処理する
/root/wh/receiver.pyを作成して、POST /webhookが署名と時刻ウィンドウだけを見てinboxキューに入れ、すぐに200 {"queued": true}を返すようにしてください。受け取る場所で元帳を触ってはいけません。--drainは、サーバーを起動せずに、キューを元帳に流し込みます。
配信IDはX-Delivery-Id、署名はX-Signatureヘッダーで届きます。本文はパースせず、元の文字列のまま検証に渡してください。検証で引っかかれば400です。キューに入れることと、元帳に適用することを分けると、処理時間が相手のタイムアウトに触れなくなります。
1日分を1件も漏らさず再び
/root/wh/replay_day.pyで、/root/wh/deliveries.jsonの配信をすべて流し込んで、/root/wh/day.dbと/root/wh/result.jsonを作成してください。集計の欄は、deliveries・accepted・rejected_signature・rejected_stale・duplicate_delivery・duplicate_event・stored・applied・stale_version・ordersの10個です。
配信一覧のファイルに入っているnowとtoleranceを、そのまま基準時刻として使ってください。そうすれば、テストが時計に左右されません。検証で落ちたものは元帳まで行かず、重複で引っかかったものは、storedに数えません。1件も漏らさず、すべて流し込んでください。
受け取る側の点検報告書
/root/wh/wh_report.mdに、## 무엇이 들어왔나、## 중복을 어떻게 걸렀나、## 순서를 어떻게 다뤘나、## 남은 위험과 운영 규칙(韓国語の見出しで、順に何が入ってきたか、重複をどう除いたか、順序をどう扱ったか、残るリスクと運用ルール、を意味します)の4つの節で書いてください。result.jsonの数字が、本文に入っている必要があります。
読む人は、こちらのチーム長でもあり、パートナー企業の担当者でもあります。各分岐が何件だったかを数字で書き、配信IDだけで除いたなら何を見逃したかも、一緒に書いてください。残るリスクには、シークレットの交換と時刻ウィンドウの幅が必ず入ります。