webhook-platform 2.5.0__tar.gz → 2.6.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.
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/PKG-INFO +1 -1
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/hookflow/__init__.py +1 -1
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/hookflow/client.py +1 -1
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/hookflow/webhooks.py +23 -6
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/pyproject.toml +1 -1
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/tests/test_webhooks.py +43 -0
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/webhook_platform.egg-info/PKG-INFO +1 -1
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/README.md +0 -0
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/hookflow/errors.py +0 -0
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/hookflow/types.py +0 -0
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/scripts/live_api_smoke.py +0 -0
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/setup.cfg +0 -0
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/tests/__init__.py +0 -0
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/tests/contract/__init__.py +0 -0
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/tests/contract/conftest.py +0 -0
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/tests/contract/support.py +0 -0
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/tests/contract/test_client_contract.py +0 -0
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/tests/test_client.py +0 -0
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/tests/test_incoming.py +0 -0
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/tests/test_package_identity.py +0 -0
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/webhook_platform.egg-info/SOURCES.txt +0 -0
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/webhook_platform.egg-info/dependency_links.txt +0 -0
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/webhook_platform.egg-info/requires.txt +0 -0
- {webhook_platform-2.5.0 → webhook_platform-2.6.0}/webhook_platform.egg-info/top_level.txt +0 -0
|
@@ -20,9 +20,16 @@ def verify_signature(
|
|
|
20
20
|
"""
|
|
21
21
|
Verify webhook signature using HMAC-SHA256.
|
|
22
22
|
|
|
23
|
+
The header is ``t=<unix-ms>,v1=<hex>`` and may carry more than one ``v1``.
|
|
24
|
+
After you rotate an endpoint's secret, Hookflow signs each delivery with both
|
|
25
|
+
the new secret and the retired one for the endpoint's grace window (24 hours
|
|
26
|
+
by default), so the new secret can be deployed whenever you like rather than
|
|
27
|
+
at the instant you press rotate. The delivery is authentic if *any* ``v1``
|
|
28
|
+
matches.
|
|
29
|
+
|
|
23
30
|
Args:
|
|
24
31
|
payload: Raw request body as string
|
|
25
|
-
signature: X-Signature header value (format: t=timestamp,v1=signature)
|
|
32
|
+
signature: X-Signature header value (format: t=timestamp,v1=signature[,v1=...])
|
|
26
33
|
secret: Endpoint webhook secret
|
|
27
34
|
tolerance_ms: Maximum age of signature in milliseconds
|
|
28
35
|
|
|
@@ -39,17 +46,20 @@ def verify_signature(
|
|
|
39
46
|
|
|
40
47
|
# Parse signature
|
|
41
48
|
timestamp: Optional[str] = None
|
|
42
|
-
|
|
49
|
+
# Collected, not overwritten: a header sent during a secret rotation carries one
|
|
50
|
+
# v1 per valid secret, and keeping only the last would reject whichever of the
|
|
51
|
+
# pair the receiver is currently holding.
|
|
52
|
+
signatures: list[str] = []
|
|
43
53
|
|
|
44
54
|
for part in signature.split(","):
|
|
45
55
|
if "=" in part:
|
|
46
56
|
key, value = part.split("=", 1)
|
|
47
57
|
if key == "t":
|
|
48
|
-
timestamp = value
|
|
58
|
+
timestamp = value.strip()
|
|
49
59
|
elif key == "v1":
|
|
50
|
-
|
|
60
|
+
signatures.append(value.strip())
|
|
51
61
|
|
|
52
|
-
if not timestamp or not
|
|
62
|
+
if not timestamp or not signatures:
|
|
53
63
|
raise HookflowError(
|
|
54
64
|
"Invalid signature format. Expected: t=timestamp,v1=signature",
|
|
55
65
|
400,
|
|
@@ -75,7 +85,14 @@ def verify_signature(
|
|
|
75
85
|
hashlib.sha256,
|
|
76
86
|
).hexdigest()
|
|
77
87
|
|
|
78
|
-
|
|
88
|
+
# Every candidate is compared, with no early exit, so the time taken does not
|
|
89
|
+
# depend on which position matched.
|
|
90
|
+
matched = False
|
|
91
|
+
for candidate in signatures:
|
|
92
|
+
if hmac.compare_digest(candidate, expected_signature):
|
|
93
|
+
matched = True
|
|
94
|
+
|
|
95
|
+
if not matched:
|
|
79
96
|
raise HookflowError("Invalid signature", 400, "invalid_signature")
|
|
80
97
|
|
|
81
98
|
return True
|
|
@@ -81,6 +81,49 @@ class TestVerifySignature:
|
|
|
81
81
|
|
|
82
82
|
assert verify_signature(payload, signature, secret) is True
|
|
83
83
|
|
|
84
|
+
def test_verifies_either_signature_during_a_secret_rotation(self):
|
|
85
|
+
"""A header carrying two v1 values verifies with either secret.
|
|
86
|
+
|
|
87
|
+
After a rotation Hookflow signs each delivery with the new secret and the
|
|
88
|
+
retired one for the grace window, so a receiver that has not deployed the new
|
|
89
|
+
secret yet keeps working. Before this, the parser kept only the last v1 and
|
|
90
|
+
rejected whichever half of the pair the receiver was holding.
|
|
91
|
+
"""
|
|
92
|
+
payload = '{"type": "order.completed"}'
|
|
93
|
+
new_secret, retired_secret = "whsec_new", "whsec_retired"
|
|
94
|
+
timestamp = int(time.time() * 1000)
|
|
95
|
+
header = "{},v1={}".format(
|
|
96
|
+
generate_signature(payload, new_secret, timestamp),
|
|
97
|
+
generate_signature(payload, retired_secret, timestamp).split("v1=")[1],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
assert verify_signature(payload, header, new_secret) is True
|
|
101
|
+
assert verify_signature(payload, header, retired_secret) is True
|
|
102
|
+
|
|
103
|
+
def test_two_signatures_still_reject_an_unrelated_secret(self):
|
|
104
|
+
"""Accepting any v1 must not become accepting anything."""
|
|
105
|
+
payload = '{"type": "order.completed"}'
|
|
106
|
+
timestamp = int(time.time() * 1000)
|
|
107
|
+
header = "{},v1={}".format(
|
|
108
|
+
generate_signature(payload, "whsec_new", timestamp),
|
|
109
|
+
generate_signature(payload, "whsec_retired", timestamp).split("v1=")[1],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
with pytest.raises(HookflowError):
|
|
113
|
+
verify_signature(payload, header, "whsec_someone_else")
|
|
114
|
+
|
|
115
|
+
def test_two_signatures_still_reject_a_tampered_body(self):
|
|
116
|
+
"""Two signatures must not weaken body integrity."""
|
|
117
|
+
payload = '{"type": "order.completed"}'
|
|
118
|
+
timestamp = int(time.time() * 1000)
|
|
119
|
+
header = "{},v1={}".format(
|
|
120
|
+
generate_signature(payload, "whsec_new", timestamp),
|
|
121
|
+
generate_signature(payload, "whsec_retired", timestamp).split("v1=")[1],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
with pytest.raises(HookflowError):
|
|
125
|
+
verify_signature(payload + " ", header, "whsec_new")
|
|
126
|
+
|
|
84
127
|
def test_raises_on_missing_signature(self):
|
|
85
128
|
"""Should raise on missing signature."""
|
|
86
129
|
with pytest.raises(HookflowError) as exc:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{webhook_platform-2.5.0 → webhook_platform-2.6.0}/webhook_platform.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|