fix(rest/python): sign order-event webhooks (RFC 9421) and retry failed deliveries - #169
Open
vishkaty wants to merge 1 commit into
Open
Conversation
…ed deliveries Observed: checkout_service._notify_webhook POSTed the order entity with a single unsigned httpx request — no UCP-Agent, Signature, Signature-Input, or Content-Digest header — and never inspected the response, so a 5xx or connection failure silently dropped the event. Expected (2026-04-08 order.md, "Webhook Signature Verification" and "Guidelines — Business"): webhook payloads MUST be signed by the business with all four headers on every delivery, the sha-256 Content-Digest MUST cover the raw body bytes (signatures.md / RFC 9530), and failed deliveries MUST be retried. Signing (reuses the RFC 9421 machinery merged in Universal-Commerce-Protocol#122; no new crypto): * _notify_webhook now serializes the order exactly once and signs those same bytes via ucp_signing.sign_request, sending UCP-Agent (profile="<base_url>/.well-known/ucp" — the business's own profile), Signature, Signature-Input, and Content-Digest on every delivery. The signed components are the signatures.md request-signing table (@method, @authority, @path, @query when the platform URL carries one, content-digest, content-type, idempotency-key, ucp-agent) plus webhook-id, webhook-timestamp, and x-event-type — every header this server adds to a delivery is signed, binding the event identity into the signature. sign_request gains an opt-in extra_components parameter for those; RFC 9421 permits covering any component beyond the required floor. * Webhook POSTs are state-changing requests, so they now carry an Idempotency-Key (the event id): the signed-component table requires it, and retries reuse it so receivers can deduplicate. * New webhook_signer module owns the business signing identity: a --webhook_signing_key flag loads an operator PEM (EC P-256 or Ed25519); unset, an ephemeral ES256 demo key is generated at startup so the sample signs correctly out of the box without committing a private key. The kid is the RFC 7638 JWK thumbprint, stable across restarts of the same key. The key is loaded and validated during app startup (config.lifespan): a misconfigured key path aborts the boot loudly instead of degrading every delivery into a swallowed per-webhook error. * The served profile now publishes the public JWK in top-level signing_keys[] (discovery profile_schema.json places it as a sibling of `ucp`; order.md's platform verification reads it there) and mirrors it into ucp.keys[], the JWK Set this server's own verifier resolves. Retry (bounded, config-driven): * A transport error or 5xx now retries with exponential backoff (--webhook_delivery_attempts, default 3 total; --webhook_retry_backoff_seconds, default 0.5s, doubling). A 4xx is a permanent rejection and is not retried. Each attempt is re-signed so `created` reflects its send time; Webhook-Id/Webhook-Timestamp/Idempotency-Key stay fixed across attempts. The awaited-inline invocation shape and the never-raise contract of _notify_webhook are preserved. Tests (integration_test.py, webhook_signer_test.py, ucp_signing_test.py): deliveries carry all four signature headers; the Content-Digest matches the raw wire bytes and the signature round-trip verifies through ucp_signing.verify_request against the profile-published key (and a tampered body fails); the signed set covers the required floor plus the webhook and event-type headers; a 500-then-200 receiver sees a retried, deduplicable delivery; retries are bounded and 4xx is not retried; a bad --webhook_signing_key path fails at startup; key loading, kid determinism, and profile publication are pinned. Existing Universal-Commerce-Protocol#122 request-verification and Universal-Commerce-Protocol#140/Universal-Commerce-Protocol#146 webhook-body tests are unchanged and still pass (160 passed). This also makes the conformance#73 webhook signing/retry checks pass against this server (verified: webhook.signed_rfc9421_verifies, webhook.signed_components, webhook.query_component_signed, webhook.idempotency_key_signed, webhook.ucp_agent_signed, webhook.ucp_agent_header, and webhook.retry_failed_delivery all clean-pass; they deviate on the unfixed server). Fixes Universal-Commerce-Protocol#163
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #163.
Adds RFC 9421 signing and delivery retry to order-event webhooks in the Python reference server, per the 2026-04-08 order.md MUSTs — the fix @carolinerg1 welcomed on #163. This is what conformance#73's webhook signing checks expect.
What was missing
_notify_webhookposted the order body with no signature and no retry: order.md ("Webhook Signature Verification") requires deliveries to carryUCP-Agent,Signature,Signature-Input, andContent-Digestand to be signed; order.md also requires failed deliveries to be retried.Signing
Reuses the RFC 9421 machinery already in the server from #122 (no new crypto). The order is serialized once and those exact bytes go on the wire, so
Content-Digest(sha-256 over the raw body, per signatures.md) covers what is sent. The server signs as the business:UCP-Agent: profile="{base_url}/.well-known/ucp". Signed component set = the required floor (@method/@authority/@path/@query/content-digest/content-type/idempotency-key/ucp-agent, per the signatures.md table —idempotency-keyis required for POST, and it makes retries deduplicable) pluswebhook-id,webhook-timestamp, andx-event-typeso every header the delivery carries is covered. The signing JWK is published at/.well-known/ucp.Key config:
--webhook_signing_keyloads an operator PEM (EC P-256 / Ed25519), validated at startup so a bad path fails fast; the default is an ephemeral ES256 demo key (there is no persisted demo key upstream anddetect-private-keyforbids committing one).kidis the RFC 7638 thumbprint.Retry
3 attempts by default (
--webhook_delivery_attempts), exponential backoff (--webhook_retry_backoff_seconds, 0.5s doubling), retrying on transport error or 5xx; a 4xx is permanent. Webhook-Id / Timestamp / Idempotency-Key stay fixed across attempts (so a deduplicating receiver treats them as the same event);createdis re-stamped per attempt. Delivery never raises into the order flow; worst-case added latency is bounded.Key-discovery note for reviewers
order.md and the profile schema place signing keys at top-level
signing_keys[]; this server's own verifier (added in #122, following ucp#566) readsucp.keys[]. The profile is published with the key in BOTH locations (both are schema-valid — verified against the pinned discovery schema), so either verifier convention resolves it. Happy to drop to one if you have a preference.Verification
uv run pytest: 160 passed (from 133; fix(rest/python): verify request signatures per RFC 9421 instead of accepting everything #122 signature and fix(rest/python): deliver the order object as the webhook body #140/fix(rest/nodejs): deliver the order object as the webhook body #146 webhook-body tests untouched and green).