feat(rest/nodejs): sign order-event webhooks (RFC 9421) - #179
Open
vishkaty wants to merge 1 commit into
Open
Conversation
Order-event webhook deliveries carried no signature: no UCP-Agent, no Signature, no Signature-Input, no Content-Digest, so a platform had no way to verify a delivery against the business, violating order.md (Webhook Signature Verification). Delivery retry landed in Universal-Commerce-Protocol#175; this adds the signing half, mirroring the Python reference (Universal-Commerce-Protocol#169) via the existing RFC 9421 signer from Universal-Commerce-Protocol#162. Every delivery is now signed as the business: UCP-Agent names this server's profile, and Content-Digest, Signature-Input, and Signature cover the exact raw body bytes, with the Standard Webhooks event headers (webhook-id, webhook-timestamp) and x-event-type bound into the signed set through a new extraComponents parameter on signRequest. An Idempotency-Key equal to the Webhook-Id joins each delivery so the signed-component table's state-changing-POST requirement holds and retried events deduplicate. Each retry attempt from the Universal-Commerce-Protocol#175 loop is re-signed with a fresh created timestamp. Redirects are not followed (redirect manual) so a 3xx cannot silently re-POST to a URL the signature does not cover, matching the Python httpx semantics. The matching public JWK is published in the served profile's signing_keys[] and mirrored into ucp.keys[]; the kid is the RFC 7638 JWK thumbprint. WEBHOOK_SIGNING_KEY loads an operator PEM (EC P-256 or Ed25519), validated at startup so a misconfigured key aborts the boot; unset, an ephemeral demo key is generated. 16 new tests including a cross-checked RFC 7638 thumbprint oracle and a clock-stubbed guard proving each retry attempt is freshly signed; full suite 149 passing.
damaz91
approved these changes
Aug 13, 2026
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.
Observed vs expected
Order-event webhook deliveries from the Node reference carry no signature: no
UCP-Agent, noSignature, noSignature-Input, noContent-Digest, and the server publishes no signing key. order.md (Webhook Signature Verification) requires webhook payloads to be signed by the business with a key published in its profilesigning_keys[], with those four headers on every delivery. Delivery retry landed in #175; this PR adds the signing half, mirroring what the Python reference gained in #169, built on the RFC 9421 signer this server already has from #162.Fix
src/utils/webhook_signer.ts: the business signing identity.WEBHOOK_SIGNING_KEYloads an operator PEM (EC P-256 or Ed25519; anything else fails the boot loudly at startup); unset, an ephemeral P-256 demo key is generated so the server signs correctly out of the box with no key material in the repo. The kid is the RFC 7638 JWK thumbprint.signRequestgains anextraComponentsparameter: caller-requested headers join the signed set when present, never duplicated. Webhook deliveries bindwebhook-id,webhook-timestamp, andx-event-type, so the event identity a platform dedupes and dispatches on cannot be altered in transit.notifyWebhookkeeps the fix(rest/nodejs): retry transient webhook failures #175 retry loop as merged and layers signing onto it: the order body is serialized exactly once and those same bytes are digested and sent; every delivery carriesUCP-Agent(profile="<origin>/.well-known/ucp"),Signature,Signature-Input,Content-Digest, and anIdempotency-Keyequal to theWebhook-Id(the signed-component table requires idempotency-key on a state-changing POST, and retried events deduplicate under it). Each retry attempt is re-signed with a freshcreated;redirect: "manual"is set on the delivery fetch so a 3xx cannot transparently re-POST to a URL the signature does not cover (the Python server's httpx client does not follow redirects either; a 3xx lands in the existing permanent-rejection branch).signing_keys[]and mirrored intoucp.keys[].src/index.ts: a misconfiguredWEBHOOK_SIGNING_KEYaborts the boot rather than silently degrading every delivery.Verification
@query); every retry attempt signed with stableWebhook-Id/Idempotency-Key; a clock-stubbed guard proving each retry attempt is freshly signed (a mutation hoisting the signing out of the fix(rest/nodejs): retry transient webhook failures #175 loop fails exactly that test); key lifecycle (ephemeral singleton, PEM loading for both key types, deterministic RFC 7638 kid against an independent oracle, unreadable and unsupported-key rejection); profile publication in both locations.ucp_signing.verify_request) against the JWK the Node profile publishes, and a tampered body is rejected.Notes for reviewers
signatureConfig-style seam:WEBHOOK_SIGNING_KEY(README section included).