Skip to content

Commit 1deb856

Browse files
author
Shanjai
committed
fix: webhook signature verification matches backend protocol
- Handle v1= prefix in signature header - Timestamp is Unix milliseconds (not seconds) - Add compute_signature() helper - Rewrite tests to mirror backend's computeSignature() exactly - Update README with correct header format and examples
1 parent 94f055b commit 1deb856

4 files changed

Lines changed: 163 additions & 50 deletions

File tree

README.md

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,14 @@ print(f"Download: {url_info.url}")
586586

587587
## Webhook Verification
588588

589-
Commune signs every webhook delivery with HMAC-SHA256. Always verify before processing:
589+
Commune signs every outbound webhook delivery with HMAC-SHA256. The signature uses the format `v1={hex_digest}` and the timestamp is Unix **milliseconds**:
590+
591+
```
592+
x-commune-signature: v1=5a3f2b...
593+
x-commune-timestamp: 1707667200000
594+
```
595+
596+
Always verify before processing:
590597

591598
```python
592599
from commune import verify_signature, WebhookVerificationError
@@ -595,23 +602,34 @@ from commune import verify_signature, WebhookVerificationError
595602
try:
596603
verify_signature(
597604
payload=request.body,
598-
signature=request.headers["X-Commune-Signature"],
605+
signature=request.headers["x-commune-signature"],
599606
secret="whsec_...", # Your inbox webhook secret
600-
timestamp=request.headers.get("X-Commune-Timestamp"),
607+
timestamp=request.headers["x-commune-timestamp"],
601608
)
602609
except WebhookVerificationError as e:
603610
print(f"Invalid webhook: {e}")
604611
return 401
605612
```
606613

614+
You can also compute signatures yourself (useful for testing):
615+
616+
```python
617+
from commune import compute_signature
618+
619+
sig = compute_signature(payload=body, secret="whsec_...", timestamp="1707667200000")
620+
# → "v1=5a3f2b..."
621+
```
622+
607623
| Parameter | Type | Required | Description |
608624
|-----------|------|----------|-------------|
609625
| `payload` | `bytes \| str` | Yes | Raw request body |
610-
| `signature` | `str` | Yes | `X-Commune-Signature` header |
626+
| `signature` | `str` | Yes | `x-commune-signature` header (`v1=...`) |
611627
| `secret` | `str` | Yes | Your inbox webhook secret |
612-
| `timestamp` | `str` | No | `X-Commune-Timestamp` header (enables freshness check) |
628+
| `timestamp` | `str` | Yes* | `x-commune-timestamp` header (Unix ms) |
613629
| `tolerance_seconds` | `int` | No | Max age in seconds (default 300) |
614630

631+
\* Technically optional, but the backend always sends it. Omitting it skips timestamp-based signing and freshness checks.
632+
615633
---
616634

617635
## Error Handling

commune/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
ValidationError,
3636
RateLimitError,
3737
)
38-
from commune.webhooks import verify_signature, WebhookVerificationError
38+
from commune.webhooks import verify_signature, compute_signature, WebhookVerificationError
3939

4040
__all__ = [
4141
"CommuneClient",
@@ -70,6 +70,7 @@
7070
"ValidationError",
7171
"RateLimitError",
7272
"verify_signature",
73+
"compute_signature",
7374
"WebhookVerificationError",
7475
]
7576

commune/webhooks.py

Lines changed: 77 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
"""Webhook signature verification for Commune inbound webhooks."""
1+
"""Webhook signature verification for Commune outbound webhooks.
2+
3+
Commune signs every webhook delivery with HMAC-SHA256. The signature is
4+
sent in the ``x-commune-signature`` header with a ``v1=`` prefix::
5+
6+
x-commune-signature: v1={hex_digest}
7+
x-commune-timestamp: {unix_ms}
8+
9+
The signed content is ``{timestamp}.{body}`` where *timestamp* is the
10+
value of the ``x-commune-timestamp`` header (Unix milliseconds).
11+
"""
212

