Skip to content
8 changes: 8 additions & 0 deletions rest/python/client/flower_shop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ uv run simple_happy_path_client.py --server_url=http://localhost:8182
uv run simple_happy_path_client.py --export_requests_to=interaction_log.md
```

- `--disable_signatures`: Do not sign requests. By default the client signs
every request per RFC 9421 with an ephemeral `ES256` key and publishes the
matching public key from a small local profile server, so the merchant can
discover the key (via the `UCP-Agent` header) and verify the signature. Pass
this flag to send unsigned requests instead — useful when exporting a
replayable dialog, since a real signature is bound to the request body and
host.

## Automated Demo (extract_json_dialog.sh)

For a fully automated demonstration that sets up the database, starts the
Expand Down
1 change: 1 addition & 0 deletions rest/python/client/flower_shop/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ authors = [
{ name = "UCP Team" }
]
dependencies = [
"cryptography>=42",
"pydantic>=2.12.0",
"ucp-sdk",
]
Expand Down
184 changes: 184 additions & 0 deletions rest/python/client/flower_shop/signing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# 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.

"""Client-side RFC 9421 request signing for the happy-path demo.

Provides an ``httpx`` auth flow that signs every outgoing request with an
ephemeral ES256 key, and a tiny localhost profile server that publishes the
matching public key so the merchant can discover it via the ``UCP-Agent``
header. This is the counterpart to the server's ``ucp_signing`` verifier and
demonstrates the full sign-then-verify loop end to end.
"""

import base64
import hashlib
import http.server
import json
import threading
import time
from urllib.parse import urlsplit

from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
import httpx

_ES256_COORD_BYTES = 32


def _b64u(data: bytes) -> str:
"""Encode bytes as base64url text without padding."""
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")


def content_digest(body: bytes) -> str:
"""Return the RFC 9530 sha-256 Content-Digest for the raw body bytes."""
digest = hashlib.sha256(body).digest()
return "sha-256=:" + base64.b64encode(digest).decode("ascii") + ":"


def public_jwk(public_key: ec.EllipticCurvePublicKey, kid: str) -> dict:
"""Export an ES256 public key as a UCP JWK."""
numbers = public_key.public_numbers()
return {
"kid": kid,
"kty": "EC",
"crv": "P-256",
"x": _b64u(numbers.x.to_bytes(_ES256_COORD_BYTES, "big")),
"y": _b64u(numbers.y.to_bytes(_ES256_COORD_BYTES, "big")),
"use": "sig",
"alg": "ES256",
}


class RequestSigner(httpx.Auth):
"""An httpx auth flow that RFC 9421-signs every request.

The signed component set follows the UCP coverage table: the method,
authority, and path (plus query when present), the content digest and type
for bodied requests, and the idempotency-key and ucp-agent headers when they
are present on the request.
"""

requires_request_body = True

def __init__(self, private_key: ec.EllipticCurvePrivateKey, kid: str) -> None:
"""Store the signing key and its identifier."""
self._key = private_key
self._kid = kid

def auth_flow(self, request):
"""Add Content-Digest, Signature-Input, and Signature to the request."""
body = request.content or b""
lowered = {k.lower(): v for k, v in request.headers.items()}
split = urlsplit(str(request.url))

if body:
digest = content_digest(body)
request.headers["Content-Digest"] = digest
lowered["content-digest"] = digest
if "content-type" not in lowered:
request.headers["Content-Type"] = "application/json"
lowered["content-type"] = "application/json"

components = ["@method", "@authority", "@path"]
if split.query:
components.append("@query")
if body:
components.extend(["content-digest", "content-type"])
if "idempotency-key" in lowered:
components.append("idempotency-key")
if "ucp-agent" in lowered:
components.append("ucp-agent")

created = int(time.time())
raw_params = (
"(" + " ".join(f'"{c}"' for c in components) + ")"
f';created={created};keyid="{self._kid}"'
)

def resolve(name: str) -> str:
if name == "@method":
return request.method.upper()
if name == "@authority":
return split.netloc.lower()
if name == "@path":
return split.path or "/"
if name == "@query":
return "?" + split.query
return lowered[name]

lines = [f'"{c}": {resolve(c)}' for c in components]
lines.append(f'"@signature-params": {raw_params}')
base = "\n".join(lines).encode("utf-8")

der = self._key.sign(base, ec.ECDSA(_ecdsa_hash()))
r, s = decode_dss_signature(der)
raw = r.to_bytes(_ES256_COORD_BYTES, "big") + s.to_bytes(
_ES256_COORD_BYTES, "big"
)
request.headers["Signature-Input"] = f"sig1={raw_params}"
request.headers["Signature"] = (
"sig1=:" + base64.b64encode(raw).decode("ascii") + ":"
)
yield request


def _ecdsa_hash():
"""Return the SHA-256 primitive that ES256 signs with."""
from cryptography.hazmat.primitives import hashes

return hashes.SHA256()


class LocalProfileServer:
"""Serve a UCP profile publishing the demo signing key on localhost."""

def __init__(self, jwk: dict, version: str = "2026-01-23") -> None:
"""Prepare the profile document and HTTP server (not yet started)."""
document = json.dumps(
{"ucp": {"version": version, "keys": [jwk]}}
).encode()

class _Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 (http.server API)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(document)

def log_message(self, *args) -> None:
del args

self._server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
self.port = self._server.server_address[1]
self.profile_url = f"http://127.0.0.1:{self.port}/profile.json"
self._thread = threading.Thread(target=self._server.serve_forever)
self._thread.daemon = True

def start(self) -> None:
"""Start serving in a background thread."""
self._thread.start()

def stop(self) -> None:
"""Stop the server and release its socket."""
self._server.shutdown()
self._server.server_close()


def build_signer() -> tuple[RequestSigner, LocalProfileServer]:
"""Create an ephemeral key, a signer, and its localhost profile server."""
key = ec.generate_private_key(ec.SECP256R1())
kid = "flower-shop-demo-client"
server = LocalProfileServer(public_jwk(key.public_key(), kid))
return RequestSigner(key, kid), server
48 changes: 43 additions & 5 deletions rest/python/client/flower_shop/simple_happy_path_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from pathlib import Path
import uuid
import httpx
import signing
from ucp_sdk.models.schemas.shopping import checkout_create_request
from ucp_sdk.models.schemas.shopping import checkout_update_request
from ucp_sdk.models.schemas.shopping import payment_create_request
Expand All @@ -44,14 +45,30 @@
from ucp_sdk.models.schemas.shopping.types import line_item_update_request


# Set by main() when request signing is enabled; the profile URL published by
# the local key server, referenced by the UCP-Agent header so the merchant can
# discover the signing key.
_SIGNING_PROFILE_URL: str | None = None


def get_headers() -> dict[str, str]:
"""Generate necessary headers for UCP requests."""
return {
"UCP-Agent": 'profile="https://agent.example/profile"',
"request-signature": "test",
"""Generate necessary headers for UCP requests.

When signing is enabled the UCP-Agent header points at the demo's local key
server (the signature itself is added by the httpx auth flow). Otherwise it
falls back to the historical placeholder and the legacy request-signature
header, so the unsigned demo behaves exactly as before.
"""
headers = {
"idempotency-key": str(uuid.uuid4()),
"request-id": str(uuid.uuid4()),
}
if _SIGNING_PROFILE_URL:
headers["UCP-Agent"] = f'profile="{_SIGNING_PROFILE_URL}"'
else:
headers["UCP-Agent"] = 'profile="https://agent.example/profile"'
headers["request-signature"] = "test"
return headers


def remove_none_values(obj):
Expand Down Expand Up @@ -154,6 +171,16 @@ def main() -> None:
help="Path to export requests and responses as markdown.",
)

parser.add_argument(
"--disable_signatures",
action="store_true",
help=(
"Do not sign requests. By default the client signs every request with "
"an ephemeral ES256 key and publishes the public key from a local "
"profile server for the merchant to verify."
),
)

args = parser.parse_args()

# Configure Logging
Expand All @@ -164,7 +191,18 @@ def main() -> None:

logger = logging.getLogger(__name__)

client = httpx.Client(base_url=args.server_url)
signer = None
if not args.disable_signatures:
global _SIGNING_PROFILE_URL
signer, profile_server = signing.build_signer()
profile_server.start()
_SIGNING_PROFILE_URL = profile_server.profile_url
logger.info(
"Signing requests with an ephemeral ES256 key; publishing it at %s",
_SIGNING_PROFILE_URL,
)

client = httpx.Client(base_url=args.server_url, auth=signer)

# Clear the export file if it exists
if args.export_requests_to:
Expand Down
39 changes: 38 additions & 1 deletion rest/python/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,43 @@ SERVER_PID=$!
Note: Keep the server running for the duration of running the client and the
following experiments.

## Request Signatures (RFC 9421)

The server verifies UCP request signatures as defined in the specification's
[`signatures.md`](https://github.com/Universal-Commerce-Protocol/ucp/blob/main/docs/specification/signatures.md):
[RFC 9421](https://www.rfc-editor.org/rfc/rfc9421.html) HTTP Message Signatures
with an [RFC 9530](https://www.rfc-editor.org/rfc/rfc9530.html) `Content-Digest`
over the raw body. The signer's public key is discovered from the profile URL in
the `UCP-Agent` header (its `keys[]`). `ES256` (fixed-width raw `r||s`, not
ASN.1/DER) is the baseline; `Ed25519` is also supported.

Behaviour is controlled by two flags:

| Flag | Default | Effect |
| ------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--require_signatures` | `false` | Reject requests whose signature is missing or invalid. When `false`, a present signature is still verified and the result logged, but unsigned or invalid requests are allowed — so existing clients keep working. |
| `--allow_insecure_profile_urls` | `false` | Permit `http` and loopback/private profile URLs when resolving keys. For localhost demos and CI only; it disables SSRF protections and must never be enabled in production. |

