Skip to content

Commit aaa7c8b

Browse files
committed
fix: update verify signature function to work with timestamp
1 parent 436f754 commit aaa7c8b

File tree

2 files changed

+54
-16
lines changed

2 files changed

+54
-16
lines changed

src/adcp/client.py

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -812,23 +812,43 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
812812
"""Async context manager exit."""
813813
await self.close()
814814

815-
def _verify_webhook_signature(self, payload: dict[str, Any], signature: str) -> bool:
815+
def _verify_webhook_signature(
816+
self, payload: dict[str, Any], signature: str, timestamp: str
817+
) -> bool:
816818
"""
817819
Verify HMAC-SHA256 signature of webhook payload.
818820
821+
The verification algorithm matches get_adcp_signed_headers_for_webhook:
822+
1. Constructs message as "{timestamp}.{json_payload}"
823+
2. JSON-serializes payload with compact separators
824+
3. UTF-8 encodes the message
825+
4. HMAC-SHA256 signs with the shared secret
826+
5. Compares against the provided signature (with "sha256=" prefix stripped)
827+
819828
Args:
820829
payload: Webhook payload dict
821-
signature: Signature to verify
830+
signature: Signature to verify (with or without "sha256=" prefix)
831+
timestamp: ISO 8601 timestamp from X-AdCP-Timestamp header
822832
823833
Returns:
824834
True if signature is valid, False otherwise
825835
"""
826836
if not self.webhook_secret:
827837
return True
828838

829-
payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
839+
# Strip "sha256=" prefix if present
840+
if signature.startswith("sha256="):
841+
signature = signature[7:]
842+
843+
# Serialize payload to JSON with consistent formatting (matches signing)
844+
payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=False).encode("utf-8")
845+
846+
# Construct signed message: timestamp.payload (matches get_adcp_signed_headers_for_webhook)
847+
signed_message = f"{timestamp}.{payload_bytes.decode('utf-8')}"
848+
849+
# Generate expected signature
830850
expected_signature = hmac.new(
831-
self.webhook_secret.encode("utf-8"), payload_bytes, hashlib.sha256
851+
self.webhook_secret.encode("utf-8"), signed_message.encode("utf-8"), hashlib.sha256
832852
).hexdigest()
833853

834854
return hmac.compare_digest(signature, expected_signature)
@@ -941,6 +961,7 @@ async def _handle_mcp_webhook(
941961
task_type: str,
942962
operation_id: str,
943963
signature: str | None,
964+
timestamp: str | None = None,
944965
) -> TaskResult[AdcpAsyncResponseData]:
945966
"""
946967
Handle MCP webhook delivered via HTTP POST.
@@ -949,7 +970,8 @@ async def _handle_mcp_webhook(
949970
payload: Webhook payload dict
950971
task_type: Task type from application routing
951972
operation_id: Operation identifier from application routing
952-
signature: Optional HMAC-SHA256 signature for verification
973+
signature: Optional HMAC-SHA256 signature for verification (X-AdCP-Signature header)
974+
timestamp: Optional timestamp for signature verification (X-AdCP-Timestamp header)
953975
954976
Returns:
955977
TaskResult with parsed task-specific response data
@@ -960,8 +982,10 @@ async def _handle_mcp_webhook(
960982
"""
961983
from adcp.types.generated_poc.core.mcp_webhook_payload import McpWebhookPayload
962984

