Skip to content

feat(rest/nodejs): sign order-event webhooks (RFC 9421) - #179

Open
vishkaty wants to merge 1 commit into
Universal-Commerce-Protocol:mainfrom
vishkaty:fix/nodejs-webhook-signing
Open

feat(rest/nodejs): sign order-event webhooks (RFC 9421)#179
vishkaty wants to merge 1 commit into
Universal-Commerce-Protocol:mainfrom
vishkaty:fix/nodejs-webhook-signing

Conversation

@vishkaty

Copy link
Copy Markdown
Contributor

Observed vs expected

Order-event webhook deliveries from the Node reference carry no signature: no UCP-Agent, no Signature, no Signature-Input, no Content-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 profile signing_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

  • New src/utils/webhook_signer.ts: the business signing identity. WEBHOOK_SIGNING_KEY loads 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.
  • signRequest gains an extraComponents parameter: caller-requested headers join the signed set when present, never duplicated. Webhook deliveries bind webhook-id, webhook-timestamp, and x-event-type, so the event identity a platform dedupes and dispatches on cannot be altered in transit.
  • notifyWebhook keeps 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 carries UCP-Agent (profile="<origin>/.well-known/ucp"), Signature, Signature-Input, Content-Digest, and an Idempotency-Key equal to the Webhook-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 fresh created; 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).
  • The public JWK is published in the served profile's top-level signing_keys[] and mirrored into ucp.keys[].
  • Startup validation in src/index.ts: a misconfigured WEBHOOK_SIGNING_KEY aborts the boot rather than silently degrading every delivery.

Verification

  • TDD throughout: the tests were written first and failed for the right reasons before the implementation existed.
  • 17 new tests: the four required headers on every delivery; the platform-side verification loop closed over the raw wire bytes (Content-Digest match plus signature verification against the published JWK, with a tampered-body kill direction); signed-component coverage of the signatures.md table plus the webhook headers (including @query); every retry attempt signed with stable Webhook-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.
  • A malformed webhook URL (platform-controlled data) degrades to a logged delivery failure, never an exception in the order flow — regression-tested, and the outer catch is mutation-proven (removing it fails exactly that test).
  • Full suite: 150 passing (all pre-existing tests, including the fix(rest/nodejs): retry transient webhook failures #175 retry tests, untouched in what they assert), tsc clean, pinned pre-commit clean.
  • Cross-language proof: a live delivery captured on the wire from a booted server verifies through the Python reference's own verifier (ucp_signing.verify_request) against the JWK the Node profile publishes, and a tampered body is rejected.
  • The full official conformance suite (17 modules) runs green against the signed server.

Notes for reviewers

  • Scope is deliberately signing-only: fix(rest/nodejs): retry transient webhook failures #175's retry semantics (attempt count, backoff, permanent-rejection rules) are kept exactly as merged; the only functional change to the loop is that each attempt is signed and redirects are surfaced rather than followed.
  • Env var mirrors the Python server's flag via the existing signatureConfig-style seam: WEBHOOK_SIGNING_KEY (README section included).

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 damaz91 added status:needs-triage Signal that the PR is ready for human triage status:under-review and removed status:needs-triage Signal that the PR is ready for human triage labels Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants