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.
Files changed (24) hide show
  1. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/PKG-INFO +1 -1
  2. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/hookflow/__init__.py +1 -1
  3. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/hookflow/client.py +1 -1
  4. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/hookflow/webhooks.py +23 -6
  5. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/pyproject.toml +1 -1
  6. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/tests/test_webhooks.py +43 -0
  7. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/webhook_platform.egg-info/PKG-INFO +1 -1
  8. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/README.md +0 -0
  9. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/hookflow/errors.py +0 -0
  10. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/hookflow/types.py +0 -0
  11. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/scripts/live_api_smoke.py +0 -0
  12. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/setup.cfg +0 -0
  13. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/tests/__init__.py +0 -0
  14. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/tests/contract/__init__.py +0 -0
  15. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/tests/contract/conftest.py +0 -0
  16. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/tests/contract/support.py +0 -0
  17. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/tests/contract/test_client_contract.py +0 -0
  18. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/tests/test_client.py +0 -0
  19. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/tests/test_incoming.py +0 -0
  20. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/tests/test_package_identity.py +0 -0
  21. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/webhook_platform.egg-info/SOURCES.txt +0 -0
  22. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/webhook_platform.egg-info/dependency_links.txt +0 -0
  23. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/webhook_platform.egg-info/requires.txt +0 -0
  24. {webhook_platform-2.5.0 → webhook_platform-2.6.0}/webhook_platform.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webhook-platform
3
- Version: 2.5.0
3
+ Version: 2.6.0
4
4
  Summary: Official Python SDK for Hookflow — reliable webhook infrastructure
5
5
  Author-email: Vadym Kykalo <vadymkykalo@gmail.com>
6
6
  License: MIT
@@ -36,7 +36,7 @@ from .types import (
36
36
  ReplayEventResponse,
37
37
  )
38
38
 
39
- __version__ = "2.5.0"
39
+ __version__ = "2.6.0"
40
40
 
41
41
  # Backward-compatible aliases
42
42
  WebhookPlatform = Hookflow
@@ -37,7 +37,7 @@ from .errors import (
37
37
 
38
38
  DEFAULT_BASE_URL = "http://localhost:8080"
39
39
  DEFAULT_TIMEOUT = 30
40
- SDK_VERSION = "2.5.0"
40
+ SDK_VERSION = "2.6.0"
41
41
 
42
42
 
43
43
  class Hookflow:
@@ -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
- sig: Optional[str] = None
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
- sig = value
60
+ signatures.append(value.strip())
51
61
 
52
- if not timestamp or not sig:
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
- if not hmac.compare_digest(sig, expected_signature):
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "webhook-platform"
7
- version = "2.5.0"
7
+ version = "2.6.0"
8
8
  description = "Official Python SDK for Hookflow — reliable webhook infrastructure"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webhook-platform
3
- Version: 2.5.0
3
+ Version: 2.6.0
4
4
  Summary: Official Python SDK for Hookflow — reliable webhook infrastructure
5
5
  Author-email: Vadym Kykalo <vadymkykalo@gmail.com>
6
6
  License: MIT