relayed 0.0.3__tar.gz → 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {relayed-0.0.3 → relayed-0.1.0}/PKG-INFO +1 -1
- {relayed-0.0.3 → relayed-0.1.0}/src/relayed/__init__.py +6 -0
- {relayed-0.0.3 → relayed-0.1.0}/src/relayed/_errors.py +5 -0
- relayed-0.1.0/src/relayed/_webhooks.py +19 -0
- relayed-0.1.0/tests/test_webhooks.py +62 -0
- {relayed-0.0.3 → relayed-0.1.0}/.gitignore +0 -0
- {relayed-0.0.3 → relayed-0.1.0}/LICENSE +0 -0
- {relayed-0.0.3 → relayed-0.1.0}/README.md +0 -0
- {relayed-0.0.3 → relayed-0.1.0}/pyproject.toml +0 -0
- {relayed-0.0.3 → relayed-0.1.0}/src/relayed/_client.py +0 -0
- {relayed-0.0.3 → relayed-0.1.0}/tests/test_sdk.py +0 -0
|
@@ -6,7 +6,10 @@ from relayed._errors import (
|
|
|
6
6
|
RelayedConflictError,
|
|
7
7
|
RelayedClientError,
|
|
8
8
|
RelayedServerError,
|
|
9
|
+
RelayedTimestampError,
|
|
10
|
+
RelayedSignatureError
|
|
9
11
|
)
|
|
12
|
+
from relayed._webhooks import verify_webhook
|
|
10
13
|
|
|
11
14
|
__all__ = [
|
|
12
15
|
"RelayedClient",
|
|
@@ -16,6 +19,9 @@ __all__ = [
|
|
|
16
19
|
"RelayedConflictError",
|
|
17
20
|
"RelayedClientError",
|
|
18
21
|
"RelayedServerError",
|
|
22
|
+
"RelayedTimestampError",
|
|
23
|
+
"RelayedSignatureError",
|
|
24
|
+
"verify_webhook"
|
|
19
25
|
]
|
|
20
26
|
|
|
21
27
|
from importlib.metadata import version
|
|
@@ -21,3 +21,8 @@ class RelayedClientError(RelayedError):
|
|
|
21
21
|
class RelayedServerError(RelayedError):
|
|
22
22
|
"""5xx — something went wrong on Relayed's end."""
|
|
23
23
|
|
|
24
|
+
class RelayedTimestampError(RelayedError):
|
|
25
|
+
"""Raised when the timestamp of the webhook is outside the allowed tolerance."""
|
|
26
|
+
|
|
27
|
+
class RelayedSignatureError(RelayedError):
|
|
28
|
+
"""Raised when the signature of the webhook does not match the expected signature."""
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
import hmac
|
|
4
|
+
|
|
5
|
+
from ._errors import RelayedTimestampError, RelayedSignatureError
|
|
6
|
+
|
|
7
|
+
def verify_webhook(body: bytes,signature: str,timestamp: str,secret: str,tolerance_seconds: int = 300,) -> None:
|
|
8
|
+
try:
|
|
9
|
+
timestamp_int = int(timestamp)
|
|
10
|
+
except (ValueError, TypeError):
|
|
11
|
+
raise RelayedTimestampError("Webhook timestamp is missing or invalid.")
|
|
12
|
+
current_timestamp = int(datetime.now(timezone.utc).timestamp())
|
|
13
|
+
if abs(current_timestamp - timestamp_int) > tolerance_seconds:
|
|
14
|
+
raise RelayedTimestampError("Webhook timestamp is outside the allowed tolerance.")
|
|
15
|
+
reconstructed_payload = str(timestamp_int).encode("utf-8") + b"." + body
|
|
16
|
+
expected_signature = hmac.new(secret.encode(), reconstructed_payload, digestmod="sha256").hexdigest()
|
|
17
|
+
if not hmac.compare_digest(expected_signature, signature):
|
|
18
|
+
raise RelayedSignatureError("Webhook signature does not match the expected signature.")
|
|
19
|
+
return None
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import hmac
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from relayed._webhooks import verify_webhook
|
|
7
|
+
from relayed import RelayedSignatureError, RelayedTimestampError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _sign(body: bytes, timestamp: int, secret: str) -> str:
|
|
11
|
+
"""Produce the signature the worker would produce for this timestamp+body."""
|
|
12
|
+
signed_payload = str(timestamp).encode("utf-8") + b"." + body
|
|
13
|
+
return hmac.new(secret.encode(), signed_payload, digestmod="sha256").hexdigest()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _now() -> int:
|
|
17
|
+
return int(datetime.now(timezone.utc).timestamp())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
SECRET = "test-webhook-secret"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_verify_webhook_happy_path():
|
|
24
|
+
"""A correctly-signed webhook with a fresh timestamp verifies without raising."""
|
|
25
|
+
body = b'{"event": "delivery.succeeded"}'
|
|
26
|
+
timestamp = _now()
|
|
27
|
+
signature = _sign(body, timestamp, SECRET)
|
|
28
|
+
assert verify_webhook(body, signature, str(timestamp), SECRET) is None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.parametrize("bad_timestamp", ["", "abc", "1234abc", None])
|
|
32
|
+
def test_verify_webhook_invalid_timestamp_format(bad_timestamp):
|
|
33
|
+
"""Malformed timestamp values raise RelayedTimestampError."""
|
|
34
|
+
body = b'{"event": "delivery.succeeded"}'
|
|
35
|
+
with pytest.raises(RelayedTimestampError):
|
|
36
|
+
verify_webhook(body, "0" * 64, bad_timestamp, SECRET)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_verify_webhook_timestamp_too_old():
|
|
40
|
+
"""A timestamp more than tolerance_seconds in the past raises RelayedTimestampError."""
|
|
41
|
+
body = b'{"event": "delivery.succeeded"}'
|
|
42
|
+
timestamp = _now() - 400
|
|
43
|
+
signature = _sign(body, timestamp, SECRET)
|
|
44
|
+
with pytest.raises(RelayedTimestampError):
|
|
45
|
+
verify_webhook(body, signature, str(timestamp), SECRET)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_verify_webhook_timestamp_too_new():
|
|
49
|
+
"""A timestamp more than tolerance_seconds in the future raises RelayedTimestampError."""
|
|
50
|
+
body = b'{"event": "delivery.succeeded"}'
|
|
51
|
+
timestamp = _now() + 400
|
|
52
|
+
signature = _sign(body, timestamp, SECRET)
|
|
53
|
+
with pytest.raises(RelayedTimestampError):
|
|
54
|
+
verify_webhook(body, signature, str(timestamp), SECRET)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_verify_webhook_bad_signature():
|
|
58
|
+
"""A well-formed but incorrect signature raises RelayedSignatureError."""
|
|
59
|
+
body = b'{"event": "delivery.succeeded"}'
|
|
60
|
+
timestamp = _now()
|
|
61
|
+
with pytest.raises(RelayedSignatureError):
|
|
62
|
+
verify_webhook(body, "0" * 64, str(timestamp), SECRET)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|