署名は正しいがWebhookが二度届いた:設計原理
一言でいうと
原文の署名・時間の窓・イベントの重複排除を、1つのWebhook処理の経路につなげます。
なぜ必要なのか
決済プロバイダーが応答を受け取れず、同じイベントを再送しました。サーバーは、署名が合っているので正常なリクエストだと考え、売上を再び加算しました。署名は、誰が送ったかに関する証拠であって、初めて処理するリクエストだという証拠ではありません。再送を許可する時間の窓と、保存されたイベントidを、それぞれ確認する必要があります。
どう動くのか
署名の対象は、タイムスタンプの文字列と、原文のbodyのバイト列です。JSONを再シリアライズしたあとで署名を比較すると、空白やキーの順序が変わって、正常なリクエストを拒否してしまいます。HMAC-SHA256とcompare_digestを使い、時間の差は両方向で検査します。有効なイベントは、idと本文のフィンガープリントをinboxに記録し、売上の合計と同じトランザクションでコミットします。途中の例外は、2つの書き込みをどちらもロールバックします。
원문+시각 → 서명·시간 검사 → inbox id+본문지문 → 합계 반영 → 동일 트랜잭션
契約を読んで失敗を予測するワークシート
以下は、実装を丸ごと暗記するための解答ではなく、ステップごとのコードレビューです。各変更の断片は、意図的に契約を壊しています。変更後も、正常なケースは通ることがある点に注意してください。実行する前に、どの入力・例外・状態を観測すれば違いが現れるかを予想し、実装したあとで、その予想と結果を比べます。
1. 署名する原文を保持する
signed_bytes(timestamp, body)は、boolを除くintのtimestampと、bytesのbodyだけを受け取り、str(timestamp).encode()+b'.'+bodyを返します。不正な型はValueErrorです。
判断の根拠: 原文のbodyをJSONとしてパースしてから作り直すと、署名の対象が変わります。
レビューする誤った変更の断片:
+ b":" + body
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
2. HMACを計算する
signature(secret, timestamp, body)は、bytesのsecretで、signed_bytesにHMAC-SHA256を適用したhex文字列です。
判断の根拠: 通常のハッシュに秘密を付け足す方式の代わりに、標準のHMACを使います。
レビューする誤った変更の断片:
body, hashlib.sha256
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
3. 不正な署名を拒否する
verify(secret, timestamp, body, supplied)は、suppliedがstrで、計算した署名とcompare_digestで同じときにTrue、そうでなければFalseです。
判断の根拠: 署名があるかどうかと、署名が合っているかどうかは、別の検査です。
レビューする誤った変更の断片:
signature(secret,timestamp,body), signature(secret,timestamp,body)
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
4. 過去と未来のリプレイを制限する
fresh(timestamp, now, tolerance=300)は、timestampとnowがboolを除くintで、abs(now-timestamp)<=toleranceならTrueです。それ以外はFalseです。toleranceは、呼び出し側が渡す正のintです。
判断の根拠: 未来のタイムスタンプを無条件に許可すると、攻撃者が有効期間を延ばせます。
レビューする誤った変更の断片:
(now-timestamp)
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
5. イベントと効果を一緒に保存する準備をする
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トランザクションにある必要があります。
レビューする誤った変更の断片:
VALUES (1,1)
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
6. 記録と売上の間の失敗をロールバックする
apply(path, event, fault=lambda:None)は、eventのidが空でないstr、amountがboolを除く正のintであるかを検証します。idとamountを正規JSONにして、フィンガープリントを計算します。同じid/フィンガープリントはFalse、別のフィンガープリントはValueError、新しいイベントはinboxへの挿入→fault()→合計の増加のあとにTrueです。
判断の根拠: faultで例外が起きたら、inboxも合計も残っていてはいけません。
レビューする誤った変更の断片:
db.commit()
fault()
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
7. 別の接続で合計を読む
total(path)は、totalのid=1のamountの整数を返します。
判断の根拠: コールバックの実行回数の代わりに、DBに実際に残った業務上の効果を確認します。
レビューする誤った変更の断片:
SELECT id FROM total WHERE id=1
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
8. 実際のWebhookリクエストを処理する
create_app(path, secret, clock)は、POST /webhookで、原文のbody、X-Timestamp、X-Signatureを読みます。時刻の形式・時間の窓・署名の失敗は401、JSONのパースの失敗は400、applyのValueErrorは409です。正常は、200 {accepted:True, duplicate:初回処理ならFalse}です。
判断の根拠: 署名を先に検証してからJSONを読み、重複も、正常な確認応答として返します。
レビューする誤った変更の断片:
"duplicate":False
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
現場での姿
この署名の形式は教育用のプロトコルであり、実際の決済プロバイダーの規格の代わりにはなりません。学習用のsecretだけを使います。サーバーの時計の信頼性、キーのローテーション、許容する本文のサイズと永久保管の期間は、別の運用上の課題です。重複排除は、同じイベントidと同じ本文に適用され、別の本文で同じidを再利用すれば衝突です。
次のラボですること
8つのステップが、1つの実行可能な成果物につながります。署名する原文を保持する → HMACを計算する → 不正な署名を拒否する → 過去と未来のリプレイを制限する → イベントと効果を一緒に保存する準備をする → 記録と売上の間の失敗をロールバックする → 別の接続で合計を読む → 実際のWebhookリクエストを処理する。
各ステップは、関数やファイルが存在するという事実ではなく、実際の戻り値・例外・状態の変化を検査します。正解を見たあとは、わざと境界の比較や後始末のコードを変えて、どの試験が失敗するかを確認してください。前の試験が次のステップでも維持される理由を説明し、このラボが保証しない運用上の条件を1つ書いてみてください。