313
from __future__ import annotations
414

@@ -12,6 +22,39 @@ class WebhookVerificationError(Exception):
1222
"""Raised when webhook signature verification fails."""
1323

1424

25+
_V1_PREFIX = "v1="
26+
27+
28+
def compute_signature(
29+
payload: bytes | str,
30+
secret: str,
31+
timestamp: str,
32+
) -> str:
33+
"""Compute the expected ``v1=`` signature for a webhook payload.
34+
35+
This is useful for testing or for building your own verification
36+
logic. Most callers should use :func:`verify_signature` instead.
37+
38+
Args:
39+
payload: The raw request body.
40+
secret: Your inbox webhook secret.
41+
timestamp: The ``x-commune-timestamp`` header value (Unix ms).
42+
43+
Returns:
44+
The full signature string including the ``v1=`` prefix.
45+
"""
46+
if isinstance(payload, str):
47+
payload = payload.encode("utf-8")
48+
49+
signed_content = f"{timestamp}.".encode("utf-8") + payload
50+
digest = hmac.new(
51+
secret.encode("utf-8"),
52+
signed_content,
53+
hashlib.sha256,
54+
).hexdigest()
55+
return f"{_V1_PREFIX}{digest}"
56+
57+
1558
def verify_signature(
1659
payload: bytes | str,
1760
signature: str,
@@ -22,17 +65,20 @@ def verify_signature(
2265
) -> bool:
2366
"""Verify a Commune webhook signature.
2467
25-
Commune signs every webhook delivery with an HMAC-SHA256 signature
26-
using your inbox webhook secret. Always verify before processing.
68+
Commune signs every webhook delivery with HMAC-SHA256. The signature
69+
header uses the format ``v1={hex_digest}`` and the timestamp header
70+
contains Unix **milliseconds**.
2771
2872
Args:
2973
payload: The raw request body (bytes or string).
30-
signature: The ``X-Commune-Signature`` header value.
74+
signature: The ``x-commune-signature`` header value
75+
(e.g. ``"v1=5a3f2b..."``).
3176
secret: Your inbox webhook secret.
32-
timestamp: The ``X-Commune-Timestamp`` header value (optional).
33-
If provided, the signature is verified against
34-
``{timestamp}.{payload}`` and the timestamp is checked
35-
for freshness.
77+
timestamp: The ``x-commune-timestamp`` header value (Unix ms).
78+
**Required** for proper verification — the backend always
79+
includes the timestamp in the signed content. If omitted,
80+
the signature is verified against the raw payload only
81+
(not recommended).
3682
tolerance_seconds: Maximum age of the webhook in seconds
3783
(default 300 = 5 minutes). Only used when ``timestamp``
3884
is provided.
@@ -51,9 +97,9 @@ def verify_signature(
5197
# In your webhook handler (e.g. Flask / FastAPI):
5298
verify_signature(
5399
payload=request.body,
54-
signature=request.headers["X-Commune-Signature"],
100+
signature=request.headers["x-commune-signature"],
55101
secret="whsec_...",
56-
timestamp=request.headers.get("X-Commune-Timestamp"),
102+
timestamp=request.headers.get("x-commune-timestamp"),
57103
)
58104
"""
59105
if not signature:
@@ -64,29 +110,41 @@ def verify_signature(
64110
if isinstance(payload, str):
65111
payload = payload.encode("utf-8")
66112

67-
# Build the signed content
113+
# Build the signed content — must match backend:
114+
# computeSignature(body, timestamp, secret) → v1=HMAC(secret, "{timestamp}.{body}")
68115
if timestamp:
69116
signed_content = f"{timestamp}.".encode("utf-8") + payload
70117
else:
71118
signed_content = payload
72119

73-
expected = hmac.new(
120+
digest = hmac.new(
74121
secret.encode("utf-8"),
75122
signed_content,
76123
hashlib.sha256,
77124
).hexdigest()
78125

79-
if not hmac.compare_digest(expected, signature):
80-
raise WebhookVerificationError("Invalid signature")
126+
# The backend sends "v1={hex}", so we compare with the prefix.
127+
# Also accept raw hex for backward compatibility.
128+
expected_v1 = f"{_V1_PREFIX}{digest}"
129+
130+
sig_to_compare = signature
131+
if not sig_to_compare.startswith(_V1_PREFIX):
132+
# Caller passed raw hex — compare against raw digest
133+
if not hmac.compare_digest(digest, sig_to_compare):
134+
raise WebhookVerificationError("Invalid signature")
135+
else:
136+
if not hmac.compare_digest(expected_v1, sig_to_compare):
137+
raise WebhookVerificationError("Invalid signature")
81138

82-
# Check timestamp freshness
139+
# Check timestamp freshness — timestamp is Unix MILLISECONDS
83140
if timestamp and tolerance_seconds > 0:
84141
try:
85-
ts = int(timestamp)
86-
age = abs(time.time() - ts)
87-
if age > tolerance_seconds:
142+
ts_ms = int(timestamp)
143+
age_ms = abs(time.time() * 1000 - ts_ms)
144+
age_s = age_ms / 1000
145+
if age_s > tolerance_seconds:
88146
raise WebhookVerificationError(
89-
f"Webhook timestamp too old ({int(age)}s > {tolerance_seconds}s)"
147+
f"Webhook timestamp too old ({int(age_s)}s > {tolerance_seconds}s)"
90148
)
91149
except ValueError:
92150
pass # Non-numeric timestamp — skip freshness check

tests/test_webhook_verify.py

Lines changed: 61 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
"""Tests for webhook signature verification."""
1+
"""Tests for webhook signature verification.
2+
3+
These tests mirror the backend's signing protocol exactly:
4+
- Signature format: ``v1={HMAC-SHA256(secret, "{timestamp_ms}.{body}")}``
5+
- Timestamp: Unix milliseconds (``Date.now()`` in Node.js)
6+
- Headers: ``x-commune-signature``, ``x-commune-timestamp``
7+
"""
28

39
from __future__ import annotations
410

@@ -8,41 +14,60 @@
814

915
import pytest
1016

11-
from commune.webhooks import verify_signature, WebhookVerificationError
17+
from commune.webhooks import verify_signature, compute_signature, WebhookVerificationError
1218

1319

1420
SECRET = "whsec_test_secret_123"
1521
PAYLOAD = b'{"type":"inbound","data":{"message_id":"msg_1"}}'
1622

1723

18-
def _sign(payload: bytes, secret: str, timestamp: str | None = None) -> str:
19-
if timestamp:
20-
signed = f"{timestamp}.".encode("utf-8") + payload
21-
else:
22-
signed = payload
23-
return hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
24-
24+
def _backend_sign(payload: bytes, secret: str, timestamp_ms: str) -> str:
25+
"""Replicate the backend's computeSignature() exactly."""
26+
digest = hmac.new(
27+
secret.encode("utf-8"),
28+
f"{timestamp_ms}.".encode("utf-8") + payload,
29+
hashlib.sha256,
30+
).hexdigest()
31+
return f"v1={digest}"
2532

26-
def test_valid_signature_no_timestamp():
27-
sig = _sign(PAYLOAD, SECRET)
28-
assert verify_signature(PAYLOAD, sig, SECRET) is True
2933

34+
# ── Core verification ────────────────────────────────────────────────────────
3035

31-
def test_valid_signature_with_timestamp():
32-
ts = str(int(time.time()))
33-
sig = _sign(PAYLOAD, SECRET, ts)
36+
def test_valid_v1_signature_with_timestamp():
37+
"""Standard flow: v1= prefixed signature with ms timestamp."""
38+
ts = str(int(time.time() * 1000))
39+
sig = _backend_sign(PAYLOAD, SECRET, ts)
3440
assert verify_signature(PAYLOAD, sig, SECRET, timestamp=ts) is True
3541

3642

3743
def test_valid_signature_string_payload():
38-
payload_str = PAYLOAD.decode("utf-8")
39-
sig = _sign(PAYLOAD, SECRET)
40-
assert verify_signature(payload_str, sig, SECRET) is True
44+
"""Payload passed as str instead of bytes."""
45+
ts = str(int(time.time() * 1000))
46+
sig = _backend_sign(PAYLOAD, SECRET, ts)
47+
assert verify_signature(PAYLOAD.decode("utf-8"), sig, SECRET, timestamp=ts) is True
48+
49+
50+
def test_compute_signature_matches_backend():
51+
"""compute_signature() produces the same output as the backend."""
52+
ts = "1707667200000"
53+
expected = _backend_sign(PAYLOAD, SECRET, ts)
54+
assert compute_signature(PAYLOAD, SECRET, ts) == expected
55+
4156

57+
def test_raw_hex_accepted_for_backward_compat():
58+
"""Raw hex (no v1= prefix) still works for backward compatibility."""
59+
ts = str(int(time.time() * 1000))
60+
full_sig = _backend_sign(PAYLOAD, SECRET, ts)
61+
raw_hex = full_sig[3:] # strip "v1="
62+
assert verify_signature(PAYLOAD, raw_hex, SECRET, timestamp=ts) is True
63+
64+
65+
# ── Error cases ──────────────────────────────────────────────────────────────
4266

4367
def test_invalid_signature_raises():
68+
ts = str(int(time.time() * 1000))
4469
with pytest.raises(WebhookVerificationError, match="Invalid signature"):
45-
verify_signature(PAYLOAD, "bad_signature", SECRET)
70+
verify_signature(PAYLOAD, "v1=bad_hex", SECRET, timestamp=ts)
4671

4772

4873
def test_missing_signature_raises():
@@ -51,19 +76,30 @@ def test_missing_signature_raises():
5176

5277

5378
def test_missing_secret_raises():
54-
sig = _sign(PAYLOAD, SECRET)
79+
ts = str(int(time.time() * 1000))
80+
sig = _backend_sign(PAYLOAD, SECRET, ts)
5581
with pytest.raises(WebhookVerificationError, match="Missing webhook secret"):
56-
verify_signature(PAYLOAD, sig, "")
82+
verify_signature(PAYLOAD, sig, "", timestamp=ts)
83+
5784

85+
# ── Timestamp freshness ─────────────────────────────────────────────────────
5886

5987
def test_expired_timestamp_raises():
60-
old_ts = str(int(time.time()) - 600) # 10 minutes ago
61-
sig = _sign(PAYLOAD, SECRET, old_ts)
88+
"""Timestamp 10 minutes old (600s) should fail with 300s tolerance."""
89+
old_ts = str(int(time.time() * 1000) - 600_000)
90+
sig = _backend_sign(PAYLOAD, SECRET, old_ts)
6291
with pytest.raises(WebhookVerificationError, match="too old"):
6392
verify_signature(PAYLOAD, sig, SECRET, timestamp=old_ts, tolerance_seconds=300)
6493

6594

6695
def test_fresh_timestamp_passes():
67-
ts = str(int(time.time()) - 10) # 10 seconds ago
68-
sig = _sign(PAYLOAD, SECRET, ts)
96+
"""Timestamp 10 seconds old should pass with 300s tolerance."""
97+
ts = str(int(time.time() * 1000) - 10_000)
98+
sig = _backend_sign(PAYLOAD, SECRET, ts)
6999
assert verify_signature(PAYLOAD, sig, SECRET, timestamp=ts, tolerance_seconds=300) is True
100+
101+
102+
def test_no_timestamp_verifies_raw_payload():
103+
"""Without timestamp, signature is computed over raw payload only."""
104+
digest = hmac.new(SECRET.encode(), PAYLOAD, hashlib.sha256).hexdigest()
105+
assert verify_signature(PAYLOAD, f"v1={digest}", SECRET) is True

0 commit comments

Comments
 (0)