SendGridのEvent WebHookを検証する

メール配信プラットフォームであるSendGridは、メールの到達状況などを、呼び出し元のアプリケーションにWebHookで伝えることができる。

sendgrid.kke.co.jp

アプリケーションがSendGridからリクエストを受け取るには、当然インターネットからアクセスできるようにする必要があるが、何の考えもなしに開放してはセキュリティ的に不安だ。しかしご安心を、SendGridにはWebHookにデジタル署名をつける機能がある。本稿ではその使い方を紹介する。

まず、Mail Settingsから、Signed Event Webhook Requestsという項目に入る。画面のボタンをクリックすると、Verification Keyが生成され、電子署名が有効になる。これは我々(HERP)のシステムで実際に運用している鍵である。

この設定をすると、以後のWebHookには、X-Twilio-Email-Event-Webhook-SignatureX-Twilio-Email-Event-Webhook-Timestampの二つのHTTPヘッダーが付与される。アプリケーション側はこれらを使って検証すればよい。

まず、電子署名に使うアルゴリズムハッシュ関数メッセージ署名公開鍵を整理する。

関係を整理したところで、公式のドキュメントを参考に実装を進めていく。docs.sendgrid.com

まずは必要なモジュールをインポートしていく。ずらずらと並べられた文は、もはやHaskellの風物詩である。

import "asn1-encoding" Data.ASN1.BinaryEncoding (BER(..))
import "asn1-encoding" Data.ASN1.Encoding (decodeASN1)
import "asn1-types" Data.ASN1.Types (ASN1(..), fromASN1)
import "base" Data.Proxy
import "base" Data.Bifunctor (bimap, first)
import "base64" Data.ByteString.Base64 (decodeBase64)
import "bytestring" Data.ByteString qualified as B
import "bytestring" Data.ByteString.Lazy qualified as BL
import "cryptonite" Crypto.ECC (Curve_P256R1)
import "cryptonite" Crypto.Error (CryptoFailable(..))
import "cryptonite" Crypto.Hash.Algorithms (SHA256(..))
import "cryptonite" Crypto.PubKey.ECC.Types (CurveName(..))
import "cryptonite" Crypto.PubKey.ECDSA qualified as ECDSA
import "x509" Data.X509 (PubKey(..), PubKeyEC(..), SerializedPoint(..))

公開鍵がBase64とASN.1でエンコードされていることはすぐにわかるが、その中身のフォーマットがドキュメントに書かれていなかったため、少々迷いが生じた。実験してみたところx509パッケージのPubKeyとしてデコードできることがわかった。

type PublicKey = ECDSA.PublicKey Curve_P256R1

curve :: Proxy Curve_P256R1
curve = Proxy

parsePublicKey :: B.ByteString -> Either String PublicKey
parsePublicKey b64 = do
  -- base64としてデコード
  raw <- first show $ decodeBase64 b64
  -- ASN.1としてデコード
  asn1 <- first show $ decodeASN1 BER $ BL.fromStrict raw
  -- 楕円曲線のパラメータのプリセットと、公開鍵を取り出す
  pubkey <- bimap show fst $ fromASN1 asn1
  -- secp256r1と仮定する
  SerializedPoint point <- case pubkey of
    PubKeyEC (PubKeyEC_Named SEC_p256r1 bs) -> Right bs
    _ -> Left $ "Unsupported public key: " <> show pubkey
  -- 座標をデコード
  case ECDSA.decodePublic curve point of
    CryptoPassed a -> Right a
    CryptoFailed err -> Left $ show err

ここまで来れば、あとは関数を呼び出すだけなので簡単だ。暗号や電子署名は、実装を間違えればただのランダムな文字列になってしまうため、ライブラリのありがたみを感じやすい。

verify :: PublicKey -> B.ByteString -> B.ByteString -> B.ByteString -> Either String ()
verify pubKey timestamp payload signatureBase64 = do
  -- 署名をデコードする
  signature <- either (const $ Left "Malformed signature") pure $ decodeBase64 signatureBase64

  -- ECDSAの署名(r, s)を取り出す
  point <- case decodeASN1 BER $ BL.fromStrict signature of
    Right [_, IntVal r, IntVal s, _] -> pure (r, s)
    Right asn1 -> Left $ "failed to decode the signature: " <> show asn1
    Left err -> Left $ show err
  case ECDSA.signatureFromIntegers curve point of
    CryptoPassed sig
      | ECDSA.verify -- 検証する
        curve
        SHA256
        pubKey
        sig
        $ timestamp <> payload
        -> pure ()
      | otherwise -> Left "Verification failed"
    CryptoFailed _ -> Left "ECDSA.signatureFromIntegers failed"

