署名は正しいがWebhookが二度届いた
目標
原文の署名・時間の窓・イベントの重複排除を、1つのWebhook処理の経路につなげます。
なぜ重要なのか
決済プロバイダーが応答を受け取れず、同じイベントを再送しました。サーバーは、署名が合っているので正常なリクエストだと考え、売上を再び加算しました。署名は、誰が送ったかに関する証拠であって、初めて処理するリクエストだという証拠ではありません。再送を許可する時間の窓と、保存されたイベントidを、それぞれ確認する必要があります。
ステップ
/root/work/idem-webhook-lab/service.pyで、signed_bytes(timestamp, body)は、boolを除くintのtimestampと、bytesのbodyだけを受け取り、str(timestamp).encode()+b'.'+bodyを返します。不正な型はValueErrorです。
最初に1回だけ準備してください。既存のファイルは上書きしません。
mkdir -p /root/work/idem-webhook-lab
test -e /root/work/idem-webhook-lab/service.py || cp /opt/fixtures/ten_labs/idem-webhook-lab/service.py /root/work/idem-webhook-lab/service.py
cd /root/work/idem-webhook-lab
-
/root/work/idem-webhook-lab/service.pyで、signature(secret, timestamp, body)は、bytesのsecretで、signed_bytesにHMAC-SHA256を適用したhex文字列です。 -
/root/work/idem-webhook-lab/service.pyで、verify(secret, timestamp, body, supplied)は、suppliedがstrで、計算した署名とcompare_digestで同じときにTrue、そうでなければFalseです。 -
/root/work/idem-webhook-lab/service.pyで、fresh(timestamp, now, tolerance=300)は、timestampとnowがboolを除くintで、abs(now-timestamp)<=toleranceならTrueです。それ以外はFalseです。toleranceは、呼び出し側が渡す正のintです。 -
/root/work/idem-webhook-lab/service.pyで、init_db(path)は、inbox(id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL)とtotal(id INTEGER PRIMARY KEY, amount INTEGER NOT NULL)を作り、totalにid=1,amount=0を重複なく入れます。 -
/root/work/idem-webhook-lab/service.pyで、apply(path, event, fault=lambda:None)は、eventのidが空でないstr、amountがboolを除く正のintであるかを検証します。idとamountを正規JSONにして、フィンガープリントを計算します。同じid/フィンガープリントはFalse、別のフィンガープリントはValueError、新しいイベントはinboxへの挿入→fault()→合計の増加のあとにTrueです。 -
/root/work/idem-webhook-lab/service.pyで、total(path)は、totalのid=1のamountの整数を返します。 -
/root/work/idem-webhook-lab/service.pyで、create_app(path, secret, clock)は、POST /webhookで、原文のbody、X-Timestamp、X-Signatureを読みます。時刻の形式・時間の窓・署名の失敗は401、JSONのパースの失敗は400、applyのValueErrorは409です。正常は、200 {accepted:True, duplicate:初回処理ならFalse}です。
参考
- インターネットやパッケージのインストールなしで、既存のlab-dev環境で行います。
- 各ステップは、45秒の採点予算の中で実行されます。実際のsleepやネットワーク呼び出しを追加しないでください。
- 採点は、提出されたモジュールを新しく読み込み、独立した入力と一時DBで検査します。期待値を定数として返す代わりに、契約を実装してください。
- FastAPI公式ドキュメント・pytest公式ドキュメント・Python sqlite3
- 限界: この署名の形式は教育用のプロトコルであり、実際の決済プロバイダーの規格の代わりにはなりません。学習用のsecretだけを使います。サーバーの時計の信頼性、キーのローテーション、許容する本文のサイズと永久保管の期間は、別の運用上の課題です。重複排除は、同じイベントidと同じ本文に適用され、別の本文で同じidを再利用すれば衝突です。
署名する原文を保持する
/root/work/idem-webhook-lab/service.pyで、signed_bytes(timestamp, body)は、boolを除くintのtimestampと、bytesのbodyだけを受け取り、str(timestamp).encode()+b'.'+bodyを返します。不正な型はValueErrorです。
最初に1回だけ準備してください。既存のファイルは上書きしません。
mkdir -p /root/work/idem-webhook-lab
test -e /root/work/idem-webhook-lab/service.py || cp /opt/fixtures/ten_labs/idem-webhook-lab/service.py /root/work/idem-webhook-lab/service.py
cd /root/work/idem-webhook-lab
原文のbodyをJSONとしてパースしてから作り直すと、署名の対象が変わります。
保存したあと、bash /opt/lab/checks/idem-webhook-lab/01-contract.shで確認してください。
HMACを計算する
/root/work/idem-webhook-lab/service.pyで、signature(secret, timestamp, body)は、bytesのsecretで、signed_bytesにHMAC-SHA256を適用したhex文字列です。
通常のハッシュに秘密を付け足す方式の代わりに、標準のHMACを使います。
保存したあと、bash /opt/lab/checks/idem-webhook-lab/02-contract.shで確認してください。
不正な署名を拒否する
/root/work/idem-webhook-lab/service.pyで、verify(secret, timestamp, body, supplied)は、suppliedがstrで、計算した署名とcompare_digestで同じときにTrue、そうでなければFalseです。
署名があるかどうかと、署名が合っているかどうかは、別の検査です。
保存したあと、bash /opt/lab/checks/idem-webhook-lab/03-contract.shで確認してください。
過去と未来のリプレイを制限する
/root/work/idem-webhook-lab/service.pyで、fresh(timestamp, now, tolerance=300)は、timestampとnowがboolを除くintで、abs(now-timestamp)<=toleranceならTrueです。それ以外はFalseです。toleranceは、呼び出し側が渡す正のintです。
未来のタイムスタンプを無条件に許可すると、攻撃者が有効期間を延ばせます。
保存したあと、bash /opt/lab/checks/idem-webhook-lab/04-contract.shで確認してください。
イベントと効果を一緒に保存する準備をする
/root/work/idem-webhook-lab/service.pyで、init_db(path)は、inbox(id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL)とtotal(id INTEGER PRIMARY KEY, amount INTEGER NOT NULL)を作り、totalにid=1,amount=0を重複なく入れます。
重複イベントの記録と業務上の合計が、同じDBトランザクションにある必要があります。
保存したあと、bash /opt/lab/checks/idem-webhook-lab/05-contract.shで確認してください。
記録と売上の間の失敗をロールバックする
/root/work/idem-webhook-lab/service.pyで、apply(path, event, fault=lambda:None)は、eventのidが空でないstr、amountがboolを除く正のintであるかを検証します。idとamountを正規JSONにして、フィンガープリントを計算します。同じid/フィンガープリントはFalse、別のフィンガープリントはValueError、新しいイベントはinboxへの挿入→fault()→合計の増加のあとにTrueです。
faultで例外が起きたら、inboxも合計も残っていてはいけません。
保存したあと、bash /opt/lab/checks/idem-webhook-lab/06-contract.shで確認してください。
別の接続で合計を読む
/root/work/idem-webhook-lab/service.pyで、total(path)は、totalのid=1のamountの整数を返します。
コールバックの実行回数の代わりに、DBに実際に残った業務上の効果を確認します。
保存したあと、bash /opt/lab/checks/idem-webhook-lab/07-contract.shで確認してください。
実際のWebhookリクエストを処理する
/root/work/idem-webhook-lab/service.pyで、create_app(path, secret, clock)は、POST /webhookで、原文のbody、X-Timestamp、X-Signatureを読みます。時刻の形式・時間の窓・署名の失敗は401、JSONのパースの失敗は400、applyのValueErrorは409です。正常は、200 {accepted:True, duplicate:初回処理ならFalse}です。
署名を先に検証してからJSONを読み、重複も、正常な確認応答として返します。
保存したあと、bash /opt/lab/checks/idem-webhook-lab/08-contract.shで確認してください。