963-
# Verify signature before processing
964-
if signature and not self._verify_webhook_signature(payload, signature):
985+
# Verify signature before processing (requires both signature and timestamp)
986+
if signature and timestamp and not self._verify_webhook_signature(
987+
payload, signature, timestamp
988+
):
965989
logger.warning(
966990
f"Webhook signature verification failed for agent {self.agent_config.id}"
967991
)
@@ -1151,6 +1175,7 @@ async def handle_webhook(
11511175
task_type: str,
11521176
operation_id: str,
11531177
signature: str | None = None,
1178+
timestamp: str | None = None,
11541179
) -> TaskResult[AdcpAsyncResponseData]:
11551180
"""
11561181
Handle incoming webhook and return typed result.
@@ -1176,8 +1201,10 @@ async def handle_webhook(
11761201
/webhook/{task_type}/{agent_id}/{operation_id}
11771202
operation_id: Operation identifier from application routing.
11781203
Used to correlate webhook notifications with original task submission.
1179-
signature: Optional HMAC-SHA256 signature for MCP webhook verification.
1180-
Ignored for A2A webhooks (they use authenticated connections).
1204+
signature: Optional HMAC-SHA256 signature for MCP webhook verification
1205+
(X-AdCP-Signature header). Ignored for A2A webhooks.
1206+
timestamp: Optional timestamp for MCP webhook signature verification
1207+
(X-AdCP-Timestamp header). Required when signature is provided.
11811208
11821209
Returns:
11831210
TaskResult with parsed task-specific response data. The structure
@@ -1197,9 +1224,10 @@ async def handle_webhook(
11971224
>>> @app.post("/webhook/{task_type}/{agent_id}/{operation_id}")
11981225
>>> async def webhook_handler(task_type: str, operation_id: str, request: Request):
11991226
>>> payload = await request.json()
1200-
>>> signature = request.headers.get("X-Signature")
1227+
>>> signature = request.headers.get("X-AdCP-Signature")
1228+
>>> timestamp = request.headers.get("X-AdCP-Timestamp")
12011229
>>> result = await client.handle_webhook(
1202-
>>> payload, task_type, operation_id, signature
1230+
>>> payload, task_type, operation_id, signature, timestamp
12031231
>>> )
12041232
>>> if result.success:
12051233
>>> print(f"Task completed: {result.data}")
@@ -1232,7 +1260,9 @@ async def handle_webhook(
12321260
return await self._handle_a2a_webhook(payload, task_type, operation_id)
12331261
else:
12341262
# MCP webhook (dict payload)
1235-
return await self._handle_mcp_webhook(payload, task_type, operation_id, signature)
1263+
return await self._handle_mcp_webhook(
1264+
payload, task_type, operation_id, signature, timestamp
1265+
)
12361266

12371267

12381268
class ADCPMultiAgentClient:

tests/test_webhook_handling.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,19 +168,26 @@ async def test_mcp_webhook_signature_verification_valid(self):
168168
},
169169
}
170170

171-
# Generate valid signature
171+
# Generate valid signature using {timestamp}.{payload} format
172+
# (matching get_adcp_signed_headers_for_webhook)
172173
import hashlib
173174
import hmac
174175

175-
payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode(
176+
header_timestamp = "2025-01-15T10:00:00Z"
177+
payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=False).encode(
176178
"utf-8"
177179
)
180+
signed_message = f"{header_timestamp}.{payload_bytes.decode('utf-8')}"
178181
signature = hmac.new(
179-
"test_secret".encode("utf-8"), payload_bytes, hashlib.sha256
182+
"test_secret".encode("utf-8"), signed_message.encode("utf-8"), hashlib.sha256
180183
).hexdigest()
181184

182185
result = await self.client.handle_webhook(
183-
payload, task_type="create_media_buy", operation_id="op_333", signature=signature
186+
payload,
187+
task_type="create_media_buy",
188+
operation_id="op_333",
189+
signature=signature,
190+
timestamp=header_timestamp,
184191
)
185192

186193
assert result.status == TaskStatus.COMPLETED
@@ -206,6 +213,7 @@ async def test_mcp_webhook_signature_verification_invalid(self):
206213
task_type="create_media_buy",
207214
operation_id="op_444",
208215
signature="invalid_signature",
216+
timestamp="2025-01-15T10:00:00Z",
209217
)
210218

211219
@pytest.mark.asyncio

0 commit comments

Comments
 (0)