WebHookの設定画面で「Test Your Integration」をクリックすると送られてくるデータを使って、実際に検証してみる。

_test_verify :: Either String ()
_test_verify = do
  pub <- parsePublicKey "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAERYcga9cTuvv0EbOFM0PO/KJjCgqYwtGar22uUyPQPwUbm+OtKXGNGIaHBvkgXBCbTxG4XQ4ddfDPgfMAcguUtg=="
  verify pub
    "1655455728"
    "[{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"processed\",\"category\":[\"cat facts\"],\"sg_event_id\":\"cPeOfuc93o3vAEftXKt_zA==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\"},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"deferred\",\"category\":[\"cat facts\"],\"sg_event_id\":\"zFYYFn__N8lr3be4TqKnVw==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\",\"response\":\"400 try again later\",\"attempt\":\"5\"},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"delivered\",\"category\":[\"cat facts\"],\"sg_event_id\":\"rj8WvpmTWgE0z2MSd41KKg==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\",\"response\":\"250 OK\"},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"open\",\"category\":[\"cat facts\"],\"sg_event_id\":\"uz1AaVpvYihASovU-M-Jrg==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\",\"useragent\":\"Mozilla/4.0 (compatible; MSIE 6.1; Windows XP; .NET CLR 1.1.4322; .NET CLR 2.0.50727)\",\"ip\":\"255.255.255.255\"},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"click\",\"category\":[\"cat facts\"],\"sg_event_id\":\"O2bZwfxc-xm-9zmeVZX8HA==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\",\"useragent\":\"Mozilla/4.0 (compatible; MSIE 6.1; Windows XP; .NET CLR 1.1.4322; .NET CLR 2.0.50727)\",\"ip\":\"255.255.255.255\",\"url\":\"http://www.sendgrid.com/\"},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"bounce\",\"category\":[\"cat facts\"],\"sg_event_id\":\"vMLN27M1-TVv5XADvpW3Nw==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\",\"reason\":\"500 unknown recipient\",\"status\":\"5.0.0\"},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"dropped\",\"category\":[\"cat facts\"],\"sg_event_id\":\"u1u9xdYlTkoDkUZFZ_j_tg==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\",\"reason\":\"Bounced Address\",\"status\":\"5.0.0\"},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"spamreport\",\"category\":[\"cat facts\"],\"sg_event_id\":\"CjkStOi15hPQfWO58rH9dw==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\"},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"unsubscribe\",\"category\":[\"cat facts\"],\"sg_event_id\":\"QrCQiwv2pfkejKE5Zi1D0g==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\"},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"group_unsubscribe\",\"category\":[\"cat facts\"],\"sg_event_id\":\"l67dvY6MIupGpuk1r9vIXw==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\",\"useragent\":\"Mozilla/4.0 (compatible; MSIE 6.1; Windows XP; .NET CLR 1.1.4322; .NET CLR 2.0.50727)\",\"ip\":\"255.255.255.255\",\"url\":\"http://www.sendgrid.com/\",\"asm_group_id\":10},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"group_resubscribe\",\"category\":[\"cat facts\"],\"sg_event_id\":\"fo7PLpVCoCA9WDHovGQyyw==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\",\"useragent\":\"Mozilla/4.0 (compatible; MSIE 6.1; Windows XP; .NET CLR 1.1.4322; .NET CLR 2.0.50727)\",\"ip\":\"255.255.255.255\",\"url\":\"http://www.sendgrid.com/\",\"asm_group_id\":10}]\r\n"
    "MEUCIQCBYJiC1zzZeM61EbekWSGMFgpRSzaQSA4zwV3vlMgf/wIgSrMZIIYTnx4dkqDK92re4WYhcM3xEKbLIKfmcu7Et0o="

