Skip to content

Commit 2a76d9f

Browse files
committed
deploy: 339548b
1 parent 9b44e61 commit 2a76d9f

19 files changed

Lines changed: 2737 additions & 363 deletions

client.html

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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 &#34;{timestamp}.{json_payload}&#34;
3049-
2. Uses raw HTTP body bytes when available (preserves sender&#39;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 &#34;sha256=&#34; prefix stripped)
3048+
1. Constructs message as &#34;{timestamp}.{raw_http_body_bytes}&#34;
3049+
2. HMAC-SHA256 signs with the shared secret
3050+
3. Compares against the provided signature (with &#34;sha256=&#34; 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&#39;t
3062+
supply ``raw_body``. Capture it from your framework&#39;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 &#34;sha256=&#34; 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
&#34;&#34;&#34;
30653079
if not self.webhook_secret:
30663080
logger.warning(&#34;Webhook signature verification skipped: no webhook_secret configured&#34;)
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+
&#34;Webhook signature verification failed: raw_body is required. &#34;
3090+
&#34;Capture the raw HTTP body pre-parse and pass it to &#34;
3091+
&#34;handle_webhook(raw_body=...). See &#34;
3092+
&#34;https://adcontextprotocol.org/docs/building/implementation/security&#34;
3093+
&#34;#legacy-hmac-sha256-fallback-deprecated-removed-in-40&#34;
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(&#34;sha256=&#34;):
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(&#34;utf-8&#34;) if isinstance(raw_body, bytes) else raw_body
3086-
else:
3087-
payload_str = json.dumps(payload)
3110+
payload_str = raw_body.decode(&#34;utf-8&#34;) if isinstance(raw_body, bytes) else raw_body
30883111

30893112
# Construct signed message: timestamp.payload
30903113
signed_message = f&#34;{timestamp}.{payload_str}&#34;

0 commit comments

Comments
 (0)