Skip to content

Commit cb58667

Browse files
authored
feat: verify signature from event webhook (#901)
When enabling the "Signed Event Webhook Requests" feature in Mail Settings, Twilio SendGrid will generate a private and public key pair using the Elliptic Curve Digital Signature Algorithm (ECDSA). Once that is successfully enabled, all new event posts will have two new headers: X-Twilio-Email-Event-Webhook-Signature and X-Twilio-Email-Event-Webhook-Timestamp, which can be used to validate your events. This SDK update will make it easier to verify signatures from signed event webhook requests by using the VerifySignature method. Pass in the public key, event payload, signature, and timestamp to validate. Note: You will need to convert your public key string to an elliptic public key object in order to use the VerifySignature method.
1 parent 75cc2d1 commit cb58667

File tree

8 files changed

+120
-2
lines changed

8 files changed

+120
-2
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.PHONY: venv install test-install test test-integ test-docker clean nopyc
22

3-
venv:
3+
venv: clean
44
@python --version || (echo "Python is not installed, please install Python 2 or Python 3"; exit 1);
55
virtualenv --python=python venv
66

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from sendgrid.helpers.eventwebhook import EventWebhook, EventWebhookHeader
2+
3+
def is_valid_signature(request):
4+
public_key = 'base64-encoded public key'
5+
6+
event_webhook = EventWebhook()
7+
ec_public_key = event_webhook.convert_public_key_to_ecdsa(public_key)
8+
9+
return event_webhook.verify_signature(
10+
request.text,
11+
request.headers[EventWebhookHeader.SIGNATURE],
12+
request.headers[EventWebhookHeader.TIMESTAMP],
13+
ec_public_key
14+
)

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ PyYAML>=4.2b1
33
python-http-client>=3.2.1
44
six==1.11.0
55
pytest==3.8.2
6+
starkbank-ecdsa>=1.0.0

sendgrid/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from .helpers.endpoints import * # noqa
1919
from .helpers.mail import * # noqa
2020
from .helpers.stats import * # noqa
21+
from .helpers.eventwebhook import * # noqa
2122
from .sendgrid import SendGridAPIClient # noqa
2223
from .twilio_email import TwilioEmailAPIClient # noqa
2324
from .version import __version__
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from ellipticcurve.ecdsa import Ecdsa
2+
from ellipticcurve.publicKey import PublicKey
3+
from ellipticcurve.signature import Signature
4+
5+
from .eventwebhook_header import EventWebhookHeader
6+
7+
class EventWebhook:
8+
"""
9+
This class allows you to use the Event Webhook feature. Read the docs for
10+
more details: https://sendgrid.com/docs/for-developers/tracking-events/event
11+
"""
12+
13+
def __init__(self, public_key=None):
14+
"""
15+
Construct the Event Webhook verifier object
16+
:param public_key: verification key under Mail Settings
17+
:type public_key: string
18+
"""
19+
self.public_key = self.convert_public_key_to_ecdsa(public_key) if public_key else public_key
20+
21+
def convert_public_key_to_ecdsa(self, public_key):
22+
"""
23+
Convert the public key string to a ECPublicKey.
24+
25+
:param public_key: verification key under Mail Settings
26+
:type public_key string
27+
:return: public key using the ECDSA algorithm
28+
:rtype PublicKey
29+
"""
30+
return PublicKey.fromPem(public_key)
31+
32+
def verify_signature(self, payload, signature, timestamp, public_key=None):
33+
"""
34+
Verify signed event webhook requests.
35+
36+
:param payload: event payload in the request body
37+
:type payload: string
38+
:param signature: value obtained from the 'X-Twilio-Email-Event-Webhook-Signature' header
39+
:type signature: string
40+
:param timestamp: value obtained from the 'X-Twilio-Email-Event-Webhook-Timestamp' header
41+
:type timestamp: string
42+
:param public_key: elliptic curve public key
43+
:type public_key: PublicKey
44+
:return: true or false if signature is valid
45+
"""
46+
timestamped_payload = timestamp + payload
47+
decoded_signature = Signature.fromBase64(signature)
48+
49+
key = public_key or self.public_key
50+
return Ecdsa.verify(timestamped_payload, decoded_signature, key)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class EventWebhookHeader:
2+
"""
3+
This class lists headers that get posted to the webhook. Read the docs for
4+
more details: https://sendgrid.com/docs/for-developers/tracking-events/event
5+
"""
6+
SIGNATURE = 'X-Twilio-Email-Event-Webhook-Signature'
7+
TIMESTAMP = 'X-Twilio-Email-Event-Webhook-Timestamp'
8+
9+
def __init__(self):
10+
pass

sendgrid/helpers/inbound/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def __init__(self, **opts):
1616
'path', os.path.abspath(os.path.dirname(__file__))
1717
)
1818
with open('{0}/config.yml'.format(self.path)) as stream:
19-
config = yaml.load(stream)
19+
config = yaml.load(stream, Loader=yaml.FullLoader)
2020
self._debug_mode = config['debug_mode']
2121
self._endpoint = config['endpoint']
2222
self._host = config['host']

