@@ -3045,27 +3045,55 @@ <h2 class="section-title" id="header-classes">Classes</h2>
30453045 Verify HMAC-SHA256 signature of webhook payload.
30463046
30473047 The verification algorithm matches get_adcp_signed_headers_for_webhook:
3048- 1. Constructs message as "{timestamp}.{json_payload}"
3049- 2. Uses raw HTTP body bytes when available (preserves sender's serialization)
3050- 3. Falls back to json.dumps() if raw_body not provided
3051- 4. HMAC-SHA256 signs with the shared secret
3052- 5. Compares against the provided signature (with "sha256=" prefix stripped)
3048+ 1. Constructs message as "{timestamp}.{raw_http_body_bytes}"
3049+ 2. HMAC-SHA256 signs with the shared secret
3050+ 3. Compares against the provided signature (with "sha256=" prefix stripped)
3051+ using constant-time comparison.
3052+
3053+ Per AdCP spec (adcontextprotocol/adcp#2478): verifiers MUST use the raw
3054+ HTTP body bytes captured before any JSON parse; they SHOULD NOT
3055+ re-serialize a parsed payload to reconstruct the signed bytes, because
3056+ re-serialization silently fails against signers whose output differs in
3057+ separator choice, key order, unicode escapes, or number formatting —
3058+ masking signer bugs the verifier should surface. Callers that genuinely
3059+ cannot capture raw bytes MUST fail closed.
3060+
3061+ This implementation therefore rejects verification attempts that don't
3062+ supply ``raw_body``. Capture it from your framework's pre-parse hook
3063+ (FastAPI ``Request.body()``, Flask ``request.get_data(cache=True)``,
3064+ aiohttp ``Request.read()``, Express ``express.raw()``).
30533065
30543066 Args:
3055- payload: Webhook payload dict (used as fallback if raw_body not provided)
3067+ payload: Parsed webhook payload dict (not used for signing; kept
3068+ for signature parity with callers, but verification derives
3069+ solely from ``raw_body``).
30563070 signature: Signature to verify (with or without "sha256=" prefix)
30573071 timestamp: Unix timestamp in seconds from X-AdCP-Timestamp header
3058- raw_body: Raw HTTP request body bytes. When provided, used directly
3059- for signature verification to avoid cross-language serialization
3060- mismatches. Strongly recommended for production use.
3072+ raw_body: Raw HTTP request body bytes as received on the wire,
3073+ captured before any JSON parse. Required.
30613074
30623075 Returns:
3063- True if signature is valid, False otherwise
3076+ True if signature is valid, False otherwise (including when
3077+ ``raw_body`` is missing — fails closed per spec).
30643078 """
30653079 if not self.webhook_secret:
30663080 logger.warning("Webhook signature verification skipped: no webhook_secret configured")
30673081 return True
30683082
3083+ # Fail closed per adcontextprotocol/adcp#2478: verifiers that cannot
3084+ # capture raw bytes MUST reject, surfacing the infrastructure gap
3085+ # rather than silently reconstructing a signed body that may diverge
3086+ # from the bytes the signer actually hashed.
3087+ if raw_body is None:
3088+ logger.error(
3089+ "Webhook signature verification failed: raw_body is required. "
3090+ "Capture the raw HTTP body pre-parse and pass it to "
3091+ "handle_webhook(raw_body=...). See "
3092+ "https://adcontextprotocol.org/docs/building/implementation/security"
3093+ "#legacy-hmac-sha256-fallback-deprecated-removed-in-40"
3094+ )
3095+ return False
3096+
30693097 # Reject stale or future timestamps to prevent replay attacks
30703098 try:
30713099 ts = int(timestamp)
@@ -3079,12 +3107,7 @@ <h2 class="section-title" id="header-classes">Classes</h2>
30793107 if signature.startswith("sha256="):
30803108 signature = signature[7:]
30813109
3082- # Use raw body if available (avoids cross-language serialization mismatches),
3083- # otherwise fall back to json.dumps() for backward compatibility
3084- if raw_body is not None:
3085- payload_str = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body
3086- else:
3087- payload_str = json.dumps(payload)
3110+ payload_str = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body
30883111
30893112 # Construct signed message: timestamp.payload
30903113 signed_message = f"{timestamp}.{payload_str}"
0 commit comments