From 88a4cc03c214fd3e15ea424f5df1c62e1e0c41a9 Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Wed, 12 Aug 2026 13:54:16 -0400 Subject: [PATCH] feat(rest/nodejs): sign order-event webhooks (RFC 9421) 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 #175; this adds the signing half, mirroring the Python reference (#169) via the existing RFC 9421 signer from #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 #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. --- rest/nodejs/README.md | 19 + rest/nodejs/src/api/checkout.ts | 106 +++-- rest/nodejs/src/api/discovery.ts | 14 + rest/nodejs/src/api/testing.ts | 2 +- rest/nodejs/src/index.ts | 6 + rest/nodejs/src/utils/config.ts | 12 + rest/nodejs/src/utils/signature.ts | 17 +- rest/nodejs/src/utils/webhook_signer.ts | 104 +++++ rest/nodejs/test/fulfillment.test.ts | 5 +- rest/nodejs/test/signing.test.ts | 100 +++++ rest/nodejs/test/webhook.test.ts | 15 +- rest/nodejs/test/webhook_signing.test.ts | 549 +++++++++++++++++++++++ 12 files changed, 911 insertions(+), 38 deletions(-) create mode 100644 rest/nodejs/src/utils/webhook_signer.ts create mode 100644 rest/nodejs/test/webhook_signing.test.ts diff --git a/rest/nodejs/README.md b/rest/nodejs/README.md index 33e4e6c..26e7d1e 100644 --- a/rest/nodejs/README.md +++ b/rest/nodejs/README.md @@ -115,6 +115,25 @@ Each verified request logs at `/.well-known/ucp` stays unverified: it is the public document a platform must read before it can sign anything. +## Webhook Signing + +Outbound order-event webhooks are signed as the business, per the +specification's `order.md` (Webhook Signature Verification): every delivery +carries `UCP-Agent` (this server's profile URL), `Signature`, +`Signature-Input`, and a `Content-Digest` over the exact raw body bytes. The +signed components cover the full request-signing table (`@method`, +`@authority`, `@path`, `@query` when the platform URL has one, +`content-digest`, `content-type`, `idempotency-key`, `ucp-agent`) plus the +Standard Webhooks event headers (`webhook-id`, `webhook-timestamp`) and +`x-event-type`. The matching public JWK is published in the served profile's +`signing_keys[]` (and mirrored into `ucp.keys[]`) so platforms can verify. +Retried deliveries reuse the same `Webhook-Id` and `Idempotency-Key` and are +re-signed per attempt. + +| Env var | Default | Effect | +| --------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `WEBHOOK_SIGNING_KEY` | (ephemeral) | Path to a PEM private key (EC P-256 or Ed25519) to sign webhooks with. When unset, an ephemeral demo key is generated at startup and published in the profile. | + ## Running Conformance Tests To verify that this server implementation complies with the UCP specifications, diff --git a/rest/nodejs/src/api/checkout.ts b/rest/nodejs/src/api/checkout.ts index cbe00fd..3eb1dbe 100644 --- a/rest/nodejs/src/api/checkout.ts +++ b/rest/nodejs/src/api/checkout.ts @@ -64,6 +64,8 @@ import { UcpError, ucpErrorResponse, } from "../utils/ucp_error"; +import { signRequest } from "../utils/signature"; +import { signingKey } from "../utils/webhook_signer"; import { type IdParamContext } from "../utils/validation"; // zCompleteCheckoutRequest and CompleteCheckoutRequest are now imported from SDK models @@ -165,7 +167,8 @@ export class CheckoutService { */ private async notifyWebhook( checkout: ExtendedCheckoutResponse, - eventType: string + eventType: string, + baseUrl: string ): Promise { if (!checkout.platform?.webhook_url) { return; @@ -184,48 +187,85 @@ export class CheckoutService { } const webhookUrl = checkout.platform.webhook_url; - const body = JSON.stringify(orderData); + // Serialize exactly once: the signed Content-Digest and the wire body + // must be the same bytes (order.md, Webhook Signature Verification). + const body = Buffer.from(JSON.stringify(orderData), "utf-8"); + const webhookId = uuidv4(); const headers = { "Content-Type": "application/json", "X-Event-Type": eventType, - "Webhook-Id": uuidv4(), + "Webhook-Id": webhookId, "Webhook-Timestamp": Math.floor(Date.now() / 1000).toString(), + // A webhook POST is a state-changing request, so the signed-component + // table requires idempotency-key (signatures.md). The event id doubles + // as the key: retries carry the same value, letting the platform + // deduplicate redelivered events. + "Idempotency-Key": webhookId, + // Sign AS this business: the profile URL platforms fetch our + // signing_keys[] from (order.md requires UCP-Agent on deliveries). + "UCP-Agent": `profile="${baseUrl}/.well-known/ucp"`, }; const maxAttempts = 3; - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - try { - const response = await fetch(webhookUrl, { - method: "POST", + // Delivery failures never propagate into the order flow: a webhook URL + // the signer cannot handle, or a signing-identity error, degrades to a + // logged failure exactly like an undeliverable receiver (the Python + // reference's outer except Exception behaves the same way). + try { + const { privateKey, kid } = signingKey(); + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + // Re-sign per attempt so the signature's `created` timestamp reflects + // the actual send time of each delivery attempt. + const additions = signRequest( + privateKey, + kid, + "POST", + webhookUrl, headers, body, - }); - if (response.ok) { - return; + undefined, + ["webhook-id", "webhook-timestamp", "x-event-type"] + ); + try { + const response = await fetch(webhookUrl, { + method: "POST", + headers: { ...headers, ...additions }, + body, + // A redirect transparently followed would re-POST to a URL the + // signature does not cover; surface 3xx as a response instead + // (the Python reference's httpx client does not follow either). + redirect: "manual", + }); + if (response.ok) { + return; + } + if (response.status < 500) { + console.error( + `Webhook at ${webhookUrl} rejected delivery with status ${response.status}` + ); + return; + } + } catch (e) { + if (attempt === maxAttempts) { + console.error(`Failed to notify webhook at ${webhookUrl}`, e); + return; + } } - if (response.status < 500) { - console.error( - `Webhook at ${webhookUrl} rejected delivery with status ${response.status}` + + if (attempt < maxAttempts) { + await new Promise((resolve) => + setTimeout(resolve, 100 * 2 ** (attempt - 1)) ); - return; - } - } catch (e) { - if (attempt === maxAttempts) { - console.error(`Failed to notify webhook at ${webhookUrl}`, e); - return; } } - if (attempt < maxAttempts) { - await new Promise((resolve) => - setTimeout(resolve, 100 * 2 ** (attempt - 1)) - ); - } + console.error( + `Failed to notify webhook at ${webhookUrl} after ${maxAttempts} attempts` + ); + } catch (e) { + console.error(`Failed to notify webhook at ${webhookUrl}`, e); } - - console.error( - `Failed to notify webhook at ${webhookUrl} after ${maxAttempts} attempts` - ); } private addressesMatch( @@ -1108,7 +1148,11 @@ export class CheckoutService { saveCheckout(id, checkout.status, checkout); // Notify webhook - await this.notifyWebhook(checkout, "order_placed"); + await this.notifyWebhook( + checkout, + "order_placed", + new URL(c.req.url).origin + ); if (idempotencyKey) { saveIdempotencyRecord( @@ -1185,7 +1229,7 @@ export class CheckoutService { return c.json(checkout, 200); }; - shipOrder = async (orderId: string): Promise => { + shipOrder = async (orderId: string, baseUrl: string): Promise => { const order = getOrder(orderId); if (!order) { throw new Error("Order not found"); @@ -1209,7 +1253,7 @@ export class CheckoutService { const checkout = getCheckoutSession(order.checkout_id); if (checkout) { - await this.notifyWebhook(checkout, "order_shipped"); + await this.notifyWebhook(checkout, "order_shipped", baseUrl); } }; } diff --git a/rest/nodejs/src/api/discovery.ts b/rest/nodejs/src/api/discovery.ts index 050998e..1518fdd 100644 --- a/rest/nodejs/src/api/discovery.ts +++ b/rest/nodejs/src/api/discovery.ts @@ -14,6 +14,8 @@ import { type Context } from "hono"; import { UCP_VERSION } from "../utils/config"; +import { type Jwk } from "../utils/signature"; +import { publicJwk } from "../utils/webhook_signer"; // overview.md (Discovery) requires the profile response to carry a // `Cache-Control` header with `public` and a `max-age` of at least 60 seconds, @@ -51,6 +53,7 @@ type UcpDiscoveryMetadata = { services: Record; capabilities: Record; payment_handlers: Record; + keys: Jwk[]; }; /** @@ -121,8 +124,18 @@ export class DiscoveryService { ], }; + // Publish the webhook-signing public key so platforms can verify our + // order-event deliveries (order.md, Webhook Signature Verification / + // signatures.md, Key Discovery). The discovery profile schema places + // signing_keys[] at the top level of the served document (a sibling of + // `ucp`); it is mirrored into ucp.keys[], the RFC 7517 JWK Set this + // server's own verifier (signature.ts extractKeys) resolves, so both + // discovery conventions find the key. + const webhookJwk = publicJwk(); + const ucp = { version: this.ucpVersion, + keys: [webhookJwk], services: { "dev.ucp.shopping": [ { @@ -203,6 +216,7 @@ export class DiscoveryService { const discoveryProfile = { ucp, + signing_keys: [webhookJwk], payment: { handlers: [ ...payment_handlers["com.shopify.shop_pay"], diff --git a/rest/nodejs/src/api/testing.ts b/rest/nodejs/src/api/testing.ts index 872308b..cf2915b 100644 --- a/rest/nodejs/src/api/testing.ts +++ b/rest/nodejs/src/api/testing.ts @@ -29,7 +29,7 @@ export class TestingService { const { id } = c.req.valid("param"); try { - await this.checkoutService.shipOrder(id); + await this.checkoutService.shipOrder(id, new URL(c.req.url).origin); return c.json({ status: "shipped" }, 200); } catch (e: any) { if (e.message === "Order not found") { diff --git a/rest/nodejs/src/index.ts b/rest/nodejs/src/index.ts index b04c892..902d5be 100644 --- a/rest/nodejs/src/index.ts +++ b/rest/nodejs/src/index.ts @@ -31,11 +31,17 @@ import { } from "./models"; import { verifySignature } from "./utils/signature"; import { IdParamSchema, prettyValidation } from "./utils/validation"; +import { signingKey } from "./utils/webhook_signer"; const app = new Hono(); initDbs("databases/products.db", "databases/transactions.db"); +// Load (and thereby validate) the webhook-signing identity up front: a +// misconfigured WEBHOOK_SIGNING_KEY must abort the boot loudly, not surface +// as a swallowed per-delivery error that silently degrades every webhook. +signingKey(); + const checkoutService = new CheckoutService(); const orderService = new OrderService(); const discoveryService = new DiscoveryService(); diff --git a/rest/nodejs/src/utils/config.ts b/rest/nodejs/src/utils/config.ts index e52c612..cca0ac2 100644 --- a/rest/nodejs/src/utils/config.ts +++ b/rest/nodejs/src/utils/config.ts @@ -23,3 +23,15 @@ export const signatureConfig = { requireSignatures: process.env.REQUIRE_SIGNATURES === "true", allowInsecureProfileUrls: process.env.ALLOW_INSECURE_PROFILE_URLS === "true", }; + +// The business webhook-signing identity (order.md, Webhook Signature +// Verification), sourced from the environment like signatureConfig above and +// mutable so tests can adjust it, mirroring the Python server's config.FLAGS: +// +// * WEBHOOK_SIGNING_KEY: path to a PEM private key (EC P-256 or Ed25519) +// used to sign outbound order-event webhooks as this business. When unset, +// an ephemeral demo key is generated at startup; either way the public JWK +// is published in the served profile's signing_keys[]. +export const webhookConfig = { + signingKeyPath: process.env.WEBHOOK_SIGNING_KEY, +}; diff --git a/rest/nodejs/src/utils/signature.ts b/rest/nodejs/src/utils/signature.ts index 7aed7cc..6868afe 100644 --- a/rest/nodejs/src/utils/signature.ts +++ b/rest/nodejs/src/utils/signature.ts @@ -464,8 +464,15 @@ function rawSign(privateKey: KeyObject, base: Buffer): Buffer { } // Signs a UCP request and returns the headers to add: Content-Digest (when a -// body is present), Signature-Input, and Signature covering exactly the UCP -// required-component set. Used by the tests as the client side of the loop. +// body is present), Signature-Input, and Signature covering the UCP +// required-component set. Used by the tests as the client side of the loop, +// and by the webhook sender as this business's signer. +// +// extraComponents lists additional header components to cover beyond the UCP +// required floor (RFC 9421 permits covering any component). Each is covered +// when the header is present on the request, mirroring how the required +// table conditions on header presence. Webhook deliveries use this to bind +// Webhook-Id and Webhook-Timestamp. export function signRequest( privateKey: KeyObject, kid: string, @@ -473,7 +480,8 @@ export function signRequest( url: string, headers: Record, body: Uint8Array, - created?: number + created?: number, + extraComponents: string[] = [] ): Record { const target = new URL(url); const additions: Record = {}; @@ -494,6 +502,9 @@ export function signRequest( const query = target.search.slice(1); const components = requiredComponents(method, query !== "", merged, hasBody); + for (const name of extraComponents) { + if (!components.includes(name) && name in merged) components.push(name); + } const createdAt = created ?? Math.floor(Date.now() / 1000); const rawParams = "(" + diff --git a/rest/nodejs/src/utils/webhook_signer.ts b/rest/nodejs/src/utils/webhook_signer.ts new file mode 100644 index 0000000..ed2704e --- /dev/null +++ b/rest/nodejs/src/utils/webhook_signer.ts @@ -0,0 +1,104 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The business's webhook-signing identity. +// +// Order-event webhooks MUST be signed by the business (order.md, Webhook +// Signature Verification) with a key the business publishes in its profile's +// signing_keys[] so platforms can verify the deliveries. This module owns +// that identity: +// +// * WEBHOOK_SIGNING_KEY loads an operator-provided PEM private key (EC P-256 +// for ES256, or Ed25519). When unset, an ephemeral demo key is generated +// at startup -- the server signs correctly out of the box and no +// private-key file ever lives in the repository. +// * The kid is the RFC 7638 JWK thumbprint of the public key, so the same +// key always republishes under the same identifier across restarts. + +import crypto, { type KeyObject } from "node:crypto"; +import fs from "node:fs"; + +import { webhookConfig } from "./config"; +import { jwkFromPublicKey, type Jwk } from "./signature"; + +// The lazily-created (private key, kid) signing identity for this process. +let signer: { privateKey: KeyObject; kid: string } | null = null; + +// Returns the `{ privateKey, kid }` this business signs webhooks with. +// +// Loaded once per process: from the WEBHOOK_SIGNING_KEY PEM when configured, +// otherwise a fresh ephemeral ES256 demo key. A configured path that cannot +// be read or holds an unsupported key type fails loudly -- silently signing +// with a different identity than the operator configured would be wrong. +export function signingKey(): { privateKey: KeyObject; kid: string } { + if (signer === null) { + const path = webhookConfig.signingKeyPath; + let privateKey: KeyObject; + if (path) { + privateKey = crypto.createPrivateKey(fs.readFileSync(path)); + const keyType = privateKey.asymmetricKeyType; + if (keyType === "ec") { + const curve = privateKey.asymmetricKeyDetails?.namedCurve; + if (curve !== "prime256v1") { + throw new Error( + "WEBHOOK_SIGNING_KEY must be EC P-256 (ES256) or Ed25519; " + + `got EC curve ${curve}` + ); + } + } else if (keyType !== "ed25519") { + throw new Error( + "WEBHOOK_SIGNING_KEY must be EC P-256 (ES256) or Ed25519; " + + `got ${keyType}` + ); + } + } else { + privateKey = crypto.generateKeyPairSync("ec", { + namedCurve: "P-256", + }).privateKey; + } + const publicKey = crypto.createPublicKey(privateKey); + signer = { privateKey, kid: thumbprintKid(publicKey) }; + } + return signer; +} + +// Returns the public JWK to publish in the profile's signing_keys[]. +export function publicJwk(): Jwk { + const { privateKey, kid } = signingKey(); + return jwkFromPublicKey(crypto.createPublicKey(privateKey), kid); +} + +// Discards the cached signing identity (used by tests). +export function resetSigner(): void { + signer = null; +} + +// Derives the RFC 7638 JWK thumbprint (base64url SHA-256) as the kid. +// +// The thumbprint hashes only the REQUIRED public members in lexicographic +// order with no whitespace, so it is deterministic for a given key. +function thumbprintKid(publicKey: KeyObject): string { + const jwk = jwkFromPublicKey(publicKey, ""); + const members: Record = + jwk.kty === "OKP" + ? { crv: jwk.crv, kty: jwk.kty, x: jwk.x } + : { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }; + // An array replacer serializes exactly these members, in this order + // (lexicographic), with no whitespace -- RFC 7638's canonical form. + const canonical = JSON.stringify(members, Object.keys(members).sort()); + return crypto + .createHash("sha256") + .update(canonical, "utf-8") + .digest("base64url"); +} diff --git a/rest/nodejs/test/fulfillment.test.ts b/rest/nodejs/test/fulfillment.test.ts index 259d969..d610de3 100644 --- a/rest/nodejs/test/fulfillment.test.ts +++ b/rest/nodejs/test/fulfillment.test.ts @@ -308,7 +308,10 @@ test("shipping an order includes its line items in the fulfillment event", async const completed = (await completeRes.json()) as Checkout; assert.ok(completed.order?.id, "completion must assign an order id"); - await new CheckoutService().shipOrder(completed.order.id); + await new CheckoutService().shipOrder( + completed.order.id, + "http://localhost:3000" + ); const order = getOrder(completed.order.id); assert.ok(order, "completed order must be persisted"); diff --git a/rest/nodejs/test/signing.test.ts b/rest/nodejs/test/signing.test.ts index 33f9be1..80e17f8 100644 --- a/rest/nodejs/test/signing.test.ts +++ b/rest/nodejs/test/signing.test.ts @@ -439,6 +439,106 @@ test("a signature carrying an alg parameter is rejected (spec MUST NOT)", () => ); }); +/* Caller-requested extra covered components beyond the UCP minimum. + * + * RFC 9421 lets a signer cover any component; the UCP table is the required + * floor. Webhook deliveries use this to bind the Standard Webhooks headers + * (Webhook-Id, Webhook-Timestamp) into the signature. */ + +test("requested present headers join the signed set; the result verifies", () => { + const { publicKey, privateKey } = es256KeyPair(); + const jwk = jwkFromPublicKey(publicKey, "k1"); + const headers = { + "UCP-Agent": 'profile="https://m.example/.well-known/ucp"', + "Idempotency-Key": "evt-1", + "Webhook-Id": "evt-1", + "Webhook-Timestamp": "1700000000", + }; + const body = Buffer.from('{"id":"ord_1"}'); + const additions = signRequest( + privateKey, + "k1", + "POST", + "https://platform.example/hook?token=t", + headers, + body, + undefined, + ["webhook-id", "webhook-timestamp"] + ); + const parsed = parseSignatureInput(additions["Signature-Input"]!); + assert.ok(parsed); + const components = parsed["sig1"]!.components; + assert.ok(components.includes("webhook-id")); + assert.ok(components.includes("webhook-timestamp")); + // The UCP required floor is still fully covered. + for (const required of [ + "@method", + "@authority", + "@path", + "@query", + "content-digest", + "content-type", + "idempotency-key", + "ucp-agent", + ]) { + assert.ok(components.includes(required), `missing ${required}`); + } + const merged: Record = {}; + for (const [k, v] of Object.entries({ ...headers, ...additions })) { + merged[k.toLowerCase()] = v; + } + const keyid = verifyRequest( + "POST", + "platform.example", + "/hook", + "token=t", + merged, + body, + [jwk] + ); + assert.equal(keyid, "k1"); +}); + +test("an extra component whose header is absent is not declared as signed", () => { + const { privateKey } = es256KeyPair(); + const additions = signRequest( + privateKey, + "k1", + "GET", + "https://m.example/p", + {}, + Buffer.alloc(0), + undefined, + ["webhook-id"] + ); + const parsed = parseSignatureInput(additions["Signature-Input"]!); + assert.ok(parsed); + assert.ok(!parsed["sig1"]!.components.includes("webhook-id")); +}); + +test("an extra component already in the required set appears once", () => { + const { privateKey } = es256KeyPair(); + const headers = { + "UCP-Agent": 'profile="https://m.example/.well-known/ucp"', + }; + const additions = signRequest( + privateKey, + "k1", + "GET", + "https://m.example/p", + headers, + Buffer.alloc(0), + undefined, + ["ucp-agent"] + ); + const parsed = parseSignatureInput(additions["Signature-Input"]!); + assert.ok(parsed); + assert.equal( + parsed["sig1"]!.components.filter((c) => c === "ucp-agent").length, + 1 + ); +}); + /* Verify-side normalization must match the signer's canonical base. */ function signedGet(url: string) { diff --git a/rest/nodejs/test/webhook.test.ts b/rest/nodejs/test/webhook.test.ts index 8f8d48f..746b1f0 100644 --- a/rest/nodejs/test/webhook.test.ts +++ b/rest/nodejs/test/webhook.test.ts @@ -96,7 +96,14 @@ async function notifyAndCapture( captured.push({ url: String(url), headers, - body: typeof rawBody === "string" ? JSON.parse(rawBody) : rawBody, + // The delivery body is the raw signed bytes (a Buffer); decode for + // JSON assertions while string bodies keep working. + body: + typeof rawBody === "string" + ? JSON.parse(rawBody) + : rawBody instanceof Uint8Array + ? JSON.parse(Buffer.from(rawBody).toString("utf-8")) + : rawBody, }); const outcome = responseOutcomes[captured.length - 1] ?? 200; if (outcome instanceof Error) { @@ -106,7 +113,11 @@ async function notifyAndCapture( }) as typeof globalThis.fetch; try { - await new CheckoutService()["notifyWebhook"](checkout as never, eventType); + await new CheckoutService()["notifyWebhook"]( + checkout as never, + eventType, + "http://localhost:3000" + ); } finally { globalThis.fetch = originalFetch; } diff --git a/rest/nodejs/test/webhook_signing.test.ts b/rest/nodejs/test/webhook_signing.test.ts new file mode 100644 index 0000000..2148fff --- /dev/null +++ b/rest/nodejs/test/webhook_signing.test.ts @@ -0,0 +1,549 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Webhook signing and delivery retry, mirroring the Python reference server. +// +// order.md (Webhook Signature Verification): webhook payloads MUST be signed +// by the business; every delivery carries UCP-Agent (the business profile +// URL), Signature, Signature-Input, and a Content-Digest over the exact raw +// body bytes, verifiable against the key the business publishes in its +// profile's signing_keys[]. Failed deliveries MUST be retried; retried +// attempts reuse the same Webhook-Id and Idempotency-Key so receivers can +// deduplicate. No private-key files are committed; all key material is +// generated at runtime. + +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { before, test } from "node:test"; +import { isDeepStrictEqual } from "node:util"; + +import { Hono } from "hono"; + +import { CheckoutService } from "../src/api/checkout"; +import { DiscoveryService } from "../src/api/discovery"; +import { initDbs, getTransactionsDb } from "../src/data/db"; +import { webhookConfig } from "../src/utils/config"; +import { + SignatureError, + contentDigestMatches, + jwkFromPublicKey, + parseSignatureInput, + verifyRequest, + type Jwk, +} from "../src/utils/signature"; +import { + publicJwk, + resetSigner, + signingKey, +} from "../src/utils/webhook_signer"; + +const BASE_URL = "http://testserver"; +const WEBHOOK_URL = "https://platform.example/ucp-webhook"; +const ORDER_ID = "order_whsig_test"; +const CHECKOUT_ID = "chk_whsig_test"; + +before(() => { + initDbs(":memory:", ":memory:"); +}); + +function seedOrder() { + getTransactionsDb() + .prepare("INSERT OR REPLACE INTO orders (id, data) VALUES (?, ?)") + .run( + ORDER_ID, + JSON.stringify({ + ucp: { version: "2025-09-24" }, + id: ORDER_ID, + checkout_id: CHECKOUT_ID, + permalink_url: `http://localhost:8080/orders/${ORDER_ID}`, + line_items: [ + { + id: "li_1", + item: { id: "bouquet_roses" }, + quantity: { total: 1, fulfilled: 0 }, + totals: [], + status: "processing", + }, + ], + fulfillment: { expectations: [] }, + currency: "USD", + totals: [{ type: "total", amount: 3500 }], + }) + ); +} + +type CapturedRequest = { + url: string; + headers: Record; + raw: Buffer; +}; + +// Fire notifyWebhook with global fetch stubbed and return the captured POSTs, +// capturing the exact raw wire bytes. `respond` optionally scripts the +// receiver, one entry per delivery attempt: a number becomes that HTTP +// status, an Error instance is thrown as a transport failure. Defaults to +// every attempt answering 200. +async function notifyAndCapture( + webhookUrl: string, + eventType: string, + respond?: Array +): Promise { + seedOrder(); + const checkout = { + id: CHECKOUT_ID, + platform: { webhook_url: webhookUrl }, + order: { + id: ORDER_ID, + permalink_url: `http://localhost:8080/orders/${ORDER_ID}`, + }, + }; + const captured: CapturedRequest[] = []; + const responses = respond ? [...respond] : []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async ( + url: string | URL | Request, + init?: RequestInit + ): Promise => { + const body = init?.body; + captured.push({ + url: String(url), + headers: { ...((init?.headers ?? {}) as Record) }, + raw: + body instanceof Uint8Array + ? Buffer.from(body) + : Buffer.from(String(body ?? ""), "utf-8"), + }); + const next = responses.length ? responses.shift()! : 200; + if (next instanceof Error) throw next; + return new Response(null, { status: next }); + }) as typeof globalThis.fetch; + + try { + await new CheckoutService()["notifyWebhook"]( + checkout as never, + eventType, + BASE_URL + ); + } finally { + globalThis.fetch = originalFetch; + } + return captured; +} + +// Runs `fn` with webhookConfig overrides, always restoring the previous +// values (the config is module state, like the Python server's FLAGS). +async function withConfig( + overrides: Partial, + fn: () => Promise +): Promise { + const saved = { ...webhookConfig }; + Object.assign(webhookConfig, overrides); + try { + return await fn(); + } finally { + Object.assign(webhookConfig, saved); + } +} + +// Fetches the discovery profile the way a platform would. +async function fetchProfile(): Promise> { + const app = new Hono(); + app.get("/.well-known/ucp", new DiscoveryService().getMerchantProfile); + const response = await app.request("/.well-known/ucp"); + assert.equal(response.status, 200); + return (await response.json()) as Record; +} + +function lowercased(headers: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(headers)) out[k.toLowerCase()] = v; + return out; +} + +// Serializes a private key as unencrypted PKCS#8 PEM into a temp file. +function pemFile(privateKey: crypto.KeyObject): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ucp-webhook-key-")); + const file = path.join(dir, "key.pem"); + fs.writeFileSync( + file, + privateKey.export({ type: "pkcs8", format: "pem" }) as string + ); + return file; +} + +/* Delivery-side: every webhook is signed as this business. */ + +test("webhook delivery carries the signature headers", async () => { + // order.md, Webhook Signature Verification: UCP-Agent (the business + // profile URL), Signature, Signature-Input, and Content-Digest are + // required headers on every delivery. + const captured = await notifyAndCapture(WEBHOOK_URL, "order_placed"); + assert.equal(captured.length, 1); + const headers = captured[0]!.headers; + for (const name of [ + "UCP-Agent", + "Signature", + "Signature-Input", + "Content-Digest", + ]) { + assert.ok(headers[name], `delivery is missing ${name}`); + } + // The UCP-Agent profile member is the business's own well-known URL + // (signatures.md, UCP-Agent parsing rule 4 for business profiles). + assert.equal( + headers["UCP-Agent"], + 'profile="http://testserver/.well-known/ucp"' + ); +}); + +test("webhook signature verifies against the published key", async () => { + // order.md, Verification (Platform): Content-Digest matches the SHA-256 of + // the raw body, and the signature verifies against the key the business + // publishes in its profile's signing_keys with the declared kid. This test + // IS that platform: it reads the served profile and runs the server's own + // verify path over the captured delivery. + const captured = await notifyAndCapture(WEBHOOK_URL, "order_placed"); + assert.equal(captured.length, 1); + const delivered = captured[0]!; + const raw = delivered.raw; + const headers = lowercased(delivered.headers); + + assert.ok( + contentDigestMatches(headers["content-digest"]!, raw), + "Content-Digest must cover the exact raw body bytes on the wire" + ); + + const profile = await fetchProfile(); + const keys = profile["signing_keys"] as Jwk[]; + assert.ok( + Array.isArray(keys) && keys.length, + "profile must publish signing_keys for verifiers" + ); + + const url = new URL(delivered.url); + const keyid = verifyRequest( + "POST", + url.host, + url.pathname, + url.search.slice(1), + headers, + raw, + keys + ); + assert.equal(keyid, publicJwk().kid); + + // Kill direction: a tampered body must NOT verify. + assert.throws( + () => + verifyRequest( + "POST", + url.host, + url.pathname, + url.search.slice(1), + headers, + Buffer.concat([raw, Buffer.from(" ")]), + keys + ), + SignatureError + ); +}); + +test("webhook signed components cover identity and event", async () => { + // signatures.md, REST Request Signing: @method/@authority/@path always; + // @query when the platform URL has one; content-digest/content-type for + // the body; idempotency-key on a state-changing POST; ucp-agent when the + // header is present. Webhook-Id, Webhook-Timestamp, and X-Event-Type are + // additionally bound: every header this server adds to the delivery is + // signed, so the event identity the platform dedupes and dispatches on + // cannot be altered in transit. + const captured = await notifyAndCapture( + `${WEBHOOK_URL}?token=t1`, + "order_placed" + ); + assert.equal(captured.length, 1); + const delivered = captured[0]!; + // The delivery reaches the URL exactly as the platform provided it. + assert.equal(delivered.url, `${WEBHOOK_URL}?token=t1`); + const parsed = parseSignatureInput(delivered.headers["Signature-Input"]!); + assert.ok(parsed); + const components = new Set(Object.values(parsed)[0]!.components); + for (const name of [ + "@method", + "@authority", + "@path", + "@query", + "content-digest", + "content-type", + "idempotency-key", + "ucp-agent", + "webhook-id", + "webhook-timestamp", + "x-event-type", + ]) { + assert.ok(components.has(name), `signature does not cover ${name}`); + } + // Every signed header component is actually present on the delivery. + for (const name of [ + "Idempotency-Key", + "Webhook-Id", + "Webhook-Timestamp", + "X-Event-Type", + ]) { + assert.ok(delivered.headers[name], `delivery is missing ${name}`); + } +}); + +/* Retry: order.md, Guidelines (Business): MUST retry failed deliveries. */ + +test("webhook retries after a 5xx and succeeds", async () => { + // The retry is the SAME event: Webhook-Id and Idempotency-Key are stable + // across attempts so the platform can deduplicate, and every attempt is + // signed. + const captured = await notifyAndCapture( + WEBHOOK_URL, + "order_placed", + [500, 200] + ); + assert.equal( + captured.length, + 2, + "a failed delivery must be retried once it 5xxes" + ); + const [first, second] = captured; + assert.equal(first!.headers["Webhook-Id"], second!.headers["Webhook-Id"]); + assert.equal( + first!.headers["Idempotency-Key"], + second!.headers["Idempotency-Key"] + ); + for (const attempt of captured) { + assert.ok(attempt.headers["Signature"], "every attempt must be signed"); + const body = JSON.parse(attempt.raw.toString("utf-8")) as { id: string }; + assert.equal(body.id, ORDER_ID); + } +}); + +test("a malformed webhook URL never throws into the order flow", async () => { + // The webhook URL is platform-controlled data. A value the signer or the + // transport cannot handle must degrade to a logged delivery failure, not an + // exception in completeCheckout after the order is already placed. + const captured = await notifyAndCapture("::not-a-url::", "order_placed"); + assert.equal( + captured.length, + 0, + "an unusable URL cannot produce a delivery, only a logged failure" + ); +}); + +test("each retry attempt is re-signed with a fresh created timestamp", async () => { + // Each delivery attempt is its own signing operation: the signature's + // `created` parameter reflects the actual send time of that attempt, not + // the time of the first one. Deterministic via a stubbed clock that + // advances by more than a second per reading; a signature hoisted out of + // the retry loop would carry the same `created` on both attempts. + const realNow = Date.now; + let tick = 1_700_000_000_000; + Date.now = () => { + tick += 2_000; + return tick; + }; + try { + const captured = await notifyAndCapture( + WEBHOOK_URL, + "order_placed", + [500, 200] + ); + assert.equal(captured.length, 2); + const createdOf = (attempt: CapturedRequest): number => { + const parsed = parseSignatureInput(attempt.headers["Signature-Input"]!); + assert.ok(parsed); + const created = Object.values(parsed)[0]!.params["created"]; + assert.ok(created, "signature must declare a created parameter"); + return Number(created); + }; + const first = createdOf(captured[0]!); + const second = createdOf(captured[1]!); + assert.ok( + second > first, + `the retry must be freshly signed (created ${second} vs ${first})` + ); + } finally { + Date.now = realNow; + } +}); + +test("the default identity is an ephemeral P-256 singleton", async () => { + await withConfig({ signingKeyPath: undefined }, async () => { + resetSigner(); + try { + const first = signingKey(); + const second = signingKey(); + assert.equal(first.privateKey.asymmetricKeyType, "ec"); + assert.equal(first.privateKey, second.privateKey); + assert.equal(first.kid, second.kid); + assert.ok(first.kid.length > 0); + } finally { + resetSigner(); + } + }); +}); + +test("the public JWK matches the signing key and its RFC 7638 kid", async () => { + await withConfig({ signingKeyPath: undefined }, async () => { + resetSigner(); + try { + const { privateKey, kid } = signingKey(); + const jwk = publicJwk(); + assert.equal(jwk.kid, kid); + assert.equal(jwk.kty, "EC"); + assert.equal(jwk.crv, "P-256"); + const expected = jwkFromPublicKey( + crypto.createPublicKey(privateKey), + kid + ); + assert.deepEqual(jwk, expected); + // Independent RFC 7638 oracle: canonical JSON of the REQUIRED public + // members in lexicographic order, SHA-256, base64url without padding. + const canonical = `{"crv":"${jwk.crv}","kty":"${jwk.kty}","x":"${jwk.x}","y":"${jwk.y}"}`; + const thumbprint = crypto + .createHash("sha256") + .update(canonical, "utf-8") + .digest("base64url"); + assert.equal(kid, thumbprint); + } finally { + resetSigner(); + } + }); +}); + +test("WEBHOOK_SIGNING_KEY loads an operator P-256 PEM key", async () => { + const provided = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); + const file = pemFile(provided.privateKey); + try { + await withConfig({ signingKeyPath: file }, async () => { + resetSigner(); + try { + const { kid } = signingKey(); + const jwk = publicJwk(); + const expected = jwkFromPublicKey(provided.publicKey, kid); + assert.deepEqual(jwk, expected); + } finally { + resetSigner(); + } + }); + } finally { + fs.rmSync(path.dirname(file), { recursive: true, force: true }); + } +}); + +test("WEBHOOK_SIGNING_KEY loads an Ed25519 PEM key; the JWK is OKP", async () => { + const provided = crypto.generateKeyPairSync("ed25519"); + const file = pemFile(provided.privateKey); + try { + await withConfig({ signingKeyPath: file }, async () => { + resetSigner(); + try { + const { privateKey } = signingKey(); + assert.equal(privateKey.asymmetricKeyType, "ed25519"); + assert.equal(publicJwk().kty, "OKP"); + } finally { + resetSigner(); + } + }); + } finally { + fs.rmSync(path.dirname(file), { recursive: true, force: true }); + } +}); + +test("the kid is deterministic for a given key", async () => { + // The kid is the RFC 7638 JWK thumbprint: reloading the same PEM must + // republish the same kid, so platforms that cache the profile keep + // resolving the key after a server restart. + const provided = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); + const file = pemFile(provided.privateKey); + try { + await withConfig({ signingKeyPath: file }, async () => { + resetSigner(); + try { + const first = signingKey().kid; + resetSigner(); + const second = signingKey().kid; + assert.equal(first, second); + } finally { + resetSigner(); + } + }); + } finally { + fs.rmSync(path.dirname(file), { recursive: true, force: true }); + } +}); + +test("an unreadable key file fails loudly", async () => { + // A bad key path is a configuration error, never a silent fallback: the + // operator asked for a specific signing identity, and silently generating + // an ephemeral key instead would sign as a different identity than the + // one configured. src/index.ts loads the key at boot so this failure + // aborts startup, not individual deliveries. + await withConfig({ signingKeyPath: "/nonexistent/key.pem" }, async () => { + resetSigner(); + try { + assert.throws(() => signingKey()); + } finally { + resetSigner(); + } + }); +}); + +test("an unsupported key type is rejected with a clear message", async () => { + const wrongCurve = crypto.generateKeyPairSync("ec", { + namedCurve: "secp384r1", + }); + const file = pemFile(wrongCurve.privateKey); + try { + await withConfig({ signingKeyPath: file }, async () => { + resetSigner(); + try { + assert.throws(() => signingKey(), /EC P-256 \(ES256\) or Ed25519/); + } finally { + resetSigner(); + } + }); + } finally { + fs.rmSync(path.dirname(file), { recursive: true, force: true }); + } +}); + +/* Key discovery: the profile publishes the webhook public key. */ + +test("the profile publishes the webhook signing key", async () => { + // signatures.md, Key Discovery: public keys live in the profile's + // signing_keys[] (a top-level sibling of `ucp` per the discovery profile + // schema). It is also mirrored into ucp.keys[], the JWK Set this server's + // own verifier resolves. + const profile = await fetchProfile(); + const jwk = publicJwk(); + const signingKeys = profile["signing_keys"] as Jwk[]; + assert.ok( + Array.isArray(signingKeys) && + signingKeys.some((k) => isDeepStrictEqual(k, jwk)), + "signing_keys[] must carry the webhook public JWK" + ); + const ucp = profile["ucp"] as { keys?: Jwk[] }; + assert.ok( + Array.isArray(ucp.keys) && ucp.keys.some((k) => isDeepStrictEqual(k, jwk)), + "ucp.keys[] must mirror the webhook public JWK" + ); +});