When verification fails under enforcement, the server returns the spec's error
code: `401 signature_missing` / `signature_invalid` / `key_not_found`,
`400 digest_mismatch` / `algorithm_unsupported` / `invalid_profile_url`,
`424 profile_unreachable`, or `422 profile_malformed`.

To see the full sign-then-verify loop locally, run the server with the demo
carve-out and let the client sign (it signs by default and publishes its key
from a local profile server):

```shell
uv run server.py \
--products_db_path=/tmp/ucp_test/products.db \
--transactions_db_path=/tmp/ucp_test/transactions.db \
--port=8182 --allow_insecure_profile_urls &
```

Each verified request logs
`RFC 9421 signature verified (keyid=..., profile=...)`. Add `--require_signatures`
to reject anything unsigned.

## Run a Simple Client

Exercise a simple checkout path: Once the server is running, execute the simple
Expand Down Expand Up @@ -237,7 +274,7 @@ Response:
}
]
},
"signing_keys": null
"keys": null
}
```

Expand Down
14 changes: 14 additions & 0 deletions rest/python/server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ def get_server_version() -> str:
"Secret key for simulation endpoints",
)
flags.DEFINE_integer("port", None, "Port to run the server on")
flags.DEFINE_boolean(
"require_signatures",
False,
"Reject requests whose RFC 9421 signature is missing or invalid. When "
"false (the default), signatures are still verified when present, but "
"unsigned or invalid requests are allowed and only logged.",
)
flags.DEFINE_boolean(
"allow_insecure_profile_urls",
False,
"Permit http and loopback/private UCP-Agent profile URLs when resolving "
"signer keys. For localhost demos and CI only; never enable in "
"production, as it disables SSRF protections.",
)
except flags.DuplicateFlagError:
pass

Expand Down
Loading
Loading