Skip to content

Commit 2223f6e

Browse files
Merge pull request #152 from adcontextprotocol/EmmaLouise2018/fix-issue-151
fix: use default JSON separators for webhook HMAC signing
2 parents f1c3990 + 72f8f4b commit 2223f6e

File tree

4 files changed

+301
-46
lines changed

4 files changed

+301
-46
lines changed

src/adcp/client.py

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1893,22 +1893,29 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
18931893
await self.close()
18941894

18951895
def _verify_webhook_signature(
1896-
self, payload: dict[str, Any], signature: str, timestamp: str
1896+
self,
1897+
payload: dict[str, Any],
1898+
signature: str,
1899+
timestamp: str,
1900+
raw_body: bytes | str | None = None,
18971901
) -> bool:
18981902
"""
18991903
Verify HMAC-SHA256 signature of webhook payload.
19001904
19011905
The verification algorithm matches get_adcp_signed_headers_for_webhook:
19021906
1. Constructs message as "{timestamp}.{json_payload}"
1903-
2. JSON-serializes payload with compact separators
1904-
3. UTF-8 encodes the message
1907+
2. Uses raw HTTP body bytes when available (preserves sender's serialization)
1908+
3. Falls back to json.dumps() if raw_body not provided
19051909
4. HMAC-SHA256 signs with the shared secret
19061910
5. Compares against the provided signature (with "sha256=" prefix stripped)
19071911
19081912
Args:
1909-
payload: Webhook payload dict
1913+
payload: Webhook payload dict (used as fallback if raw_body not provided)
19101914
signature: Signature to verify (with or without "sha256=" prefix)
1911-
timestamp: ISO 8601 timestamp from X-AdCP-Timestamp header
1915+
timestamp: Unix timestamp in seconds from X-AdCP-Timestamp header
1916+
raw_body: Raw HTTP request body bytes. When provided, used directly
1917+
for signature verification to avoid cross-language serialization
1918+
mismatches. Strongly recommended for production use.
19121919
19131920
Returns:
19141921
True if signature is valid, False otherwise
@@ -1920,11 +1927,15 @@ def _verify_webhook_signature(
19201927
if signature.startswith("sha256="):
19211928
signature = signature[7:]
19221929

1923-
# Serialize payload to JSON with consistent formatting (matches signing)
1924-
payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=False).encode("utf-8")
1930+
# Use raw body if available (avoids cross-language serialization mismatches),
1931+
# otherwise fall back to json.dumps() for backward compatibility
1932+
if raw_body is not None:
1933+
payload_str = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body
1934+
else:
1935+
payload_str = json.dumps(payload)
19251936

1926-
# Construct signed message: timestamp.payload (matches get_adcp_signed_headers_for_webhook)
1927-
signed_message = f"{timestamp}.{payload_bytes.decode('utf-8')}"
1937+
# Construct signed message: timestamp.payload
1938+
signed_message = f"{timestamp}.{payload_str}"
19281939

19291940
# Generate expected signature
19301941
expected_signature = hmac.new(
@@ -2073,6 +2084,7 @@ async def _handle_mcp_webhook(
20732084
operation_id: str,
20742085
signature: str | None,
20752086
timestamp: str | None = None,
2087+
raw_body: bytes | str | None = None,
20762088
) -> TaskResult[AdcpAsyncResponseData]:
20772089
"""
20782090
Handle MCP webhook delivered via HTTP POST.
@@ -2082,7 +2094,8 @@ async def _handle_mcp_webhook(
20822094
task_type: Task type from application routing
20832095
operation_id: Operation identifier from application routing
20842096
signature: Optional HMAC-SHA256 signature for verification (X-AdCP-Signature header)
2085-
timestamp: Optional timestamp for signature verification (X-AdCP-Timestamp header)
2097+
timestamp: Optional Unix timestamp for signature verification (X-AdCP-Timestamp header)
2098+
raw_body: Optional raw HTTP request body for signature verification
20862099
20872100
Returns:
20882101
TaskResult with parsed task-specific response data
@@ -2097,7 +2110,7 @@ async def _handle_mcp_webhook(
20972110
if (
20982111
signature
20992112
and timestamp
2100-
and not self._verify_webhook_signature(payload, signature, timestamp)
2113+
and not self._verify_webhook_signature(payload, signature, timestamp, raw_body)
21012114
):
21022115
logger.warning(
21032116
f"Webhook signature verification failed for agent {self.agent_config.id}"
@@ -2283,6 +2296,7 @@ async def handle_webhook(
22832296
operation_id: str,
22842297
signature: str | None = None,
22852298
timestamp: str | None = None,
2299+
raw_body: bytes | str | None = None,
22862300
) -> TaskResult[AdcpAsyncResponseData]:
22872301
"""
22882302
Handle incoming webhook and return typed result.
@@ -2310,8 +2324,12 @@ async def handle_webhook(
23102324
Used to correlate webhook notifications with original task submission.
23112325
signature: Optional HMAC-SHA256 signature for MCP webhook verification
23122326
(X-AdCP-Signature header). Ignored for A2A webhooks.
2313-
timestamp: Optional timestamp for MCP webhook signature verification
2314-
(X-AdCP-Timestamp header). Required when signature is provided.
2327+
timestamp: Optional Unix timestamp (seconds) for MCP webhook signature
2328+
verification (X-AdCP-Timestamp header). Required when signature is provided.
2329+
raw_body: Optional raw HTTP request body bytes for signature verification.
2330+
When provided, used directly instead of re-serializing the payload,
2331+
avoiding cross-language JSON serialization mismatches. Strongly
2332+
recommended for production use.
23152333
23162334
Returns:
23172335
TaskResult with parsed task-specific response data. The structure
@@ -2330,11 +2348,13 @@ async def handle_webhook(
23302348
MCP webhook (HTTP endpoint):
23312349
>>> @app.post("/webhook/{task_type}/{agent_id}/{operation_id}")
23322350
>>> async def webhook_handler(task_type: str, operation_id: str, request: Request):
2333-
>>> payload = await request.json()
2351+
>>> raw_body = await request.body()
2352+
>>> payload = json.loads(raw_body)
23342353
>>> signature = request.headers.get("X-AdCP-Signature")
23352354
>>> timestamp = request.headers.get("X-AdCP-Timestamp")
23362355
>>> result = await client.handle_webhook(
2337-
>>> payload, task_type, operation_id, signature, timestamp
2356+
>>> payload, task_type, operation_id, signature, timestamp,
2357+
>>> raw_body=raw_body,
23382358
>>> )
23392359
>>> if result.success:
23402360
>>> print(f"Task completed: {result.data}")
@@ -2368,7 +2388,7 @@ async def handle_webhook(
23682388
else:
23692389
# MCP webhook (dict payload)
23702390
return await self._handle_mcp_webhook(
2371-
payload, task_type, operation_id, signature, timestamp
2391+
payload, task_type, operation_id, signature, timestamp, raw_body
23722392
)
23732393

23742394

src/adcp/webhooks.py

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,10 @@ def create_mcp_webhook_payload(
125125

126126

127127
def get_adcp_signed_headers_for_webhook(
128-
headers: dict[str, Any], secret: str, timestamp: str, payload: dict[str, Any] | AdCPBaseModel
128+
headers: dict[str, Any],
129+
secret: str,
130+
timestamp: str | int | None,
131+
payload: dict[str, Any] | AdCPBaseModel,
129132
) -> dict[str, Any]:
130133
"""
131134
Generate AdCP-compliant signed headers for webhook delivery.
@@ -136,28 +139,29 @@ def get_adcp_signed_headers_for_webhook(
136139
137140
The function adds two headers to the provided headers dict:
138141
- X-AdCP-Signature: HMAC-SHA256 signature in format "sha256=<hex_digest>"
139-
- X-AdCP-Timestamp: ISO 8601 timestamp used in signature generation
142+
- X-AdCP-Timestamp: Unix timestamp in seconds
140143
141144
The signing algorithm:
142145
1. Constructs message as "{timestamp}.{json_payload}"
143-
2. JSON-serializes payload with compact separators (no sorted keys for performance)
146+
2. JSON-serializes payload with default separators (matches wire format from json= kwarg)
144147
3. UTF-8 encodes the message
145148
4. HMAC-SHA256 signs with the shared secret
146149
5. Hex-encodes and prefixes with "sha256="
147150
148151
Args:
149152
headers: Existing headers dictionary to add signature headers to
150153
secret: Shared secret key for HMAC signing
151-
timestamp: ISO 8601 timestamp string (e.g., "2025-01-15T10:00:00Z")
154+
timestamp: Unix timestamp in seconds (str or int). If None, uses current time.
152155
payload: Webhook payload (dict or Pydantic model - will be JSON-serialized)
153156
154157
Returns:
155158
The modified headers dictionary with signature headers added
156159
157160
Examples:
158161
Sign and send an MCP webhook:
159-
>>> from adcp.webhooks import create_mcp_webhook_payload get_adcp_signed_headers_for_webhook
160-
>>> from datetime import datetime, timezone
162+
>>> import time
163+
>>> from adcp.webhooks import create_mcp_webhook_payload
164+
>>> from adcp.webhooks import get_adcp_signed_headers_for_webhook
161165
>>>
162166
>>> payload = create_mcp_webhook_payload(
163167
... task_id="task_123",
@@ -166,9 +170,9 @@ def get_adcp_signed_headers_for_webhook(
166170
... result={"products": [...]}
167171
... )
168172
>>> headers = {"Content-Type": "application/json"}
169-
>>> timestamp = datetime.now(timezone.utc).isoformat()
170173
>>> signed_headers = get_adcp_signed_headers_for_webhook(
171-
... headers, secret="my-webhook-secret", timestamp=timestamp, payload=payload
174+
... headers, secret="my-webhook-secret", timestamp=str(int(time.time())),
175+
... payload=payload,
172176
... )
173177
>>>
174178
>>> # Send webhook with signed headers
@@ -184,35 +188,31 @@ def get_adcp_signed_headers_for_webhook(
184188
{
185189
"Content-Type": "application/json",
186190
"X-AdCP-Signature": "sha256=a1b2c3...",
187-
"X-AdCP-Timestamp": "2025-01-15T10:00:00Z"
191+
"X-AdCP-Timestamp": "1773185740"
188192
}
189-
190-
Sign with Pydantic model directly:
191-
>>> from adcp import GetMediaBuyDeliveryResponse
192-
>>> from datetime import datetime, timezone
193-
>>>
194-
>>> response: GetMediaBuyDeliveryResponse = ... # From API call
195-
>>> headers = {"Content-Type": "application/json"}
196-
>>> timestamp = datetime.now(timezone.utc).isoformat()
197-
>>> signed_headers = get_adcp_signed_headers_for_webhook(
198-
... headers, secret="my-webhook-secret", timestamp=timestamp, payload=response
199-
... )
200-
>>> # Pydantic model is automatically converted to dict for signing
201193
"""
194+
# Default to current Unix time if not provided
195+
if timestamp is None:
196+
import time
197+
198+
timestamp = str(int(time.time()))
199+
else:
200+
timestamp = str(timestamp)
201+
202202
# Convert Pydantic model to dict if needed
203203
# All AdCP types inherit from AdCPBaseModel (Pydantic BaseModel)
204204
if hasattr(payload, "model_dump"):
205205
payload_dict = payload.model_dump(mode="json")
206206
else:
207207
payload_dict = payload
208208

209-
# Serialize payload to JSON with consistent formatting
210-
# Note: sort_keys=False for performance (key order doesn't affect signature)
211-
payload_bytes = json.dumps(payload_dict, separators=(",", ":"), sort_keys=False).encode("utf-8")
209+
# Serialize payload to JSON with default formatting (matches what json= kwarg sends on the wire)
210+
# This aligns with the JS reference implementation's JSON.stringify() behavior
211+
payload_json = json.dumps(payload_dict)
212212

213213
# Construct signed message: timestamp.payload
214214
# Including timestamp prevents replay attacks
215-
signed_message = f"{timestamp}.{payload_bytes.decode('utf-8')}"
215+
signed_message = f"{timestamp}.{payload_json}"
216216

217217
# Generate HMAC-SHA256 signature over timestamp + payload
218218
signature_hex = hmac.new(
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
{
2+
"version": 1,
3+
"algorithm": "HMAC-SHA256",
4+
"secret": "test-secret-key-minimum-32-characters-long",
5+
"description": "Test vectors from adcontextprotocol/adcp PR #1383",
6+
"vectors": [
7+
{
8+
"description": "compact JSON (JS-style JSON.stringify)",
9+
"timestamp": 1700000000,
10+
"raw_body": "{\"event\":\"creative.status_changed\",\"creative_id\":\"creative_123\",\"status\":\"approved\"}",
11+
"expected_signature": "sha256=c4faf82609efe07621706df0d28c801de2b5145f427e129f243a3839df891a4e"
12+
},
13+
{
14+
"description": "spaced JSON (Python-style json.dumps with default separators)",
15+
"timestamp": 1700000000,
16+
"raw_body": "{\"event\": \"creative.status_changed\", \"creative_id\": \"creative_123\", \"status\": \"approved\"}",
17+
"expected_signature": "sha256=4acce503547a93922a2b41c32f5f0e646b71a36572fd1536d3d7fcd88a4e5c5f"
18+
},
19+
{
20+
"description": "empty object",
21+
"timestamp": 1700000000,
22+
"raw_body": "{}",
23+
"expected_signature": "sha256=fc66235ab6cf0a5927d76d88194036fa99c7e08c75d55c9de5008288d448f1a0"
24+
},
25+
{
26+
"description": "nested objects and arrays",
27+
"timestamp": 1700000000,
28+
"raw_body": "{\"task_id\":\"task_456\",\"operation_id\":\"op_789\",\"result\":{\"media_buy_id\":\"mb_001\",\"packages\":[{\"package_id\":\"pkg_1\"},{\"package_id\":\"pkg_2\"}]}}",
29+
"expected_signature": "sha256=a90052e145bd73ba69a236748df05a3887ef9e73ddd429ef179bdd498ddb97ba"
30+
},
31+
{
32+
"description": "unicode characters (literal UTF-8, not escaped)",
33+
"timestamp": 1700000000,
34+
"raw_body": "{\"brand_name\":\"Café Münchën\",\"tagline\":\"日本語テスト\"}",
35+
"expected_signature": "sha256=4383aa943264c461c5b9796734fdd9ae51934ecbdf7d38fcf94d330bfa590576"
36+
},
37+
{
38+
"description": "pretty-printed JSON (multiline with indentation)",
39+
"timestamp": 1700000000,
40+
"raw_body": "{\n \"status\": \"completed\",\n \"result\": {\n \"id\": \"mb_001\"\n }\n}",
41+
"expected_signature": "sha256=ad4858d6a7a38207ee178502b4bffc700080258a433e127919b445b68794f085"
42+
},
43+
{
44+
"description": "numeric values, booleans, and null",
45+
"timestamp": 1700000000,
46+
"raw_body": "{\"price\":19.99,\"count\":1000,\"active\":true,\"discount\":null}",
47+
"expected_signature": "sha256=12d4173bebd369c066880bd8f12952c4c1f6f48addbc1dc5267d8ba8de205a4f"
48+
},
49+
{
50+
"description": "empty body",
51+
"timestamp": 1700000000,
52+
"raw_body": "",
53+
"expected_signature": "sha256=9ab3f90245d5919d344a849a4a1b0ec20b75fcf8f29d817e63b23b54fce52294"
54+
},
55+
{
56+
"description": "timestamp zero",
57+
"timestamp": 0,
58+
"raw_body": "{\"event\":\"test\"}",
59+
"expected_signature": "sha256=446cc9dbe11ee98af9445a27dfcf9d52530c874583e5750d295bad336a406c3c"
60+
},
61+
{
62+
"description": "large timestamp (year 2040)",
63+
"timestamp": 2208988800,
64+
"raw_body": "{\"event\":\"test\"}",
65+
"expected_signature": "sha256=a0fdee5e93b2ac2efdf8d3d22b7a03ae8e6df157b493d0140f7902ef32f6be60"
66+
}
67+
]
68+
}

0 commit comments

Comments
 (0)