test/test_eventwebhook.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import json
2+
import unittest
3+
4+
from sendgrid import EventWebhook
5+
6+
7+
class UnitTests(unittest.TestCase):
8+
@classmethod
9+
def setUpClass(cls):
10+
cls.PUBLIC_KEY = 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEDr2LjtURuePQzplybdC+u4CwrqDqBaWjcMMsTbhdbcwHBcepxo7yAQGhHPTnlvFYPAZFceEu/1FwCM/QmGUhA=='
11+
cls.SIGNATURE = 'MEUCIQCtIHJeH93Y+qpYeWrySphQgpNGNr/U+UyUlBkU6n7RAwIgJTz2C+8a8xonZGi6BpSzoQsbVRamr2nlxFDWYNH2j/0='
12+
cls.TIMESTAMP = '1588788367'
13+
cls.PAYLOAD = json.dumps({
14+
'event': 'test_event',
15+
'category': 'example_payload',
16+
'message_id': 'message_id',
17+
}, sort_keys=True, separators=(',', ':'))
18+
19+
def test_verify_valid_signature(self):
20+
ew = EventWebhook()
21+
key = ew.convert_public_key_to_ecdsa(self.PUBLIC_KEY)
22+
self.assertTrue(ew.verify_signature(self.PAYLOAD, self.SIGNATURE, self.TIMESTAMP, key))
23+
24+
def test_verify_bad_key(self):
25+
ew = EventWebhook('MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqTxd43gyp8IOEto2LdIfjRQrIbsd4SXZkLW6jDutdhXSJCWHw8REntlo7aNDthvj+y7GjUuFDb/R1NGe1OPzpA==')
26+
self.assertFalse(ew.verify_signature(self.PAYLOAD, self.SIGNATURE, self.TIMESTAMP))
27+
28+
def test_verify_bad_payload(self):
29+
ew = EventWebhook(self.PUBLIC_KEY)
30+
self.assertFalse(ew.verify_signature('payload', self.SIGNATURE, self.TIMESTAMP))
31+
32+
def test_verify_bad_signature(self):
33+
ew = EventWebhook(self.PUBLIC_KEY)
34+
self.assertFalse(ew.verify_signature(
35+
self.PAYLOAD,
36+
'MEUCIQCtIHJeH93Y+qpYeWrySphQgpNGNr/U+UyUlBkU6n7RAwIgJTz2C+8a8xonZGi6BpSzoQsbVRamr2nlxFDWYNH3j/0=',
37+
self.TIMESTAMP
38+
))
39+
40+
def test_verify_bad_timestamp(self):
41+
ew = EventWebhook(self.PUBLIC_KEY)
42+
self.assertFalse(ew.verify_signature(self.PAYLOAD, self.SIGNATURE, 'timestamp'))

0 commit comments

Comments
 (0)