_test_verify_fail :: Either String ()
_test_verify_fail = do
  pub <- parsePublicKey "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAERYcga9cTuvv0EbOFM0PO/KJjCgqYwtGar22uUyPQPwUbm+OtKXGNGIaHBvkgXBCbTxG4XQ4ddfDPgfMAcguUtg=="
  verify pub
    "1655455729"
    "[{\"email\":\"bob@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"processed\",\"category\":[\"cat facts\"],\"sg_event_id\":\"cPeOfuc93o3vAEftXKt_zA==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\"},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"deferred\",\"category\":[\"cat facts\"],\"sg_event_id\":\"zFYYFn__N8lr3be4TqKnVw==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\",\"response\":\"400 try again later\",\"attempt\":\"5\"},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"delivered\",\"category\":[\"cat facts\"],\"sg_event_id\":\"rj8WvpmTWgE0z2MSd41KKg==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\",\"response\":\"250 OK\"},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"open\",\"category\":[\"cat facts\"],\"sg_event_id\":\"uz1AaVpvYihASovU-M-Jrg==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\",\"useragent\":\"Mozilla/4.0 (compatible; MSIE 6.1; Windows XP; .NET CLR 1.1.4322; .NET CLR 2.0.50727)\",\"ip\":\"255.255.255.255\"},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"click\",\"category\":[\"cat facts\"],\"sg_event_id\":\"O2bZwfxc-xm-9zmeVZX8HA==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\",\"useragent\":\"Mozilla/4.0 (compatible; MSIE 6.1; Windows XP; .NET CLR 1.1.4322; .NET CLR 2.0.50727)\",\"ip\":\"255.255.255.255\",\"url\":\"http://www.sendgrid.com/\"},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"bounce\",\"category\":[\"cat facts\"],\"sg_event_id\":\"vMLN27M1-TVv5XADvpW3Nw==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\",\"reason\":\"500 unknown recipient\",\"status\":\"5.0.0\"},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"dropped\",\"category\":[\"cat facts\"],\"sg_event_id\":\"u1u9xdYlTkoDkUZFZ_j_tg==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\",\"reason\":\"Bounced Address\",\"status\":\"5.0.0\"},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"spamreport\",\"category\":[\"cat facts\"],\"sg_event_id\":\"CjkStOi15hPQfWO58rH9dw==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\"},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"unsubscribe\",\"category\":[\"cat facts\"],\"sg_event_id\":\"QrCQiwv2pfkejKE5Zi1D0g==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\"},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"group_unsubscribe\",\"category\":[\"cat facts\"],\"sg_event_id\":\"l67dvY6MIupGpuk1r9vIXw==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\",\"useragent\":\"Mozilla/4.0 (compatible; MSIE 6.1; Windows XP; .NET CLR 1.1.4322; .NET CLR 2.0.50727)\",\"ip\":\"255.255.255.255\",\"url\":\"http://www.sendgrid.com/\",\"asm_group_id\":10},\r\n{\"email\":\"example@test.com\",\"timestamp\":1655455223,\"smtp-id\":\"\\u003c14c5d75ce93.dfd.64b469@ismtpd-555\\u003e\",\"event\":\"group_resubscribe\",\"category\":[\"cat facts\"],\"sg_event_id\":\"fo7PLpVCoCA9WDHovGQyyw==\",\"sg_message_id\":\"14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0\",\"useragent\":\"Mozilla/4.0 (compatible; MSIE 6.1; Windows XP; .NET CLR 1.1.4322; .NET CLR 2.0.50727)\",\"ip\":\"255.255.255.255\",\"url\":\"http://www.sendgrid.com/\",\"asm_group_id\":10}]\r\n"
    "MEUCIQCBYJiC1zzZeM61EbekWSGMFgpRSzaQSA4zwV3vlMgf/wIgSrMZIIYTnx4dkqDK92re4WYhcM3xEKbLIKfmcu7Et0o="
ghci> _test_verify 
Right ()
ghci> _test_verify_fail 
Left "Verification failed"

SendGridから送られてきたデータは通り、改ざんしたデータは弾けることを確認できた。

まとめ

インターネットサバンナの住人として、不正な情報に対する警戒は怠ってはいけない。SendGridは「リクエストの内容とタイムスタンプをECDSAで署名する」というシンプルな仕組みがあるおかげで、比較的少ないコードで検証機能を実装できた。

PR枠

WebHookの門はECDSAによって閉ざされているが、採用においてHERPの門は開かれている。HERPは頭が良くて物事を成し遂げられる人を募集しています。

github.com