diff --git a/rest/python/server/README.md b/rest/python/server/README.md index 3f0b3c2..ad6c875 100644 --- a/rest/python/server/README.md +++ b/rest/python/server/README.md @@ -127,6 +127,30 @@ Each verified request logs `RFC 9421 signature verified (keyid=..., profile=...)`. Add `--require_signatures` to reject anything unsigned. +## Webhook Signing & Delivery Retry + +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`). The +matching public JWK is published in the served profile's `signing_keys[]` +(and mirrored into `ucp.keys[]`) so platforms can verify. + +Failed deliveries — transport errors or a 5xx from the receiver — are retried +with exponential backoff, as `order.md` requires; a 4xx is treated as a +permanent rejection and is not retried. Retried attempts reuse the same +`Webhook-Id` and `Idempotency-Key`, so receivers can deduplicate. + +| Flag | 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. | +| `--webhook_delivery_attempts` | `3` | Total delivery attempts per webhook (initial attempt plus retries). | +| `--webhook_retry_backoff_seconds` | `0.5` | Delay before the first retry; doubles on each subsequent retry. | + ## Run a Simple Client Exercise a simple checkout path: Once the server is running, execute the simple diff --git a/rest/python/server/config.py b/rest/python/server/config.py index aec6ee9..8be998e 100644 --- a/rest/python/server/config.py +++ b/rest/python/server/config.py @@ -79,6 +79,30 @@ def get_server_version() -> str: "signer keys. For localhost demos and CI only; never enable in " "production, as it disables SSRF protections.", ) + flags.DEFINE_string( + "webhook_signing_key", + None, + "Path to a PEM private key (EC P-256 or Ed25519) used to sign outbound " + "order-event webhooks as this business (order.md, Webhook Signature " + "Verification). When unset, an ephemeral demo key is generated at " + "startup; either way the public JWK is published in the served " + "profile's signing_keys[].", + ) + flags.DEFINE_integer( + "webhook_delivery_attempts", + 3, + "Total delivery attempts per order-event webhook (the initial attempt " + "plus retries). order.md requires failed deliveries to be retried; the " + "bound keeps the retry finite.", + lower_bound=1, + ) + flags.DEFINE_float( + "webhook_retry_backoff_seconds", + 0.5, + "Delay before the first webhook retry, doubling on each subsequent " + "retry (exponential backoff).", + lower_bound=0.0, + ) except flags.DuplicateFlagError: pass @@ -87,6 +111,13 @@ def get_server_version() -> str: async def lifespan(app: FastAPI): """Shared lifespan manager for initializing databases.""" del app # Unused. + # 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. Imported lazily; webhook_signer imports this module. + import webhook_signer + + webhook_signer.signing_key() # In tests or if flags aren't set, these might be None, handled by caller if FLAGS.products_db_path and FLAGS.transactions_db_path: await db.manager.init_dbs( diff --git a/rest/python/server/integration_test.py b/rest/python/server/integration_test.py index 97e990c..8852088 100644 --- a/rest/python/server/integration_test.py +++ b/rest/python/server/integration_test.py @@ -23,12 +23,18 @@ import tempfile import uuid +from urllib.parse import urlsplit + from absl import flags from absl.testing import absltest +import config import db import dependencies from fastapi.testclient import TestClient +import httpx import respx +import ucp_signing +import webhook_signer from enums import ErrorSeverity, MessageType from exceptions import UcpErrorResponse, UcpMessageError from models import UnifiedCheckout @@ -816,9 +822,17 @@ def test_idempotency_key_is_scoped_to_operation_and_checkout(self) -> None: self.assertEqual(second_checkout_data["line_items"][0]["quantity"], 1) def _notify_and_capture( - self, checkout: UnifiedCheckout, event_type: str + self, + checkout: UnifiedCheckout, + event_type: str, + respond: list | None = None, ) -> list[dict]: - """Fire _notify_webhook with httpx stubbed and return captured POSTs.""" + """Fire _notify_webhook with httpx stubbed and return captured POSTs. + + ``respond`` optionally scripts the receiver, one entry per delivery + attempt: an int becomes that HTTP status, an Exception instance is + raised as a transport failure. Defaults to a single 200. + """ captured: list[dict] = [] async def run() -> None: @@ -833,7 +847,15 @@ async def run() -> None: "http://testserver", ) with respx.mock: - route = respx.post().respond(200) + if respond is None: + route = respx.post().respond(200) + else: + route = respx.post().mock( + side_effect=[ + r if isinstance(r, Exception) else httpx.Response(r) + for r in respond + ] + ) await service._notify_webhook(checkout, event_type) if route.called: for call in route.calls: @@ -843,6 +865,7 @@ async def run() -> None: { "url": str(request.url), "json": body, + "content": request.content, "headers": request.headers, } ) @@ -959,6 +982,262 @@ def test_webhook_is_skipped_when_there_is_no_order(self) -> None: captured = self._notify_and_capture(checkout, "order_placed") self.assertEqual(captured, [], "no webhook may be sent without an order") + def _completed_checkout( + self, checkout_id: str, webhook_url: str + ) -> UnifiedCheckout: + """Drive create + complete so a real order exists; set the webhook URL.""" + with self.client: + payload = self._create_checkout_payload( + checkout_id, [("rose", "Red Rose", 1000, 1)] + ) + response = self.client.post( + "/checkout-sessions", + headers=self._get_headers( + idempotency_key=f"{checkout_id}_create", + request_id=f"{checkout_id}_create", + ), + json=payload.model_dump(mode="json", exclude_none=True), + ) + self.assertEqual(response.status_code, 201, response.text) + response = self.client.post( + f"/checkout-sessions/{checkout_id}/complete", + headers=self._get_headers( + idempotency_key=f"{checkout_id}_complete", + request_id=f"{checkout_id}_complete", + ), + json=self._create_payment_payload(), + ) + self.assertEqual(response.status_code, 200, response.text) + checkout = UnifiedCheckout.model_validate(response.json()) + self.assertIsNotNone(checkout.order) + checkout.platform = PlatformSchema(webhook_url=webhook_url) + return checkout + + def test_webhook_delivery_carries_the_signature_headers(self) -> None: + """Every delivery carries the four required signature headers. + + order.md, Webhook Signature Verification: webhook payloads MUST be + signed; UCP-Agent (the business profile URL), Signature, + Signature-Input, and Content-Digest are required headers on every + delivery. + """ + checkout = self._completed_checkout( + "wh_signed", "https://platform.example/ucp-webhook" + ) + captured = self._notify_and_capture(checkout, "order_placed") + self.assertEqual(len(captured), 1) + headers = captured[0]["headers"] + for name in ("UCP-Agent", "Signature", "Signature-Input", "Content-Digest"): + self.assertIn(name, headers, f"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). + self.assertEqual( + headers["UCP-Agent"], + 'profile="http://testserver/.well-known/ucp"', + ) + + def test_webhook_signature_verifies_against_the_published_key(self) -> None: + """The platform-side verification loop closes on the raw wire bytes. + + 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 discovers the profile from the server and runs the + server's own verify path over the captured delivery. + """ + checkout = self._completed_checkout( + "wh_verify", "https://platform.example/ucp-webhook" + ) + captured = self._notify_and_capture(checkout, "order_placed") + self.assertEqual(len(captured), 1) + delivered = captured[0] + raw = delivered["content"] + headers = {k.lower(): v for k, v in delivered["headers"].items()} + + self.assertTrue( + ucp_signing.content_digest_matches(headers["content-digest"], raw), + "Content-Digest must cover the exact raw body bytes on the wire", + ) + + with self.client: + profile = self.client.get("/.well-known/ucp").json() + keys = profile.get("signing_keys") + self.assertTrue(keys, "profile must publish signing_keys for verifiers") + + split = urlsplit(delivered["url"]) + keyid = ucp_signing.verify_request( + "POST", split.netloc, split.path, split.query, headers, raw, keys + ) + self.assertEqual(keyid, webhook_signer.public_jwk()["kid"]) + + # Kill direction: a tampered body must NOT verify. + with self.assertRaises(ucp_signing.SignatureError): + ucp_signing.verify_request( + "POST", + split.netloc, + split.path, + split.query, + headers, + raw + b" ", + keys, + ) + + def test_webhook_signed_components_cover_identity_and_event(self) -> None: + """The signed set covers the spec table plus the webhook headers. + + 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. + """ + checkout = self._completed_checkout( + "wh_components", "https://platform.example/ucp-webhook?token=t1" + ) + captured = self._notify_and_capture(checkout, "order_placed") + self.assertEqual(len(captured), 1) + delivered = captured[0] + # The delivery reaches the URL exactly as the platform provided it. + self.assertEqual( + delivered["url"], "https://platform.example/ucp-webhook?token=t1" + ) + parsed = ucp_signing.parse_signature_input( + delivered["headers"]["Signature-Input"] + ) + self.assertIsNotNone(parsed) + components = set(next(iter(parsed.values()))["components"]) + self.assertLessEqual( + { + "@method", + "@authority", + "@path", + "@query", + "content-digest", + "content-type", + "idempotency-key", + "ucp-agent", + "webhook-id", + "webhook-timestamp", + "x-event-type", + }, + components, + ) + # Every signed header component is actually present on the delivery. + for name in ( + "Idempotency-Key", + "Webhook-Id", + "Webhook-Timestamp", + "X-Event-Type", + ): + self.assertIn(name, delivered["headers"]) + + def test_webhook_retries_after_5xx_and_succeeds(self) -> None: + """A 5xx from the receiver triggers a retry that then succeeds. + + order.md, Guidelines (Business): MUST retry failed webhook deliveries. + 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. + """ + config.FLAGS.webhook_retry_backoff_seconds = 0.01 + checkout = self._completed_checkout( + "wh_retry", "https://platform.example/ucp-webhook" + ) + captured = self._notify_and_capture( + checkout, "order_placed", respond=[500, 200] + ) + self.assertEqual( + len(captured), 2, "a failed delivery must be retried once it 5xxes" + ) + first, second = captured + self.assertEqual( + first["headers"]["Webhook-Id"], second["headers"]["Webhook-Id"] + ) + self.assertEqual( + first["headers"]["Idempotency-Key"], + second["headers"]["Idempotency-Key"], + ) + for attempt in captured: + self.assertIn("Signature", attempt["headers"]) + self.assertEqual(attempt["json"]["id"], checkout.order.id) + + def test_webhook_retries_after_connection_error(self) -> None: + """A transport failure (connection refused/reset) is also retried.""" + config.FLAGS.webhook_retry_backoff_seconds = 0.01 + checkout = self._completed_checkout( + "wh_conn_retry", "https://platform.example/ucp-webhook" + ) + captured = self._notify_and_capture( + checkout, + "order_placed", + respond=[httpx.ConnectError("connection refused"), 200], + ) + self.assertEqual(len(captured), 2) + + def test_webhook_retries_are_bounded(self) -> None: + """A receiver that keeps failing sees a bounded number of attempts. + + The retry MUST terminate: exactly --webhook_delivery_attempts POSTs, + and the failure never escapes into the checkout flow. + """ + config.FLAGS.webhook_retry_backoff_seconds = 0.01 + checkout = self._completed_checkout( + "wh_bounded", "https://platform.example/ucp-webhook" + ) + captured = self._notify_and_capture( + checkout, "order_placed", respond=[500] * 10 + ) + self.assertEqual(len(captured), config.FLAGS.webhook_delivery_attempts) + + def test_webhook_4xx_is_not_retried(self) -> None: + """A 4xx is a permanent rejection of this delivery: exactly one POST. + + Retrying a request the receiver deemed invalid cannot succeed and turns + a bad delivery into a retry storm; only transport failures and 5xx are + transient. + """ + config.FLAGS.webhook_retry_backoff_seconds = 0.01 + checkout = self._completed_checkout( + "wh_4xx", "https://platform.example/ucp-webhook" + ) + captured = self._notify_and_capture( + checkout, "order_placed", respond=[400, 200] + ) + self.assertEqual(len(captured), 1) + + def test_bad_webhook_signing_key_fails_at_startup(self) -> None: + """A misconfigured signing key aborts server startup loudly. + + Loading the key only at delivery time would swallow the configuration + error into a per-webhook log line and silently degrade every delivery; + the operator asked for a specific signing identity, so a key that + cannot be loaded must fail the boot, not the webhooks. + """ + config.FLAGS.webhook_signing_key = "/nonexistent/key.pem" + webhook_signer.reset() + try: + with self.assertRaises(OSError), TestClient(app): + pass + finally: + config.FLAGS.webhook_signing_key = None + webhook_signer.reset() + + def test_profile_publishes_the_webhook_signing_key(self) -> None: + """The served profile publishes the webhook public key for verifiers. + + 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. + """ + with self.client: + profile = self.client.get("/.well-known/ucp").json() + jwk = webhook_signer.public_jwk() + self.assertIn(jwk, profile.get("signing_keys", [])) + self.assertIn(jwk, profile.get("ucp", {}).get("keys", [])) + def test_version_invalid_format(self) -> None: """Tests that UCP-Agent with invalid version format is rejected.""" with self.client: diff --git a/rest/python/server/routes/discovery.py b/rest/python/server/routes/discovery.py index 7a69da3..e4f8f21 100644 --- a/rest/python/server/routes/discovery.py +++ b/rest/python/server/routes/discovery.py @@ -20,6 +20,7 @@ from fastapi import APIRouter from fastapi import Request from fastapi import Response +import webhook_signer router = APIRouter() @@ -60,4 +61,15 @@ async def get_merchant_profile(request: Request, response: Response): ucp = profile_data.setdefault("ucp", {}) ucp.setdefault("payment_handlers", []) + # 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 (ucp_signing._extract_keys) resolves, so both + # discovery conventions find the key. + jwk = webhook_signer.public_jwk() + profile_data.setdefault("signing_keys", []).append(jwk) + ucp.setdefault("keys", []).append(jwk) + return profile_data diff --git a/rest/python/server/services/checkout_service.py b/rest/python/server/services/checkout_service.py index 50665d7..d75acf7 100644 --- a/rest/python/server/services/checkout_service.py +++ b/rest/python/server/services/checkout_service.py @@ -30,6 +30,7 @@ - Supporting hierarchical fulfillment configuration. """ +import asyncio import datetime import hashlib import json @@ -54,6 +55,8 @@ from pydantic import BaseModel from services.fulfillment_service import FulfillmentService from sqlalchemy.ext.asyncio import AsyncSession +import ucp_signing +import webhook_signer from ucp_sdk.models.schemas.ucp import ( ResponseCheckoutSchema as ResponseCheckout, ) @@ -838,6 +841,15 @@ async def _notify_webhook(self, checkout: Checkout, event_type: str) -> None: conveyed out of band in the ``X-Event-Type`` header. The body must always be a valid order, so no notification is sent when there is no order to deliver. + + Every delivery is RFC 9421-signed as this business (order.md, Webhook + Signature Verification): ``UCP-Agent`` names our profile, and + ``Content-Digest``/``Signature-Input``/``Signature`` cover the exact raw + body bytes sent on the wire. Failed deliveries (transport errors or a + 5xx from the receiver) are retried with bounded exponential backoff + (order.md: businesses MUST retry failed webhook deliveries); a 4xx is a + permanent rejection and is not retried. Delivery failures are logged and + never propagate into the order flow. """ if not checkout.platform or not checkout.platform.webhook_url: return @@ -857,20 +869,88 @@ async def _notify_webhook(self, checkout: Checkout, event_type: str) -> None: return webhook_url = str(checkout.platform.webhook_url) - + # Serialize exactly once: the signed Content-Digest and the wire body + # must be the same bytes. + body = json.dumps(order_data).encode("utf-8") + webhook_id = str(uuid.uuid4()) + headers = { + "Content-Type": "application/json", + "X-Event-Type": event_type, + "Webhook-Id": webhook_id, + "Webhook-Timestamp": str( + int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + ), + # 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": webhook_id, + # Sign AS this business: the profile URL platforms fetch our + # signing_keys[] from (order.md requires UCP-Agent on deliveries). + "UCP-Agent": f'profile="{self.base_url}/.well-known/ucp"', + } + attempts = config.FLAGS.webhook_delivery_attempts + backoff = config.FLAGS.webhook_retry_backoff_seconds try: + key, kid = webhook_signer.signing_key() async with httpx.AsyncClient() as client: - await client.post( - webhook_url, - json=order_data, - headers={ - "X-Event-Type": event_type, - "Webhook-Id": str(uuid.uuid4()), - "Webhook-Timestamp": str( - int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + for attempt in range(1, attempts + 1): + # Re-sign per attempt so the signature's `created` timestamp + # reflects the actual send time of each delivery attempt. + additions = ucp_signing.sign_request( + key, + kid, + "POST", + webhook_url, + headers, + body, + extra_components=( + "webhook-id", + "webhook-timestamp", + "x-event-type", ), - }, - timeout=5.0, + ) + try: + response = await client.post( + webhook_url, + content=body, + headers={**headers, **additions}, + timeout=5.0, + ) + except httpx.HTTPError as exc: + failure = f"transport error: {exc}" + else: + if 200 <= response.status_code < 300: + return + failure = f"HTTP {response.status_code}" + if response.status_code < 500: + # The receiver rejected this delivery as invalid; retrying + # the same request cannot succeed. + logger.error( + "Webhook delivery to %s permanently rejected (%s); " + "not retrying", + webhook_url, + failure, + ) + return + if attempt < attempts: + delay = backoff * (2 ** (attempt - 1)) + logger.warning( + "Webhook delivery attempt %d/%d to %s failed (%s); " + "retrying in %.2fs", + attempt, + attempts, + webhook_url, + failure, + delay, + ) + await asyncio.sleep(delay) + logger.error( + "Failed to deliver %s webhook to %s after %d attempts (%s)", + event_type, + webhook_url, + attempts, + failure, ) except Exception as e: # pylint: disable=broad-exception-caught logger.error("Failed to notify webhook at %s: %s", webhook_url, e) diff --git a/rest/python/server/ucp_signing.py b/rest/python/server/ucp_signing.py index 934a7a7..2db8d28 100644 --- a/rest/python/server/ucp_signing.py +++ b/rest/python/server/ucp_signing.py @@ -596,6 +596,7 @@ def sign_request( headers: dict, body: bytes, created: int | None = None, + extra_components: tuple[str, ...] = (), ) -> dict: """Sign a UCP request and return the headers to add. @@ -612,6 +613,11 @@ def sign_request( is avoided -- a new dict of additions is returned. body: Raw request body bytes. created: Optional ``created`` timestamp; defaults to the current time. + extra_components: 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``. Returns: A dict of header names to values that the caller must add to the request. @@ -632,6 +638,9 @@ def sign_request( additions["Content-Type"] = "application/json" components = required_components(method, bool(split.query), merged, has_body) + for name in extra_components: + if name not in components and name in merged: + components.append(name) created = int(time.time()) if created is None else created raw_params = ( "(" + " ".join(f'"{c}"' for c in components) + ")" diff --git a/rest/python/server/ucp_signing_test.py b/rest/python/server/ucp_signing_test.py index bba0f7a..0a6ae7c 100644 --- a/rest/python/server/ucp_signing_test.py +++ b/rest/python/server/ucp_signing_test.py @@ -815,6 +815,88 @@ def test_empty_string(self) -> None: self.assertIsNone(signing.parse_signature_input("")) +class ExtraComponentsTest(absltest.TestCase): + """sign_request can cover caller-requested headers 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. + """ + + def test_extra_components_are_covered_and_verify(self) -> None: + """Requested present headers join the signed set; the result verifies.""" + key = ec.generate_private_key(ec.SECP256R1()) + jwk = signing.jwk_from_public_key(key.public_key(), "k1") + headers = { + "UCP-Agent": 'profile="https://m.example/.well-known/ucp"', + "Idempotency-Key": "evt-1", + "Webhook-Id": "evt-1", + "Webhook-Timestamp": "1700000000", + } + body = b'{"id":"ord_1"}' + add = signing.sign_request( + key, + "k1", + "POST", + "https://platform.example/hook?token=t", + headers, + body, + extra_components=("webhook-id", "webhook-timestamp"), + ) + parsed = signing.parse_signature_input(add["Signature-Input"]) + components = parsed["sig1"]["components"] + self.assertIn("webhook-id", components) + self.assertIn("webhook-timestamp", components) + # The UCP required floor is still fully covered. + for required in ( + "@method", + "@authority", + "@path", + "@query", + "content-digest", + "content-type", + "idempotency-key", + "ucp-agent", + ): + self.assertIn(required, components) + merged = {k.lower(): v for k, v in {**headers, **add}.items()} + keyid = signing.verify_request( + "POST", "platform.example", "/hook", "token=t", merged, body, [jwk] + ) + self.assertEqual(keyid, "k1") + + def test_absent_extra_component_is_skipped(self) -> None: + """An extra component whose header is absent is not declared as signed.""" + key = ec.generate_private_key(ec.SECP256R1()) + add = signing.sign_request( + key, + "k1", + "GET", + "https://m.example/p", + {}, + b"", + extra_components=("webhook-id",), + ) + parsed = signing.parse_signature_input(add["Signature-Input"]) + self.assertNotIn("webhook-id", parsed["sig1"]["components"]) + + def test_extra_component_never_duplicates_required(self) -> None: + """A requested component already in the required set appears once.""" + key = ec.generate_private_key(ec.SECP256R1()) + headers = {"UCP-Agent": 'profile="https://m.example/.well-known/ucp"'} + add = signing.sign_request( + key, + "k1", + "GET", + "https://m.example/p", + headers, + b"", + extra_components=("ucp-agent",), + ) + parsed = signing.parse_signature_input(add["Signature-Input"]) + self.assertEqual(parsed["sig1"]["components"].count("ucp-agent"), 1) + + class ParseParenDepthTest(absltest.TestCase): """The component-list paren scanner handles nested and unbalanced parens.""" diff --git a/rest/python/server/webhook_signer.py b/rest/python/server/webhook_signer.py new file mode 100644 index 0000000..7727af5 --- /dev/null +++ b/rest/python/server/webhook_signer.py @@ -0,0 +1,116 @@ +# 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 base64 +import hashlib +import json +import pathlib + +import config +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric import ed25519 +import ucp_signing + +# The lazily-created (private key, kid) signing identity for this process. +_SIGNER: tuple | None = None + + +def signing_key() -> tuple: + """Return the ``(private_key, 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. + + Returns: + A tuple of the private key object and its RFC 7638 thumbprint kid. + + Raises: + OSError: When the configured key file cannot be read. + ValueError: When the file is not a supported private key (EC P-256 or + Ed25519). + + """ + global _SIGNER + if _SIGNER is None: + path = config.FLAGS.webhook_signing_key + if path: + pem = pathlib.Path(path).read_bytes() + key = serialization.load_pem_private_key(pem, password=None) + if isinstance(key, ec.EllipticCurvePrivateKey): + if not isinstance(key.curve, ec.SECP256R1): + raise ValueError( + "--webhook_signing_key must be EC P-256 (ES256) or Ed25519; " + f"got EC curve {key.curve.name}" + ) + elif not isinstance(key, ed25519.Ed25519PrivateKey): + raise ValueError( + "--webhook_signing_key must be EC P-256 (ES256) or Ed25519; " + f"got {type(key).__name__}" + ) + else: + key = ec.generate_private_key(ec.SECP256R1()) + _SIGNER = (key, _thumbprint_kid(key.public_key())) + return _SIGNER + + +def public_jwk() -> dict: + """Return the public JWK to publish in the profile's ``signing_keys[]``.""" + key, kid = signing_key() + return ucp_signing.jwk_from_public_key(key.public_key(), kid) + + +def reset() -> None: + """Discard the cached signing identity (used by tests).""" + global _SIGNER + _SIGNER = None + + +def _thumbprint_kid(public_key) -> str: + """Derive 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. + """ + jwk = ucp_signing.jwk_from_public_key(public_key, kid="") + if jwk["kty"] == "OKP": + members = {"crv": jwk["crv"], "kty": jwk["kty"], "x": jwk["x"]} + else: + members = { + "crv": jwk["crv"], + "kty": jwk["kty"], + "x": jwk["x"], + "y": jwk["y"], + } + canonical = json.dumps(members, separators=(",", ":"), sort_keys=True) + digest = hashlib.sha256(canonical.encode("utf-8")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") diff --git a/rest/python/server/webhook_signer_test.py b/rest/python/server/webhook_signer_test.py new file mode 100644 index 0000000..0da4fce --- /dev/null +++ b/rest/python/server/webhook_signer_test.py @@ -0,0 +1,136 @@ +# 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. + +"""Unit tests for the business webhook-signing identity. + +The reference server signs outbound order-event webhooks as the business +(order.md, Webhook Signature Verification). These tests pin the key +lifecycle: an out-of-the-box ephemeral demo key, deterministic RFC 7638 +thumbprint kids, and ``--webhook_signing_key`` loading an operator-provided +PEM (ES256 or Ed25519). No private-key files are committed; all key material +is generated at runtime. +""" + +import pathlib +import tempfile + +from absl import flags +from absl.testing import absltest +import config +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric import ed25519 +import ucp_signing +import webhook_signer + + +def _pem(private_key) -> bytes: + """Serialize a private key as unencrypted PKCS#8 PEM.""" + return private_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + + +class WebhookSignerTest(absltest.TestCase): + """Key loading, kid derivation, and profile JWK export.""" + + def setUp(self) -> None: + """Parse flags and clear any cached signing identity.""" + super().setUp() + flags.FLAGS(["test"]) + config.FLAGS.webhook_signing_key = None + webhook_signer.reset() + + def tearDown(self) -> None: + """Restore the default key configuration.""" + config.FLAGS.webhook_signing_key = None + webhook_signer.reset() + super().tearDown() + + def test_default_is_ephemeral_p256_singleton(self) -> None: + """Without the flag, one ES256 demo key is generated and reused.""" + key1, kid1 = webhook_signer.signing_key() + key2, kid2 = webhook_signer.signing_key() + self.assertIsInstance(key1, ec.EllipticCurvePrivateKey) + self.assertIs(key1, key2) + self.assertEqual(kid1, kid2) + self.assertNotEmpty(kid1) + + def test_public_jwk_matches_signing_key(self) -> None: + """The published JWK is the signing key's public half, same kid.""" + key, kid = webhook_signer.signing_key() + jwk = webhook_signer.public_jwk() + self.assertEqual(jwk["kid"], kid) + self.assertEqual(jwk["kty"], "EC") + self.assertEqual(jwk["crv"], "P-256") + expected = ucp_signing.jwk_from_public_key(key.public_key(), kid) + self.assertEqual(jwk, expected) + + def test_flag_loads_p256_pem(self) -> None: + """--webhook_signing_key loads an operator EC P-256 PEM key.""" + provided = ec.generate_private_key(ec.SECP256R1()) + path = pathlib.Path(tempfile.mkdtemp()) / "key.pem" + path.write_bytes(_pem(provided)) + config.FLAGS.webhook_signing_key = str(path) + webhook_signer.reset() + key, _ = webhook_signer.signing_key() + self.assertEqual( + key.private_numbers().private_value, + provided.private_numbers().private_value, + ) + + def test_flag_loads_ed25519_pem(self) -> None: + """--webhook_signing_key loads an Ed25519 PEM key; JWK is OKP.""" + provided = ed25519.Ed25519PrivateKey.generate() + path = pathlib.Path(tempfile.mkdtemp()) / "key.pem" + path.write_bytes(_pem(provided)) + config.FLAGS.webhook_signing_key = str(path) + webhook_signer.reset() + key, _ = webhook_signer.signing_key() + self.assertIsInstance(key, ed25519.Ed25519PrivateKey) + self.assertEqual(webhook_signer.public_jwk()["kty"], "OKP") + + def test_kid_is_deterministic_for_a_given_key(self) -> None: + """The kid is the RFC 7638 JWK thumbprint: stable across restarts. + + Reloading the same PEM must republish the same kid, so platforms that + cache the profile keep resolving the key after a server restart. + """ + provided = ec.generate_private_key(ec.SECP256R1()) + path = pathlib.Path(tempfile.mkdtemp()) / "key.pem" + path.write_bytes(_pem(provided)) + config.FLAGS.webhook_signing_key = str(path) + webhook_signer.reset() + _, kid_first = webhook_signer.signing_key() + webhook_signer.reset() + _, kid_second = webhook_signer.signing_key() + self.assertEqual(kid_first, kid_second) + + def test_unreadable_key_file_fails_loudly(self) -> None: + """A bad key path is a configuration error, never a silent fallback. + + The operator asked for a specific signing identity; silently generating + an ephemeral key instead would sign as a different identity than the one + configured. + """ + config.FLAGS.webhook_signing_key = "/nonexistent/key.pem" + webhook_signer.reset() + with self.assertRaises(OSError): + webhook_signer.signing_key() + + +if __name__ == "__main__": + absltest.main()