fix: sign webhook HMACs over the exact transmitted bytes (#1441)#1605
fix: sign webhook HMACs over the exact transmitted bytes (#1441)#1605pmezzich wants to merge 3 commits into
Conversation
The AdCP legacy-HMAC contract is byte-equality: the signature covers
{unix_timestamp}.{raw_http_body}. All three senders signed one serialization
and let the HTTP client re-serialize another (spaced vs compact separators,
sorted vs insertion key order, ensure_ascii vs UTF-8, ISO vs unix timestamp)
— every emitted signature failed raw-body verification against a
spec-compliant receiver.
Fix: consolidate all three senders onto adcp.sign_legacy_webhook (in the
pinned SDK since prebid#1399), which returns (signed_headers, body_bytes); each
sender now serializes ONCE before its retry loop and POSTs those exact bytes
(content=/data=, never json=).
- protocol_webhook_service (buyer push notifications, PRIMARY): lib signer +
single serialization also reused for payload-size metrics (was a third
json.dumps).
- webhook_delivery_service (delivery reports): deleted the local
_generate_hmac_signature (sorted+ISO); X-ADCP-Timestamp switches ISO→unix
seconds per spec and the signature gains the sha256= prefix; BR-UC-004
feature wording updated accordingly.
- webhook_delivery (generic retry sender, latent — no live signed callers):
lib signer; drops the non-spec X-Webhook-* header names. Removed
WebhookAuthenticator.sign_payload (same sign-different-bytes defect);
verify_signature stays (raw-body, spec-shaped).
- webhook_verification (receiver reference): raw str/bytes body is the
contract (dict path kept only as deprecated compact-form convenience —
the old sort_keys dict path mirrored the sender bug); accepts unix
timestamps (ISO kept for back-compat).
Tests now grade the wire, not a re-serialization:
- NEW tests/integration/test_webhook_wire_signature.py: real requests
transport into a raw-capturing localhost receiver; HMAC recomputed over
the RECEIVED bytes for the push-notification and generic senders, with
non-ASCII / float / unsorted-key payloads (the exact divergence traps);
both in-repo verifiers agree on the raw body.
- Byte-equality oracles replace re-serialization in the unit/behavioral/BDD
HMAC assertions (mock kwargs now carry data=/content= bytes); BDD
hmac-computation step recomputes over the raw sent body.
- Header asserts made case-insensitive (X-AdCP-* casing from the lib).
Signature values change for identical payloads — deliberately: current
signatures are already rejected by any spec-compliant verifier, so there is
no working behavior to preserve. Receivers that copied the old
verify-by-re-serialization pattern must switch to raw-body verification.
The obligation-test-quality guard (correctly) rejected the rewritten UC-004-WH-07 test for exercising only the library signer. It now drives send_delivery_webhook -> _deliver_with_backoff with a mocked transport and recomputes the HMAC over the literal bytes handed to httpx — production path plus the byte-equality oracle.
Review — webhook HMAC byte-equality (#1441)This is a well-executed, spec-correct fix. The It can't merge yet (conflicts with current BLOCKER — merge stateThe branch is CONFLICTING with current
SHOULD-FIX (in scope)
NIT
Pre-existing / out of scope (we'll file these separately — not blocking this PR)
Re-review after the rebase + the in-scope items. |
Summary
Closes #1441.
The AdCP legacy-HMAC contract is byte-equality: the signature covers
{unix_timestamp}.{raw_http_body}. All three webhook senders signed one serialization and let the HTTP client re-serialize another (spaced vs compact separators, sorted vs insertion key order,ensure_asciivs UTF-8, ISO vs unix timestamp) — so every emitted signature failed raw-body verification against a spec-compliant receiver. Sender and the in-repo receiver reference only agreed with each other because both re-serialized the same wrong way.Fix
All three senders consolidate onto
adcp.sign_legacy_webhook(in the pinned SDK since #1399), which returns(signed_headers, body_bytes). Each sender serializes once, before its retry loop, and POSTs those exact bytes (content=/data=, neverjson=):json.dumps)._generate_hmac_signature(sorted + ISO);X-ADCP-Timestampswitches ISO→unix seconds per spec and the signature gains thesha256=prefix. BR-UC-004 feature wording updated accordingly.X-Webhook-*header names.WebhookAuthenticator.sign_payloadis removed (same sign-different-bytes defect);verify_signaturestays (raw-body, spec-shaped).sort_keysdict path mirrored the sender bug); accepts unix timestamps (ISO kept for back-compat).Tests grade the wire, not a re-serialization
tests/integration/test_webhook_wire_signature.py: realrequeststransport into a raw-capturing localhost receiver — HMAC recomputed over the received socket bytes for both the push-notification and generic senders, with non-ASCII / float / unsorted-key payloads (the exact divergence traps). Both in-repo verifiers must agree on the raw body.data=/content=bytes); the BDD HMAC step recomputes over the raw sent body; header asserts are case-insensitive (X-AdCP-*casing from the lib).Execution proof
-rxX(19.8s — real execution).Compatibility note
Signature values change for identical payloads — deliberately: current signatures are already rejected by any spec-compliant verifier, so there is no working behavior to preserve. Receivers that copied the old verify-by-re-serialization pattern must switch to raw-body verification (the receiver reference in this PR shows the shape). Suggested for release notes.