Skip to content

Commit 12566f6

Browse files
Merge pull request #22 from blockfrost/feat/verify-webhook-sig
feat: add verify_webhook_signature
2 parents dd3661e + 99101ab commit 12566f6

File tree

4 files changed

+209
-2
lines changed

4 files changed

+209
-2
lines changed

README.md

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,13 @@ your API key.
3030
<br/>
3131

3232
## Installation
33+
3334
[![PyPI Latest Release](https://img.shields.io/pypi/v/blockfrost-python.svg)](https://pypi.org/project/blockfrost-python/)
35+
3436
```console
3537
$ pip install blockfrost-python
3638
```
39+
3740
<br/>
3841

3942
## Usage
@@ -59,7 +62,7 @@ try:
5962
print(health) # prints Dataframe: is_healthy
6063
# 0 True
6164

62-
65+
6366
account_rewards = api.account_rewards(
6467
stake_address='stake1ux3g2c9dx2nhhehyrezyxpkstartcqmu9hk63qgfkccw5rqttygt7',
6568
count=20,
@@ -107,4 +110,61 @@ try:
107110
file.write(file_data)
108111
except ApiError as e:
109112
print(e)
110-
```
113+
```
114+
115+
### Verifying Secure Webhook signature
116+
117+
Webhooks enable Blockfrost to push real-time notifications to your application. In order to prevent malicious actor from pretending to be Blockfrost every webhook request is signed. The signature is included in a request's `Blockfrost-Signature` header. This allows you to verify that the events were sent by Blockfrost, not by a third party.
118+
To learn more about Secure Webhooks, see [Secure Webhooks Docs](https://blockfrost.dev/docs/start-building/webhooks/).
119+
120+
You can verify the signature using `verifyWebhookSignature` function.
121+
122+
Example:
123+
124+
```python
125+
# Example of Python Flask app with /webhook endpoint
126+
# for processing events sent by Blockfrost Secure Webhooks
127+
from flask import Flask, request, json
128+
from blockfrost import verify_webhook_signature, SignatureVerificationError
129+
130+
SECRET_AUTH_TOKEN = "SECRET-WEBHOOK-AUTH-TOKEN"
131+
132+
app = Flask(__name__)
133+
134+
@app.route('/webhook', methods=['POST'])
135+
def webhook():
136+
if request.method == 'POST':
137+
# Validate webhook signature
138+
request_bytes = request.get_data()
139+
try:
140+
verify_webhook_signature(
141+
request_bytes, request.headers['Blockfrost-Signature'], SECRET_AUTH_TOKEN)
142+
except SignatureVerificationError as e:
143+
# for easier debugging you can access passed header and request_body values (e.header, e.request_body)
144+
print('Webhook signature is invalid.', e)
145+
return 'Invalid signature', 403
146+
147+
# Get the payload as JSON
148+
event = request.json
149+
150+
print('Received request id {}, webhook_id: {}'.format(
151+
event['id'], event['webhook_id']))
152+
153+
if event['type'] == "block":
154+
# process Block event
155+
print('Received block hash {}'.format(event['payload']['hash']))
156+
elif event['type'] == "...":
157+
# truncated
158+
else:
159+
# Unexpected event type
160+
print('Unexpected event type {}'.format(event['type']))
161+
162+
return 'Webhook received', 200
163+
else:
164+
return 'POST Method not supported', 405
165+
166+
167+
168+
if __name__ == "__main__":
169+
app.run(host='0.0.0.0', port=6666)
170+
```

blockfrost/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
from blockfrost.ipfs import BlockFrostIPFS
33
from blockfrost.config import ApiUrls
44
from blockfrost.utils import ApiError, Namespace
5+
from blockfrost.helpers import SignatureVerificationError, verify_webhook_signature

blockfrost/helpers.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import hmac
2+
import hashlib
3+
import time
4+
5+
6+
class SignatureVerificationError(Exception):
7+
def __init__(self, message, header, request_body):
8+
self.message = message
9+
self.header = header
10+
self.request_body = request_body
11+
super().__init__(self.message)
12+
13+
14+
def get_unix_timestamp():
15+
return int(time.time())
16+
17+
18+
def verify_webhook_signature(request_body, signature_header, secret, timestamp_tolerance_seconds=600):
19+
# Parse signature header
20+
# Example of Blockfrost-Signature header: t=1648550558,v1=162381a59040c97d9b323cdfec02facdfce0968490ec1732f5d938334c1eed4e,v1=...)
21+
tokens = signature_header.split(',')
22+
timestamp = None
23+
signatures = []
24+
for token in tokens:
25+
key, value = token.split('=')
26+
if key == 't':
27+
timestamp = value
28+
elif key == 'v1':
29+
signatures.append(value)
30+
else:
31+
print('Cannot parse part of the Blockfrost-Signature header, key "{}" is not supported by this version of Blockfrost SDK. Please upgrade.'.format(key))
32+
33+
if timestamp is None or timestamp.isnumeric() is False or len(tokens) < 2:
34+
# timestamp and at least one signature must be present
35+
raise SignatureVerificationError(
36+
'Invalid signature header format.', signature_header, request_body)
37+
38+
if len(signatures) == 0:
39+
# There are no signatures that this version of SDK supports
40+
raise SignatureVerificationError(
41+
'No signatures with supported version scheme.', signature_header, request_body)
42+
43+
has_valid_signature = False
44+
for signature in signatures:
45+
# Recreate signature by concatenating the timestamp with the payload (all in bytes),
46+
# then compute HMAC using sha256 and provided secret (webhook auth token)
47+
signature_payload = timestamp.encode() + b"." + request_body
48+
local_signature = hmac.new(
49+
secret.encode(), signature_payload, hashlib.sha256).hexdigest()
50+
51+
# computed signature should match at least one signature parsed from a signature header
52+
if (hmac.compare_digest(signature, local_signature)):
53+
has_valid_signature = True
54+
break
55+
56+
if has_valid_signature == False:
57+
raise SignatureVerificationError(
58+
'No signature matches the expected signature for the payload.', signature_header, request_body)
59+
60+
current_timestamp = get_unix_timestamp()
61+
62+
if (current_timestamp - int(timestamp) > timestamp_tolerance_seconds):
63+
# Event is older than timestamp_tolerance_seconds
64+
raise SignatureVerificationError(
65+
'Signature\'s timestamp is outside of the time tolerance.', signature_header, request_body)
66+
else:
67+
# Successfully validate the signature only if it is within timestamp_tolerance_seconds tolerance
68+
return True

tests/test_helpers.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import os
2+
import mock
3+
import pytest
4+
from blockfrost import SignatureVerificationError, verify_webhook_signature
5+
6+
request_body = b'{"id":"47668401-c3a4-42d4-bac1-ad46515924a3","webhook_id":"cf68eb9c-635f-415e-a5a8-6233638f28d7","created":1650013856,"type":"block","payload":{"time":1650013853,"height":7126256,"hash":"f49521b67b440e5030adf124aee8f88881b7682ba07acf06c2781405b0f806a4","slot":58447562,"epoch":332,"epoch_slot":386762,"slot_leader":"pool1njjr0zn7uvydjy8067nprgwlyxqnznp9wgllfnag24nycgkda25","size":34617,"tx_count":13,"output":"13403118309871","fees":"4986390","block_vrf":"vrf_vk197w95j9alkwt8l4g7xkccknhn4pqwx65c5saxnn5ej3cpmps72msgpw69d","previous_block":"9e3f5bfc9f0be44cf6e14db9ed5f1efb6b637baff0ea1740bb6711786c724915","next_block":null,"confirmations":0}}'
7+
success_fixtures_list = [
8+
{
9+
'description': 'valid signature',
10+
'request_body': request_body,
11+
'signature_header': 't=1650013856,v1=f4c3bb2a8b0c8e21fa7d5fdada2ee87c9c6f6b0b159cc22e483146917e195c3e',
12+
'secret': '59a1eb46-96f4-4f0b-8a03-b4d26e70593a',
13+
'current_timestamp_mock': 1650013856 + 1,
14+
'result': True
15+
},
16+
{
17+
'description': '2 signatures, one valid and one invalid',
18+
'request_body': request_body,
19+
'signature_header': 't=1650013856,v1=abc,v1=f4c3bb2a8b0c8e21fa7d5fdada2ee87c9c6f6b0b159cc22e483146917e195c3e',
20+
'secret': '59a1eb46-96f4-4f0b-8a03-b4d26e70593a',
21+
'current_timestamp_mock': 1650013856 + 1,
22+
'result': True
23+
}
24+
]
25+
26+
error_fixtures_list = [
27+
{
28+
'description': 'throws due to invalid header fromat',
29+
'request_body': request_body,
30+
'signature_header': 'v1=f4c3bb2a8b0c8e21fa7d5fdada2ee87c9c6f6b0b159cc22e483146917e195c3e',
31+
'secret': '59a1eb46-96f4-4f0b-8a03-b4d26e70593a',
32+
'current_timestamp_mock': 1650013856 + 1,
33+
'result_error': 'Invalid signature header format.'
34+
},
35+
{
36+
'description': 'throws due to sig version not supported by this sdk',
37+
'request_body': request_body,
38+
'signature_header': 't=1650013856,v42=abc',
39+
'secret': '59a1eb46-96f4-4f0b-8a03-b4d26e70593a',
40+
'current_timestamp_mock': 1650013856 + 1,
41+
'result_error': 'No signatures with supported version scheme.'
42+
},
43+
{
44+
'description': 'throws due to no signature match',
45+
'request_body': request_body,
46+
'signature_header': 't=1650013856,v1=abc',
47+
'secret': '59a1eb46-96f4-4f0b-8a03-b4d26e70593a',
48+
'current_timestamp_mock': 1650013856 + 1,
49+
'result_error': 'No signature matches the expected signature for the payload.'
50+
},
51+
{
52+
'description': 'throws due to timestamp out of tolerance zone',
53+
'request_body': request_body,
54+
'signature_header': 't=1650013856,v1=f4c3bb2a8b0c8e21fa7d5fdada2ee87c9c6f6b0b159cc22e483146917e195c3e',
55+
'secret': '59a1eb46-96f4-4f0b-8a03-b4d26e70593a',
56+
'current_timestamp_mock': 1650013856 + 7200,
57+
'result_error': 'Signature\'s timestamp is outside of the time tolerance.'
58+
}
59+
]
60+
61+
62+
@pytest.mark.parametrize("fixture", success_fixtures_list)
63+
def test_verify_webhook_signature(fixture):
64+
with mock.patch('blockfrost.helpers.get_unix_timestamp', return_value=fixture['current_timestamp_mock']):
65+
res = verify_webhook_signature(
66+
fixture['request_body'], fixture['signature_header'], fixture['secret'])
67+
assert res == fixture['result']
68+
69+
70+
@pytest.mark.parametrize("fixture", error_fixtures_list)
71+
def test_verify_webhook_signature_fails(fixture):
72+
with mock.patch('blockfrost.helpers.get_unix_timestamp', return_value=fixture['current_timestamp_mock']):
73+
with pytest.raises(SignatureVerificationError) as e_info:
74+
verify_webhook_signature(
75+
fixture['request_body'], fixture['signature_header'], fixture['secret'])
76+
assert str(e_info.value) == fixture['result_error']
77+
assert e_info.value.header == fixture['signature_header']
78+
assert e_info.value.request_body == fixture['request_body']

0 commit comments

Comments
 (0)