algovoi-webhook-verifier 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.
- algovoi_webhook_verifier-0.1.0/.gitignore +30 -0
- algovoi_webhook_verifier-0.1.0/PKG-INFO +25 -0
- algovoi_webhook_verifier-0.1.0/README.md +1 -0
- algovoi_webhook_verifier-0.1.0/algovoi_webhook_verifier/__init__.py +16 -0
- algovoi_webhook_verifier-0.1.0/algovoi_webhook_verifier/errors.py +40 -0
- algovoi_webhook_verifier-0.1.0/algovoi_webhook_verifier/verify.py +171 -0
- algovoi_webhook_verifier-0.1.0/pyproject.toml +39 -0
- algovoi_webhook_verifier-0.1.0/tests/__init__.py +0 -0
- algovoi_webhook_verifier-0.1.0/tests/test_vectors.py +72 -0
- algovoi_webhook_verifier-0.1.0/tests/test_verify.py +316 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
.venv/
|
|
8
|
+
venv/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
*.egg-info/
|
|
12
|
+
.eggs/
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
*.pyc
|
|
15
|
+
|
|
16
|
+
# TypeScript / Node
|
|
17
|
+
node_modules/
|
|
18
|
+
typescript/dist/
|
|
19
|
+
typescript/node_modules/
|
|
20
|
+
*.tsbuildinfo
|
|
21
|
+
|
|
22
|
+
# Editors
|
|
23
|
+
.vscode/
|
|
24
|
+
.idea/
|
|
25
|
+
*.swp
|
|
26
|
+
*.swo
|
|
27
|
+
|
|
28
|
+
# OS
|
|
29
|
+
.DS_Store
|
|
30
|
+
Thumbs.db
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: algovoi-webhook-verifier
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cryptographic verifier for AlgoVoi webhook signatures (v1 HMAC-SHA256 + v2 HKDF-SHA256/HMAC-SHA384)
|
|
5
|
+
Project-URL: Homepage, https://docs.algovoi.co.uk/webhook-verifier
|
|
6
|
+
Project-URL: Repository, https://github.com/chopmob-cloud/algovoi-webhook-verifier
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/chopmob-cloud/algovoi-webhook-verifier/issues
|
|
8
|
+
Author-email: AlgoVoi <dev@algovoi.co.uk>
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Keywords: algovoi,hmac,signature,verification,webhook
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Security :: Cryptography
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: cryptography>=41.0.0
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
See the [repository README](https://github.com/chopmob-cloud/algovoi-webhook-verifier) for full documentation.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
See the [repository README](https://github.com/chopmob-cloud/algovoi-webhook-verifier) for full documentation.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""AlgoVoi webhook verifier — public API."""
|
|
2
|
+
|
|
3
|
+
from .errors import ERROR_CODES, ErrorCode, WebhookVerificationError
|
|
4
|
+
from .verify import DEFAULT_TOLERANCE, KNOWN_EVENT_TYPES, SIGNATURE_HEADER, verify_webhook
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"verify_webhook",
|
|
8
|
+
"WebhookVerificationError",
|
|
9
|
+
"ErrorCode",
|
|
10
|
+
"ERROR_CODES",
|
|
11
|
+
"SIGNATURE_HEADER",
|
|
12
|
+
"DEFAULT_TOLERANCE",
|
|
13
|
+
"KNOWN_EVENT_TYPES",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Typed error codes for AlgoVoi webhook verification failures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
ERROR_CODES = frozenset({
|
|
8
|
+
"MISSING_SIGNATURE",
|
|
9
|
+
"MALFORMED_SIGNATURE",
|
|
10
|
+
"STALE_SIGNATURE",
|
|
11
|
+
"INVALID_SIGNATURE",
|
|
12
|
+
"INVALID_PAYLOAD",
|
|
13
|
+
"UNKNOWN_EVENT_TYPE",
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
ErrorCode = Literal[
|
|
17
|
+
"MISSING_SIGNATURE",
|
|
18
|
+
"MALFORMED_SIGNATURE",
|
|
19
|
+
"STALE_SIGNATURE",
|
|
20
|
+
"INVALID_SIGNATURE",
|
|
21
|
+
"INVALID_PAYLOAD",
|
|
22
|
+
"UNKNOWN_EVENT_TYPE",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class WebhookVerificationError(Exception):
|
|
27
|
+
"""Raised when webhook signature verification fails.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
code: One of the six ``ERROR_CODES`` values.
|
|
31
|
+
message: Human-readable description.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, code: ErrorCode, message: str) -> None:
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.code: ErrorCode = code
|
|
37
|
+
self.message: str = message
|
|
38
|
+
|
|
39
|
+
def __repr__(self) -> str:
|
|
40
|
+
return f"WebhookVerificationError(code={self.code!r}, message={self.message!r})"
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""AlgoVoi webhook signature verifier — v1 (HMAC-SHA256) and v2 (HKDF-SHA256 + HMAC-SHA384)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from cryptography.hazmat.primitives import hashes
|
|
13
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
14
|
+
|
|
15
|
+
from .errors import WebhookVerificationError
|
|
16
|
+
|
|
17
|
+
# Header name used by the AlgoVoi gateway
|
|
18
|
+
SIGNATURE_HEADER = "X-AlgoVoi-Signature"
|
|
19
|
+
|
|
20
|
+
# Default replay-prevention window (seconds)
|
|
21
|
+
DEFAULT_TOLERANCE = 300
|
|
22
|
+
|
|
23
|
+
# Supported event types
|
|
24
|
+
KNOWN_EVENT_TYPES = frozenset({"payment.confirmed"})
|
|
25
|
+
|
|
26
|
+
_SIG_RE = re.compile(
|
|
27
|
+
r"^t=(?P<ts>\d+),v1=(?P<v1>[0-9a-f]{64})(?:,v2=(?P<v2>[0-9a-f]{96}))?$"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _derive_v2_key(secret_bytes: bytes) -> bytes:
|
|
32
|
+
"""HKDF-SHA256 key derivation matching the gateway _sign() implementation."""
|
|
33
|
+
hkdf = HKDF(
|
|
34
|
+
algorithm=hashes.SHA256(),
|
|
35
|
+
length=48,
|
|
36
|
+
salt=b"algovoi-webhook-v2-pqc",
|
|
37
|
+
info=b"hmac-sha384-outbound",
|
|
38
|
+
)
|
|
39
|
+
return hkdf.derive(secret_bytes)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _compute_sigs(secret: str, ts: int, body: bytes) -> tuple[str, str]:
|
|
43
|
+
"""Return (v1_hex, v2_hex) for the given secret, timestamp, and body."""
|
|
44
|
+
secret_bytes = secret.encode()
|
|
45
|
+
signed_payload = f"{ts}.".encode() + body
|
|
46
|
+
v1 = hmac.new(secret_bytes, signed_payload, hashlib.sha256).hexdigest()
|
|
47
|
+
v2_key = _derive_v2_key(secret_bytes)
|
|
48
|
+
v2 = hmac.new(v2_key, signed_payload, hashlib.sha384).hexdigest()
|
|
49
|
+
return v1, v2
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def verify_webhook(
|
|
53
|
+
*,
|
|
54
|
+
payload: bytes,
|
|
55
|
+
secret: str,
|
|
56
|
+
signature_header: str,
|
|
57
|
+
tolerance: int = DEFAULT_TOLERANCE,
|
|
58
|
+
require_v2: bool = False,
|
|
59
|
+
) -> dict[str, Any]:
|
|
60
|
+
"""Verify an AlgoVoi webhook signature and return the parsed event payload.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
payload:
|
|
65
|
+
The raw request body bytes exactly as received (do not decode first).
|
|
66
|
+
secret:
|
|
67
|
+
The webhook signing secret for the tenant (``algvw_*`` prefixed string).
|
|
68
|
+
signature_header:
|
|
69
|
+
The value of the ``X-AlgoVoi-Signature`` header.
|
|
70
|
+
tolerance:
|
|
71
|
+
Maximum age (seconds) of a valid signature. Default 300. Pass ``0``
|
|
72
|
+
to disable staleness checking (test environments only).
|
|
73
|
+
require_v2:
|
|
74
|
+
If ``True`` the v2 component *must* be present and valid. Default
|
|
75
|
+
``False`` (v2 is validated when present; absent v2 is accepted).
|
|
76
|
+
|
|
77
|
+
Returns
|
|
78
|
+
-------
|
|
79
|
+
dict
|
|
80
|
+
The parsed JSON event object.
|
|
81
|
+
|
|
82
|
+
Raises
|
|
83
|
+
------
|
|
84
|
+
WebhookVerificationError
|
|
85
|
+
On any verification failure. Inspect ``.code`` for the specific
|
|
86
|
+
failure mode.
|
|
87
|
+
"""
|
|
88
|
+
# ------------------------------------------------------------------ #
|
|
89
|
+
# 1. Presence check #
|
|
90
|
+
# ------------------------------------------------------------------ #
|
|
91
|
+
trimmed_header = signature_header.strip() if signature_header else ""
|
|
92
|
+
if not trimmed_header:
|
|
93
|
+
raise WebhookVerificationError(
|
|
94
|
+
"MISSING_SIGNATURE",
|
|
95
|
+
"X-AlgoVoi-Signature header is absent",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------ #
|
|
99
|
+
# 2. Parse header #
|
|
100
|
+
# ------------------------------------------------------------------ #
|
|
101
|
+
m = _SIG_RE.match(trimmed_header)
|
|
102
|
+
if not m:
|
|
103
|
+
raise WebhookVerificationError(
|
|
104
|
+
"MALFORMED_SIGNATURE",
|
|
105
|
+
f"Signature header does not match expected format t=<unix>,v1=<sha256hex>[,v2=<sha384hex>]: {signature_header!r}",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
ts = int(m.group("ts"))
|
|
109
|
+
received_v1: str = m.group("v1")
|
|
110
|
+
received_v2: str | None = m.group("v2")
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------ #
|
|
113
|
+
# 3. Staleness check #
|
|
114
|
+
# ------------------------------------------------------------------ #
|
|
115
|
+
if tolerance > 0:
|
|
116
|
+
age = abs(int(time.time()) - ts)
|
|
117
|
+
if age > tolerance:
|
|
118
|
+
raise WebhookVerificationError(
|
|
119
|
+
"STALE_SIGNATURE",
|
|
120
|
+
f"Signature timestamp {ts} is {age}s old (tolerance {tolerance}s)",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------ #
|
|
124
|
+
# 4. HMAC computation #
|
|
125
|
+
# ------------------------------------------------------------------ #
|
|
126
|
+
expected_v1, expected_v2 = _compute_sigs(secret, ts, payload)
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------------ #
|
|
129
|
+
# 5. Signature comparison #
|
|
130
|
+
# ------------------------------------------------------------------ #
|
|
131
|
+
v1_ok = hmac.compare_digest(received_v1, expected_v1)
|
|
132
|
+
|
|
133
|
+
if received_v2 is not None:
|
|
134
|
+
v2_ok = hmac.compare_digest(received_v2, expected_v2)
|
|
135
|
+
else:
|
|
136
|
+
v2_ok = not require_v2 # absent v2 is fine unless caller requires it
|
|
137
|
+
|
|
138
|
+
if not v1_ok or not v2_ok:
|
|
139
|
+
raise WebhookVerificationError(
|
|
140
|
+
"INVALID_SIGNATURE",
|
|
141
|
+
"One or more signature components did not match",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------------ #
|
|
145
|
+
# 6. Payload parse #
|
|
146
|
+
# ------------------------------------------------------------------ #
|
|
147
|
+
try:
|
|
148
|
+
event: dict[str, Any] = json.loads(payload)
|
|
149
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
150
|
+
raise WebhookVerificationError(
|
|
151
|
+
"INVALID_PAYLOAD",
|
|
152
|
+
f"Payload is not valid JSON: {exc}",
|
|
153
|
+
) from exc
|
|
154
|
+
|
|
155
|
+
if not isinstance(event, dict):
|
|
156
|
+
raise WebhookVerificationError(
|
|
157
|
+
"INVALID_PAYLOAD",
|
|
158
|
+
"Payload root must be a JSON object",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# ------------------------------------------------------------------ #
|
|
162
|
+
# 7. Event-type check #
|
|
163
|
+
# ------------------------------------------------------------------ #
|
|
164
|
+
event_type = event.get("type")
|
|
165
|
+
if event_type not in KNOWN_EVENT_TYPES:
|
|
166
|
+
raise WebhookVerificationError(
|
|
167
|
+
"UNKNOWN_EVENT_TYPE",
|
|
168
|
+
f"Unrecognised event type: {event_type!r}",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return event
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "algovoi-webhook-verifier"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Cryptographic verifier for AlgoVoi webhook signatures (v1 HMAC-SHA256 + v2 HKDF-SHA256/HMAC-SHA384)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "Apache-2.0" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "AlgoVoi", email = "dev@algovoi.co.uk" }]
|
|
13
|
+
keywords = ["webhook", "hmac", "signature", "verification", "algovoi"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Security :: Cryptography",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"cryptography>=41.0.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://docs.algovoi.co.uk/webhook-verifier"
|
|
32
|
+
Repository = "https://github.com/chopmob-cloud/algovoi-webhook-verifier"
|
|
33
|
+
"Bug Tracker" = "https://github.com/chopmob-cloud/algovoi-webhook-verifier/issues"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["algovoi_webhook_verifier"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Cross-validation vector tests for algovoi_webhook_verifier."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from algovoi_webhook_verifier import WebhookVerificationError, verify_webhook
|
|
12
|
+
|
|
13
|
+
VECTORS_ROOT = Path(__file__).parent.parent.parent / "vectors"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_vectors(subdir: str):
|
|
17
|
+
d = VECTORS_ROOT / subdir
|
|
18
|
+
if not d.exists():
|
|
19
|
+
pytest.skip(f"Vector directory not found: {d}")
|
|
20
|
+
return sorted(d.glob("*.json"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# --------------------------------------------------------------------------- #
|
|
24
|
+
# Valid vectors — must all succeed #
|
|
25
|
+
# --------------------------------------------------------------------------- #
|
|
26
|
+
|
|
27
|
+
@pytest.mark.parametrize("fixture_path", _load_vectors("valid"))
|
|
28
|
+
def test_valid_vector(fixture_path):
|
|
29
|
+
fixture = json.loads(fixture_path.read_text(encoding="utf-8"))
|
|
30
|
+
body = fixture["body"].encode("utf-8")
|
|
31
|
+
event = verify_webhook(
|
|
32
|
+
payload=body,
|
|
33
|
+
secret=fixture["secret"],
|
|
34
|
+
signature_header=fixture["signature_header"],
|
|
35
|
+
tolerance=0, # vectors use fixed timestamps — disable staleness check
|
|
36
|
+
)
|
|
37
|
+
assert event["type"] == "payment.confirmed"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# --------------------------------------------------------------------------- #
|
|
41
|
+
# Invalid vectors — must raise with matching error_code #
|
|
42
|
+
# --------------------------------------------------------------------------- #
|
|
43
|
+
|
|
44
|
+
@pytest.mark.parametrize("fixture_path", _load_vectors("invalid"))
|
|
45
|
+
def test_invalid_vector(fixture_path):
|
|
46
|
+
fixture = json.loads(fixture_path.read_text(encoding="utf-8"))
|
|
47
|
+
body = fixture["body"].encode("utf-8")
|
|
48
|
+
tolerance = fixture.get("tolerance", 0)
|
|
49
|
+
fake_now = fixture.get("fake_now")
|
|
50
|
+
|
|
51
|
+
expected_code = fixture["error_code"]
|
|
52
|
+
|
|
53
|
+
def _run():
|
|
54
|
+
verify_webhook(
|
|
55
|
+
payload=body,
|
|
56
|
+
secret=fixture["secret"],
|
|
57
|
+
signature_header=fixture["signature_header"],
|
|
58
|
+
tolerance=tolerance,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
with pytest.raises(WebhookVerificationError) as exc_info:
|
|
62
|
+
if fake_now is not None:
|
|
63
|
+
with patch("algovoi_webhook_verifier.verify.time") as mock_time:
|
|
64
|
+
mock_time.time.return_value = fake_now
|
|
65
|
+
_run()
|
|
66
|
+
else:
|
|
67
|
+
_run()
|
|
68
|
+
|
|
69
|
+
assert exc_info.value.code == expected_code, (
|
|
70
|
+
f"Vector {fixture_path.name}: expected error code {expected_code!r}, "
|
|
71
|
+
f"got {exc_info.value.code!r}"
|
|
72
|
+
)
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Unit tests for algovoi_webhook_verifier.verify."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from cryptography.hazmat.primitives import hashes
|
|
13
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
14
|
+
|
|
15
|
+
from algovoi_webhook_verifier import WebhookVerificationError, verify_webhook
|
|
16
|
+
|
|
17
|
+
# --------------------------------------------------------------------------- #
|
|
18
|
+
# Helpers #
|
|
19
|
+
# --------------------------------------------------------------------------- #
|
|
20
|
+
|
|
21
|
+
SECRET = "algvw_test_unit_secret"
|
|
22
|
+
NOW = 1748650000 # fixed timestamp for all tests
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _derive_v2_key(secret_bytes: bytes) -> bytes:
|
|
26
|
+
return HKDF(
|
|
27
|
+
algorithm=hashes.SHA256(),
|
|
28
|
+
length=48,
|
|
29
|
+
salt=b"algovoi-webhook-v2-pqc",
|
|
30
|
+
info=b"hmac-sha384-outbound",
|
|
31
|
+
).derive(secret_bytes)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _sign(secret: str, ts: int, body: bytes) -> str:
|
|
35
|
+
sb = secret.encode()
|
|
36
|
+
sp = f"{ts}.".encode() + body
|
|
37
|
+
v1 = hmac.new(sb, sp, hashlib.sha256).hexdigest()
|
|
38
|
+
v2_key = _derive_v2_key(sb)
|
|
39
|
+
v2 = hmac.new(v2_key, sp, hashlib.sha384).hexdigest()
|
|
40
|
+
return f"t={ts},v1={v1},v2={v2}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _payment_body(**overrides) -> bytes:
|
|
44
|
+
data = {
|
|
45
|
+
"id": "evt_unit_001",
|
|
46
|
+
"type": "payment.confirmed",
|
|
47
|
+
"created": NOW,
|
|
48
|
+
"api_version": "2024-01-01",
|
|
49
|
+
"data": {},
|
|
50
|
+
}
|
|
51
|
+
data.update(overrides)
|
|
52
|
+
return json.dumps(data, separators=(",", ":")).encode()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _call(body=None, secret=SECRET, ts=NOW, **kw):
|
|
56
|
+
"""Helper: sign body with secret+ts and call verify_webhook with tolerance=0."""
|
|
57
|
+
if body is None:
|
|
58
|
+
body = _payment_body()
|
|
59
|
+
header = _sign(secret, ts, body)
|
|
60
|
+
return verify_webhook(
|
|
61
|
+
payload=body,
|
|
62
|
+
secret=secret,
|
|
63
|
+
signature_header=header,
|
|
64
|
+
tolerance=0,
|
|
65
|
+
**kw,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --------------------------------------------------------------------------- #
|
|
70
|
+
# Happy-path #
|
|
71
|
+
# --------------------------------------------------------------------------- #
|
|
72
|
+
|
|
73
|
+
class TestHappyPath:
|
|
74
|
+
def test_returns_parsed_event(self):
|
|
75
|
+
event = _call()
|
|
76
|
+
assert event["type"] == "payment.confirmed"
|
|
77
|
+
assert event["id"] == "evt_unit_001"
|
|
78
|
+
|
|
79
|
+
def test_v1_only_accepted_by_default(self):
|
|
80
|
+
body = _payment_body()
|
|
81
|
+
sb = SECRET.encode()
|
|
82
|
+
sp = f"{NOW}.".encode() + body
|
|
83
|
+
v1 = hmac.new(sb, sp, hashlib.sha256).hexdigest()
|
|
84
|
+
header = f"t={NOW},v1={v1}"
|
|
85
|
+
event = verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0)
|
|
86
|
+
assert event["type"] == "payment.confirmed"
|
|
87
|
+
|
|
88
|
+
def test_v2_validated_when_present(self):
|
|
89
|
+
# Passing require_v2=True with a full sig should succeed
|
|
90
|
+
event = _call(require_v2=True)
|
|
91
|
+
assert event["type"] == "payment.confirmed"
|
|
92
|
+
|
|
93
|
+
def test_whitespace_trimmed_from_header(self):
|
|
94
|
+
body = _payment_body()
|
|
95
|
+
header = " " + _sign(SECRET, NOW, body) + " "
|
|
96
|
+
event = verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0)
|
|
97
|
+
assert event["type"] == "payment.confirmed"
|
|
98
|
+
|
|
99
|
+
def test_custom_tolerance_accepted(self):
|
|
100
|
+
body = _payment_body()
|
|
101
|
+
# ts = NOW - 100, fake time = NOW → age = 100 < tolerance 200
|
|
102
|
+
old_ts = NOW - 100
|
|
103
|
+
header = _sign(SECRET, old_ts, body)
|
|
104
|
+
with patch("algovoi_webhook_verifier.verify.time") as mock_time:
|
|
105
|
+
mock_time.time.return_value = NOW
|
|
106
|
+
event = verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=200)
|
|
107
|
+
assert event["type"] == "payment.confirmed"
|
|
108
|
+
|
|
109
|
+
def test_unicode_body(self):
|
|
110
|
+
body = json.dumps({
|
|
111
|
+
"id": "evt_u_001",
|
|
112
|
+
"type": "payment.confirmed",
|
|
113
|
+
"created": NOW,
|
|
114
|
+
"api_version": "2024-01-01",
|
|
115
|
+
"data": {"label": "café"},
|
|
116
|
+
}, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
117
|
+
header = _sign(SECRET, NOW, body)
|
|
118
|
+
event = verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0)
|
|
119
|
+
assert event["data"]["label"] == "café"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# --------------------------------------------------------------------------- #
|
|
123
|
+
# MISSING_SIGNATURE #
|
|
124
|
+
# --------------------------------------------------------------------------- #
|
|
125
|
+
|
|
126
|
+
class TestMissingSignature:
|
|
127
|
+
@pytest.mark.parametrize("header", ["", None])
|
|
128
|
+
def test_empty_or_none_header(self, header):
|
|
129
|
+
body = _payment_body()
|
|
130
|
+
with pytest.raises(WebhookVerificationError) as exc_info:
|
|
131
|
+
verify_webhook(payload=body, secret=SECRET, signature_header=header or "", tolerance=0)
|
|
132
|
+
assert exc_info.value.code == "MISSING_SIGNATURE"
|
|
133
|
+
|
|
134
|
+
def test_error_message_contains_header_name(self):
|
|
135
|
+
with pytest.raises(WebhookVerificationError) as exc_info:
|
|
136
|
+
verify_webhook(payload=_payment_body(), secret=SECRET, signature_header="", tolerance=0)
|
|
137
|
+
assert "X-AlgoVoi-Signature" in exc_info.value.message
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --------------------------------------------------------------------------- #
|
|
141
|
+
# MALFORMED_SIGNATURE #
|
|
142
|
+
# --------------------------------------------------------------------------- #
|
|
143
|
+
|
|
144
|
+
class TestMalformedSignature:
|
|
145
|
+
@pytest.mark.parametrize("bad_header", [
|
|
146
|
+
"v1=abc123",
|
|
147
|
+
"t=abc,v1=abc", # non-numeric ts
|
|
148
|
+
"t=123,v2=abc", # missing v1
|
|
149
|
+
"t=123,v1=" + "a" * 63, # v1 too short
|
|
150
|
+
"t=123,v1=" + "a" * 65, # v1 too long
|
|
151
|
+
"t=123,v1=" + "g" * 64, # non-hex v1
|
|
152
|
+
"randomgarbage",
|
|
153
|
+
])
|
|
154
|
+
def test_rejects_bad_format(self, bad_header):
|
|
155
|
+
with pytest.raises(WebhookVerificationError) as exc_info:
|
|
156
|
+
verify_webhook(payload=_payment_body(), secret=SECRET, signature_header=bad_header, tolerance=0)
|
|
157
|
+
assert exc_info.value.code == "MALFORMED_SIGNATURE"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# --------------------------------------------------------------------------- #
|
|
161
|
+
# STALE_SIGNATURE #
|
|
162
|
+
# --------------------------------------------------------------------------- #
|
|
163
|
+
|
|
164
|
+
class TestStaleSignature:
|
|
165
|
+
def test_expired_timestamp(self):
|
|
166
|
+
body = _payment_body()
|
|
167
|
+
old_ts = NOW - 600
|
|
168
|
+
header = _sign(SECRET, old_ts, body)
|
|
169
|
+
with patch("algovoi_webhook_verifier.verify.time") as mock_time:
|
|
170
|
+
mock_time.time.return_value = NOW
|
|
171
|
+
with pytest.raises(WebhookVerificationError) as exc_info:
|
|
172
|
+
verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=300)
|
|
173
|
+
assert exc_info.value.code == "STALE_SIGNATURE"
|
|
174
|
+
|
|
175
|
+
def test_future_timestamp_beyond_tolerance(self):
|
|
176
|
+
body = _payment_body()
|
|
177
|
+
future_ts = NOW + 600
|
|
178
|
+
header = _sign(SECRET, future_ts, body)
|
|
179
|
+
with patch("algovoi_webhook_verifier.verify.time") as mock_time:
|
|
180
|
+
mock_time.time.return_value = NOW
|
|
181
|
+
with pytest.raises(WebhookVerificationError) as exc_info:
|
|
182
|
+
verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=300)
|
|
183
|
+
assert exc_info.value.code == "STALE_SIGNATURE"
|
|
184
|
+
|
|
185
|
+
def test_tolerance_zero_bypasses_check(self):
|
|
186
|
+
body = _payment_body()
|
|
187
|
+
old_ts = NOW - 99999
|
|
188
|
+
header = _sign(SECRET, old_ts, body)
|
|
189
|
+
# Should NOT raise even though ts is ancient
|
|
190
|
+
event = verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0)
|
|
191
|
+
assert event["type"] == "payment.confirmed"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# --------------------------------------------------------------------------- #
|
|
195
|
+
# INVALID_SIGNATURE #
|
|
196
|
+
# --------------------------------------------------------------------------- #
|
|
197
|
+
|
|
198
|
+
class TestInvalidSignature:
|
|
199
|
+
def test_wrong_secret(self):
|
|
200
|
+
body = _payment_body()
|
|
201
|
+
header = _sign("algvw_wrong_secret", NOW, body)
|
|
202
|
+
with pytest.raises(WebhookVerificationError) as exc_info:
|
|
203
|
+
verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0)
|
|
204
|
+
assert exc_info.value.code == "INVALID_SIGNATURE"
|
|
205
|
+
|
|
206
|
+
def test_tampered_body(self):
|
|
207
|
+
body = _payment_body()
|
|
208
|
+
header = _sign(SECRET, NOW, body)
|
|
209
|
+
tampered = body.replace(b"evt_unit_001", b"evt_tampered")
|
|
210
|
+
with pytest.raises(WebhookVerificationError) as exc_info:
|
|
211
|
+
verify_webhook(payload=tampered, secret=SECRET, signature_header=header, tolerance=0)
|
|
212
|
+
assert exc_info.value.code == "INVALID_SIGNATURE"
|
|
213
|
+
|
|
214
|
+
def test_corrupted_v1_hex(self):
|
|
215
|
+
body = _payment_body()
|
|
216
|
+
header = _sign(SECRET, NOW, body)
|
|
217
|
+
# Flip last character of v1
|
|
218
|
+
corrupted = header[:-1] + ("0" if header[-1] != "0" else "1")
|
|
219
|
+
with pytest.raises(WebhookVerificationError) as exc_info:
|
|
220
|
+
verify_webhook(payload=body, secret=SECRET, signature_header=corrupted, tolerance=0)
|
|
221
|
+
assert exc_info.value.code == "INVALID_SIGNATURE"
|
|
222
|
+
|
|
223
|
+
def test_require_v2_fails_when_v2_absent(self):
|
|
224
|
+
body = _payment_body()
|
|
225
|
+
sb = SECRET.encode()
|
|
226
|
+
sp = f"{NOW}.".encode() + body
|
|
227
|
+
v1 = hmac.new(sb, sp, hashlib.sha256).hexdigest()
|
|
228
|
+
header = f"t={NOW},v1={v1}" # no v2
|
|
229
|
+
with pytest.raises(WebhookVerificationError) as exc_info:
|
|
230
|
+
verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0, require_v2=True)
|
|
231
|
+
assert exc_info.value.code == "INVALID_SIGNATURE"
|
|
232
|
+
|
|
233
|
+
def test_corrupted_v2_hex(self):
|
|
234
|
+
body = _payment_body()
|
|
235
|
+
header = _sign(SECRET, NOW, body)
|
|
236
|
+
# Corrupt the v2 component
|
|
237
|
+
parts = header.split(",v2=")
|
|
238
|
+
bad_v2 = parts[1][:-1] + ("0" if parts[1][-1] != "0" else "1")
|
|
239
|
+
corrupted = parts[0] + ",v2=" + bad_v2
|
|
240
|
+
with pytest.raises(WebhookVerificationError) as exc_info:
|
|
241
|
+
verify_webhook(payload=body, secret=SECRET, signature_header=corrupted, tolerance=0)
|
|
242
|
+
assert exc_info.value.code == "INVALID_SIGNATURE"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# --------------------------------------------------------------------------- #
|
|
246
|
+
# INVALID_PAYLOAD #
|
|
247
|
+
# --------------------------------------------------------------------------- #
|
|
248
|
+
|
|
249
|
+
class TestInvalidPayload:
|
|
250
|
+
def test_not_json(self):
|
|
251
|
+
body = b"not-json"
|
|
252
|
+
header = _sign(SECRET, NOW, body)
|
|
253
|
+
with pytest.raises(WebhookVerificationError) as exc_info:
|
|
254
|
+
verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0)
|
|
255
|
+
assert exc_info.value.code == "INVALID_PAYLOAD"
|
|
256
|
+
|
|
257
|
+
def test_json_array_root(self):
|
|
258
|
+
body = b'["a","b"]'
|
|
259
|
+
header = _sign(SECRET, NOW, body)
|
|
260
|
+
with pytest.raises(WebhookVerificationError) as exc_info:
|
|
261
|
+
verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0)
|
|
262
|
+
assert exc_info.value.code == "INVALID_PAYLOAD"
|
|
263
|
+
|
|
264
|
+
def test_truncated_json(self):
|
|
265
|
+
body = b'{"type": "payment.confirmed"' # missing closing brace
|
|
266
|
+
header = _sign(SECRET, NOW, body)
|
|
267
|
+
with pytest.raises(WebhookVerificationError) as exc_info:
|
|
268
|
+
verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0)
|
|
269
|
+
assert exc_info.value.code == "INVALID_PAYLOAD"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# --------------------------------------------------------------------------- #
|
|
273
|
+
# UNKNOWN_EVENT_TYPE #
|
|
274
|
+
# --------------------------------------------------------------------------- #
|
|
275
|
+
|
|
276
|
+
class TestUnknownEventType:
|
|
277
|
+
@pytest.mark.parametrize("event_type", [
|
|
278
|
+
"refund.issued",
|
|
279
|
+
"payment.failed",
|
|
280
|
+
"mandate.cancelled",
|
|
281
|
+
None,
|
|
282
|
+
"",
|
|
283
|
+
])
|
|
284
|
+
def test_unknown_types(self, event_type):
|
|
285
|
+
data = {
|
|
286
|
+
"id": "evt_unk",
|
|
287
|
+
"type": event_type,
|
|
288
|
+
"created": NOW,
|
|
289
|
+
"api_version": "2024-01-01",
|
|
290
|
+
"data": {},
|
|
291
|
+
}
|
|
292
|
+
body = json.dumps(data, separators=(",", ":")).encode()
|
|
293
|
+
header = _sign(SECRET, NOW, body)
|
|
294
|
+
with pytest.raises(WebhookVerificationError) as exc_info:
|
|
295
|
+
verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0)
|
|
296
|
+
assert exc_info.value.code == "UNKNOWN_EVENT_TYPE"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# --------------------------------------------------------------------------- #
|
|
300
|
+
# Error object shape #
|
|
301
|
+
# --------------------------------------------------------------------------- #
|
|
302
|
+
|
|
303
|
+
class TestErrorObject:
|
|
304
|
+
def test_error_has_code_and_message(self):
|
|
305
|
+
with pytest.raises(WebhookVerificationError) as exc_info:
|
|
306
|
+
verify_webhook(payload=_payment_body(), secret=SECRET, signature_header="", tolerance=0)
|
|
307
|
+
err = exc_info.value
|
|
308
|
+
assert isinstance(err.code, str)
|
|
309
|
+
assert isinstance(err.message, str)
|
|
310
|
+
assert str(err) == err.message
|
|
311
|
+
|
|
312
|
+
def test_repr_contains_code_and_message(self):
|
|
313
|
+
err = WebhookVerificationError("INVALID_SIGNATURE", "test msg")
|
|
314
|
+
r = repr(err)
|
|
315
|
+
assert "INVALID_SIGNATURE" in r
|
|
316
|
+
assert "test msg" in r
|