配信の検証
`OpenEmail::verifyWebhookSignature`:一定時間での比較、リプレイ許容ウィンドウ、そしてパース済みイベントの返却。
リクエストハンドラーでの使い方
Webhook の URL は公開されています。インターネット上の誰でも、正しい形の JSON をそこへ POST できます。したがって、署名を検証せずにイベントの type を読むハンドラーは、誰でも書き込める API にほかなりません。
use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail; $payload = (string) file_get_contents('php://input'); try { $event = OpenEmail::verifyWebhookSignature( $payload, $_SERVER, (string) getenv('OPENEMAIL_WEBHOOK_SECRET'), toleranceSeconds: 300, );} catch (WebhookSignatureException) { http_response_code(400); return;} error_log($event['type'] . ' ' . $event['id']);http_response_code(204);「生の」ボディを文字列として渡してください。デコードして再びエンコードすると、キーの順序や空白が変わって署名が一致しなくなります。ペイロードの型が string なのはそのためです。json_decode() の結果やフレームワークがパースした入力のような配列は、検証される代わりに TypeError になります。ヘッダーには、getallheaders() が返すような、ヘッダー名の大文字小文字を問わない配列、ヘッダーが HTTP_X_OPENEMAIL_SIGNATURE として届く $_SERVER、Symfony や Laravel の HeaderBag のような任意の iterable、または PSR-7 のリクエストを渡せます。値が配列の場合は、その最初の要素が読まれます。
自前で書いたチェックではたいてい扱われない 2 つのことを、これは扱います。hash_equals() で MAC を定数時間で比較するため、時間を計測して正しい接頭辞を割り出すことはできません。また、前後どちらの方向にも toleranceSeconds:(指定しなければ 5 分)より古い配信を拒否するため、盗み取られたリクエストをいつまでもリプレイすることはできません。toleranceSeconds: 0 はリプレイのチェックを無効にします。どちらのバグも表に出ません。どちらかのバグを抱えたハンドラーでも、思いつくかぎりのテストはすべて通ってしまいます。
失敗した場合は必ず OpenEmail\Exception\WebhookSignatureException をスローします。X-OpenEmail-Signature ヘッダーがない、形式が t=<seconds>,v1=<hex> になっていない、タイムスタンプが許容範囲外である、署名が一致しない、署名されたボディが JSON オブジェクトではない、のいずれの場合も同様です。成功した場合は、ボディを id、type、createdAt、data を持つ camelCase のキーの配列にデコードして返すため、間違えやすい 2 度目の json_decode() は不要です。
空のシークレットは代わりに OpenEmail\Exception\InvalidArgumentException をスローします。それは不正な配信ではなく設定の誤りだからです。getenv() の前の (string) は、設定されていない変数をまさにその空のシークレットに変えます。WebhookSignatureException だけを捕捉して 400 を返してください。そうすればシークレットがない場合は捕捉されない例外でスクリプトが終わり、本番環境の PHP は 500 を返します。すべてのイベントが偽造として拒否されるのではなく、設定を直した後に配信が再試行されます。
このチェックは、どの PHP のビルドにもある hash_hmac() と hash_equals() で動くため、拡張は必要ありません。サンプルは exit を呼ぶのではなく return するため、同じ行を関数やフロントコントローラーの中でもそのまま使えます。
PSR-7 のアプリでは
Slim や Mezzio が渡すような PSR-7 のリクエストでは、リクエストそのものをヘッダーとして、そのボディを文字列として渡します。
use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail;use Psr\Http\Message\ResponseFactoryInterface;use Psr\Http\Message\ResponseInterface;use Psr\Http\Message\ServerRequestInterface; function receiveOpenEmail(ServerRequestInterface $request, ResponseFactoryInterface $responses): ResponseInterface{ try { $event = OpenEmail::verifyWebhookSignature( (string) $request->getBody(), $request, (string) getenv('OPENEMAIL_WEBHOOK_SECRET'), ); } catch (WebhookSignatureException) { return $responses->createResponse(400); } error_log($event['type'] . ' ' . $event['id']); return $responses->createResponse(204);}すぐに応答してください。5 秒以内に応答のない配信は失敗とみなされ、後で再送されるため、処理はキューに渡して 2xx を返してください。キューへの受け渡しを含む Laravel と Symfony のコントローラーは、フレームワークのページにあります。
OpenEmail\Constants\WebhookSignatureHeaders は、配信が持つ 3 つのヘッダーを定義しています。X-OpenEmail-Signature、イベントの種類を持つ X-OpenEmail-Event、そしてその id を持つ X-OpenEmail-Delivery で、この id はボディの id と同じです。この id はイベントのリトライでもリプレイでも変わらないため、処理済みのイベントをスキップするときに保存すべきなのはこの値です。
シークレットのローテーション中
webhooks->rotateSecret には移行期間がありません:配信は、呼び出しが戻った瞬間から新しいシークレットで署名されます。まずどちらのシークレットも受け付ける受信側をデプロイし、ローテーションして、新しいシークレットを受信側が読む場所に保存し、それから古いものを外してください。
use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail; function verifyDelivery(string $payload, mixed $headers): array{ $next = getenv('OPENEMAIL_WEBHOOK_SECRET_NEXT'); try { return OpenEmail::verifyWebhookSignature($payload, $headers, (string) getenv('OPENEMAIL_WEBHOOK_SECRET')); } catch (WebhookSignatureException $error) { if (!is_string($next) || $next === '') { throw $error; } return OpenEmail::verifyWebhookSignature($payload, $headers, $next); }}webhooks->test は署名付きの合成イベントを送るため、ローテーション後に呼び出し、古いシークレットを外す前に新しいシークレットで検証が通ることを確かめてください。